Merge branch 'plugins-v4' into 'develop'
Plugins infrastructure See merge request funkwhale/funkwhale!1155
This commit is contained in:
commit
1032e94eb4
|
@ -17,6 +17,7 @@ router = common_routers.OptionalSlashRouter()
|
|||
router.register(r"settings", GlobalPreferencesViewSet, basename="settings")
|
||||
router.register(r"activity", activity_views.ActivityViewSet, "activity")
|
||||
router.register(r"tags", tags_views.TagViewSet, "tags")
|
||||
router.register(r"plugins", common_views.PluginViewSet, "plugins")
|
||||
router.register(r"tracks", views.TrackViewSet, "tracks")
|
||||
router.register(r"uploads", views.UploadViewSet, "uploads")
|
||||
router.register(r"libraries", views.LibraryViewSet, "libraries")
|
||||
|
|
|
@ -0,0 +1,291 @@
|
|||
import copy
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import persisting_theory
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
logger = logging.getLogger("plugins")
|
||||
|
||||
|
||||
class Startup(persisting_theory.Registry):
|
||||
look_into = "persisting_theory"
|
||||
|
||||
|
||||
class Ready(persisting_theory.Registry):
|
||||
look_into = "persisting_theory"
|
||||
|
||||
|
||||
startup = Startup()
|
||||
ready = Ready()
|
||||
|
||||
_plugins = {}
|
||||
_filters = {}
|
||||
_hooks = {}
|
||||
|
||||
|
||||
def get_plugin_config(
|
||||
name,
|
||||
user=False,
|
||||
source=False,
|
||||
registry=_plugins,
|
||||
conf={},
|
||||
description=None,
|
||||
version=None,
|
||||
label=None,
|
||||
):
|
||||
conf = {
|
||||
"name": name,
|
||||
"label": label or name,
|
||||
"logger": logger,
|
||||
"conf": conf,
|
||||
"user": True if source else user,
|
||||
"source": source,
|
||||
"description": description,
|
||||
"version": version,
|
||||
}
|
||||
registry[name] = conf
|
||||
return conf
|
||||
|
||||
|
||||
def get_session():
|
||||
from funkwhale_api.common import session
|
||||
|
||||
return session.get_session()
|
||||
|
||||
|
||||
def register_filter(name, plugin_config, registry=_filters):
|
||||
def decorator(func):
|
||||
handlers = registry.setdefault(name, [])
|
||||
|
||||
def inner(*args, **kwargs):
|
||||
plugin_config["logger"].debug("Calling filter for %s", name)
|
||||
rval = func(*args, **kwargs)
|
||||
return rval
|
||||
|
||||
handlers.append((plugin_config["name"], inner))
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_hook(name, plugin_config, registry=_hooks):
|
||||
def decorator(func):
|
||||
handlers = registry.setdefault(name, [])
|
||||
|
||||
def inner(*args, **kwargs):
|
||||
plugin_config["logger"].debug("Calling hook for %s", name)
|
||||
func(*args, **kwargs)
|
||||
|
||||
handlers.append((plugin_config["name"], inner))
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Skip(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def trigger_filter(name, value, enabled=False, **kwargs):
|
||||
"""
|
||||
Call filters registered for "name" with the given
|
||||
args and kwargs.
|
||||
|
||||
Return the value (that could be modified by handlers)
|
||||
"""
|
||||
logger.debug("Calling handlers for filter %s", name)
|
||||
registry = kwargs.pop("registry", _filters)
|
||||
confs = kwargs.pop("confs", {})
|
||||
for plugin_name, handler in registry.get(name, []):
|
||||
if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
|
||||
continue
|
||||
try:
|
||||
value = handler(value, conf=confs.get(plugin_name, {}), **kwargs)
|
||||
except Skip:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warn("Plugin %s errored during filter %s: %s", plugin_name, name, e)
|
||||
return value
|
||||
|
||||
|
||||
def trigger_hook(name, enabled=False, **kwargs):
|
||||
"""
|
||||
Call hooks registered for "name" with the given
|
||||
args and kwargs.
|
||||
|
||||
Returns nothing
|
||||
"""
|
||||
logger.debug("Calling handlers for hook %s", name)
|
||||
registry = kwargs.pop("registry", _hooks)
|
||||
confs = kwargs.pop("confs", {})
|
||||
for plugin_name, handler in registry.get(name, []):
|
||||
if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
|
||||
continue
|
||||
try:
|
||||
handler(conf=confs.get(plugin_name, {}).get("conf"), **kwargs)
|
||||
except Skip:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warn("Plugin %s errored during hook %s: %s", plugin_name, name, e)
|
||||
|
||||
|
||||
def set_conf(name, conf, user=None, registry=_plugins):
|
||||
from funkwhale_api.common import models
|
||||
|
||||
if not registry[name]["conf"] and not registry[name]["source"]:
|
||||
return
|
||||
conf_serializer = get_serializer_from_conf_template(
|
||||
registry[name]["conf"], user=user, source=registry[name]["source"],
|
||||
)(data=conf)
|
||||
conf_serializer.is_valid(raise_exception=True)
|
||||
if "library" in conf_serializer.validated_data:
|
||||
conf_serializer.validated_data["library"] = str(
|
||||
conf_serializer.validated_data["library"]
|
||||
)
|
||||
conf, _ = models.PluginConfiguration.objects.update_or_create(
|
||||
user=user, code=name, defaults={"conf": conf_serializer.validated_data}
|
||||
)
|
||||
|
||||
|
||||
def get_confs(user=None):
|
||||
from funkwhale_api.common import models
|
||||
|
||||
qs = models.PluginConfiguration.objects.filter(code__in=list(_plugins.keys()))
|
||||
if user:
|
||||
qs = qs.filter(Q(user=None) | Q(user=user))
|
||||
else:
|
||||
qs = qs.filter(user=None)
|
||||
confs = {
|
||||
v["code"]: {"conf": v["conf"], "enabled": v["enabled"]}
|
||||
for v in qs.values("code", "conf", "enabled")
|
||||
}
|
||||
for p, v in _plugins.items():
|
||||
if p not in confs:
|
||||
confs[p] = {"conf": None, "enabled": False}
|
||||
return confs
|
||||
|
||||
|
||||
def get_conf(plugin, user=None):
|
||||
return get_confs(user=user)[plugin]
|
||||
|
||||
|
||||
def enable_conf(code, value, user):
|
||||
from funkwhale_api.common import models
|
||||
|
||||
models.PluginConfiguration.objects.update_or_create(
|
||||
code=code, user=user, defaults={"enabled": value}
|
||||
)
|
||||
|
||||
|
||||
class LibraryField(serializers.UUIDField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.actor = kwargs.pop("actor")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, v):
|
||||
v = super().to_internal_value(v)
|
||||
if not self.actor.libraries.filter(uuid=v).first():
|
||||
raise serializers.ValidationError("Invalid library id")
|
||||
return v
|
||||
|
||||
|
||||
def get_serializer_from_conf_template(conf, source=False, user=None):
|
||||
conf = copy.deepcopy(conf)
|
||||
validators = {f["name"]: f.pop("validator") for f in conf if "validator" in f}
|
||||
mapping = {
|
||||
"url": serializers.URLField,
|
||||
"boolean": serializers.BooleanField,
|
||||
"text": serializers.CharField,
|
||||
"long_text": serializers.CharField,
|
||||
"password": serializers.CharField,
|
||||
"number": serializers.IntegerField,
|
||||
}
|
||||
|
||||
for attr in ["label", "help"]:
|
||||
for c in conf:
|
||||
c.pop(attr, None)
|
||||
|
||||
class Serializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_conf in conf:
|
||||
field_kwargs = copy.copy(field_conf)
|
||||
name = field_kwargs.pop("name")
|
||||
self.fields[name] = mapping[field_kwargs.pop("type")](**field_kwargs)
|
||||
if source:
|
||||
self.fields["library"] = LibraryField(actor=user.actor)
|
||||
|
||||
for vname, v in validators.items():
|
||||
setattr(Serializer, "validate_{}".format(vname), v)
|
||||
return Serializer
|
||||
|
||||
|
||||
def serialize_plugin(plugin_conf, confs):
|
||||
return {
|
||||
"name": plugin_conf["name"],
|
||||
"label": plugin_conf["label"],
|
||||
"description": plugin_conf.get("description") or None,
|
||||
"user": plugin_conf.get("user", False),
|
||||
"source": plugin_conf.get("source", False),
|
||||
"conf": plugin_conf.get("conf", None),
|
||||
"values": confs.get(plugin_conf["name"], {"conf"}).get("conf"),
|
||||
"enabled": plugin_conf["name"] in confs
|
||||
and confs[plugin_conf["name"]]["enabled"],
|
||||
}
|
||||
|
||||
|
||||
def install_dependencies(deps):
|
||||
if not deps:
|
||||
return
|
||||
logger.info("Installing plugins dependencies %s", deps)
|
||||
pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
|
||||
subprocess.check_call([pip_path, "install"] + deps)
|
||||
|
||||
|
||||
def background_task(name):
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
def decorator(func):
|
||||
return celery.app.task(func, name=name)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# HOOKS
|
||||
LISTENING_CREATED = "listening_created"
|
||||
"""
|
||||
Called when a track is being listened
|
||||
"""
|
||||
SCAN = "scan"
|
||||
"""
|
||||
|
||||
"""
|
||||
# FILTERS
|
||||
PLUGINS_DEPENDENCIES = "plugins_dependencies"
|
||||
"""
|
||||
Called with an empty list, use this filter to append pip dependencies
|
||||
to the list for installation.
|
||||
"""
|
||||
PLUGINS_APPS = "plugins_apps"
|
||||
"""
|
||||
Called with an empty list, use this filter to append apps to INSTALLED_APPS
|
||||
"""
|
||||
MIDDLEWARES_BEFORE = "middlewares_before"
|
||||
"""
|
||||
Called with an empty list, use this filter to prepend middlewares
|
||||
to MIDDLEWARE
|
||||
"""
|
||||
MIDDLEWARES_AFTER = "middlewares_after"
|
||||
"""
|
||||
Called with an empty list, use this filter to append middlewares
|
||||
to MIDDLEWARE
|
||||
"""
|
||||
URLS = "urls"
|
||||
"""
|
||||
Called with an empty list, use this filter to register new urls and views
|
||||
"""
|
|
@ -46,6 +46,12 @@ logging.config.dictConfig(
|
|||
# required to avoid double logging with root logger
|
||||
"propagate": False,
|
||||
},
|
||||
"plugins": {
|
||||
"level": LOGLEVEL,
|
||||
"handlers": ["console"],
|
||||
# required to avoid double logging with root logger
|
||||
"propagate": False,
|
||||
},
|
||||
"": {"level": "WARNING", "handlers": ["console"]},
|
||||
},
|
||||
}
|
||||
|
@ -87,6 +93,20 @@ Path to a directory containing Funkwhale plugins. These will be imported at runt
|
|||
"""
|
||||
sys.path.append(FUNKWHALE_PLUGINS_PATH)
|
||||
|
||||
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
|
||||
"""
|
||||
List of Funkwhale plugins to load.
|
||||
"""
|
||||
if PLUGINS:
|
||||
logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
|
||||
else:
|
||||
logger.info("Running with no plugins")
|
||||
|
||||
from .. import plugins # noqa
|
||||
|
||||
plugins.startup.autodiscover([p + ".funkwhale_startup" for p in PLUGINS])
|
||||
DEPENDENCIES = plugins.trigger_filter(plugins.PLUGINS_DEPENDENCIES, [], enabled=True)
|
||||
plugins.install_dependencies(DEPENDENCIES)
|
||||
FUNKWHALE_HOSTNAME = None
|
||||
FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
|
||||
FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None)
|
||||
|
@ -247,16 +267,6 @@ LOCAL_APPS = (
|
|||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
|
||||
|
||||
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
|
||||
"""
|
||||
List of Funkwhale plugins to load.
|
||||
"""
|
||||
if PLUGINS:
|
||||
logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
|
||||
else:
|
||||
logger.info("Running with no plugins")
|
||||
|
||||
ADDITIONAL_APPS = env.list("ADDITIONAL_APPS", default=[])
|
||||
"""
|
||||
List of Django apps to load in addition to Funkwhale plugins and apps.
|
||||
|
@ -265,27 +275,32 @@ INSTALLED_APPS = (
|
|||
DJANGO_APPS
|
||||
+ THIRD_PARTY_APPS
|
||||
+ LOCAL_APPS
|
||||
+ tuple(["{}.apps.Plugin".format(p) for p in PLUGINS])
|
||||
+ tuple(ADDITIONAL_APPS)
|
||||
+ tuple(plugins.trigger_filter(plugins.PLUGINS_APPS, [], enabled=True))
|
||||
)
|
||||
|
||||
# MIDDLEWARE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
ADDITIONAL_MIDDLEWARES_BEFORE = env.list("ADDITIONAL_MIDDLEWARES_BEFORE", default=[])
|
||||
MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + (
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
# needs to be before SPA middleware
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
# /end
|
||||
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"funkwhale_api.users.middleware.RecordActivityMiddleware",
|
||||
"funkwhale_api.common.middleware.ThrottleStatusMiddleware",
|
||||
MIDDLEWARE = (
|
||||
tuple(plugins.trigger_filter(plugins.MIDDLEWARES_BEFORE, [], enabled=True))
|
||||
+ tuple(ADDITIONAL_MIDDLEWARES_BEFORE)
|
||||
+ (
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
# needs to be before SPA middleware
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
# /end
|
||||
"funkwhale_api.common.middleware.SPAFallbackMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"funkwhale_api.users.middleware.RecordActivityMiddleware",
|
||||
"funkwhale_api.common.middleware.ThrottleStatusMiddleware",
|
||||
)
|
||||
+ tuple(plugins.trigger_filter(plugins.MIDDLEWARES_AFTER, [], enabled=True))
|
||||
)
|
||||
|
||||
# DEBUG
|
||||
|
|
|
@ -8,7 +8,9 @@ from django.conf.urls.static import static
|
|||
from funkwhale_api.common import admin
|
||||
from django.views import defaults as default_views
|
||||
|
||||
from config import plugins
|
||||
|
||||
plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
url(settings.ADMIN_URL, admin.site.urls),
|
||||
|
@ -21,8 +23,7 @@ urlpatterns = [
|
|||
),
|
||||
url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
|
||||
url(r"^accounts/", include("allauth.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
]
|
||||
] + plugins_patterns
|
||||
|
||||
if settings.DEBUG:
|
||||
# This allows the error pages to be debugged during development, just visit
|
||||
|
|
|
@ -4,6 +4,7 @@ import sys
|
|||
from . import base
|
||||
from . import library # noqa
|
||||
from . import media # noqa
|
||||
from . import plugins # noqa
|
||||
from . import users # noqa
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import click
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
from . import base
|
||||
|
||||
|
||||
@base.cli.group()
|
||||
def plugins():
|
||||
"""Manage plugins"""
|
||||
pass
|
||||
|
||||
|
||||
@plugins.command("install")
|
||||
@click.argument("plugin", nargs=-1)
|
||||
def install(plugin):
|
||||
"""
|
||||
Install a plugin from a given URL (zip, pip or git are supported)
|
||||
"""
|
||||
if not plugin:
|
||||
return click.echo("No plugin provided")
|
||||
|
||||
click.echo("Installing plugins…")
|
||||
pip_install(list(plugin), settings.FUNKWHALE_PLUGINS_PATH)
|
||||
|
||||
|
||||
def pip_install(deps, target):
|
||||
if not deps:
|
||||
return
|
||||
pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
|
||||
subprocess.check_call([pip_path, "install", "-t", target] + deps)
|
|
@ -1,4 +1,7 @@
|
|||
from django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
|
||||
from config import plugins
|
||||
|
||||
from . import mutations
|
||||
from . import utils
|
||||
|
@ -13,3 +16,4 @@ class CommonConfig(AppConfig):
|
|||
app_names = [app.name for app in apps.app_configs.values()]
|
||||
mutations.registry.autodiscover(app_names)
|
||||
utils.monkey_patch_request_build_absolute_uri()
|
||||
plugins.startup.autodiscover([p + ".funkwhale_ready" for p in settings.PLUGINS])
|
||||
|
|
|
@ -35,3 +35,12 @@ class CommonFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
|
||||
class Meta:
|
||||
model = "common.Content"
|
||||
|
||||
|
||||
@registry.register
|
||||
class PluginConfiguration(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
code = "test"
|
||||
conf = {"foo": "bar"}
|
||||
|
||||
class Meta:
|
||||
model = "common.PluginConfiguration"
|
||||
|
|
|
@ -11,6 +11,7 @@ from django import http
|
|||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from django.middleware import csrf
|
||||
from django.contrib import auth
|
||||
from django import urls
|
||||
from rest_framework import views
|
||||
|
||||
|
@ -282,6 +283,25 @@ def monkey_patch_rest_initialize_request():
|
|||
monkey_patch_rest_initialize_request()
|
||||
|
||||
|
||||
def monkey_patch_auth_get_user():
|
||||
"""
|
||||
We need an actor on our users for many endpoints, so we monkey patch
|
||||
auth.get_user to create it if it's missing
|
||||
"""
|
||||
original = auth.get_user
|
||||
|
||||
def replacement(request):
|
||||
r = original(request)
|
||||
if not r.is_anonymous and not r.actor:
|
||||
r.create_actor()
|
||||
return r
|
||||
|
||||
setattr(auth, "get_user", replacement)
|
||||
|
||||
|
||||
monkey_patch_auth_get_user()
|
||||
|
||||
|
||||
class ThrottleStatusMiddleware:
|
||||
"""
|
||||
Include useful information regarding throttling in API responses to
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.0.8 on 2020-07-01 13:17
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('common', '0007_auto_20200116_1610'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attachment',
|
||||
name='url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PluginConfiguration',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=100)),
|
||||
('conf', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plugins', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'code')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -363,3 +363,24 @@ def remove_attached_content(sender, instance, **kwargs):
|
|||
getattr(instance, field).delete()
|
||||
except Content.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class PluginConfiguration(models.Model):
|
||||
"""
|
||||
Store plugin configuration in DB
|
||||
"""
|
||||
|
||||
code = models.CharField(max_length=100)
|
||||
user = models.ForeignKey(
|
||||
"users.User",
|
||||
related_name="plugins",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
conf = JSONField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=False)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "code")
|
||||
|
|
|
@ -12,6 +12,8 @@ from rest_framework import response
|
|||
from rest_framework import views
|
||||
from rest_framework import viewsets
|
||||
|
||||
from config import plugins
|
||||
|
||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||
|
||||
from . import filters
|
||||
|
@ -210,3 +212,102 @@ class TextPreviewView(views.APIView):
|
|||
)
|
||||
}
|
||||
return response.Response(data, status=200)
|
||||
|
||||
|
||||
class PluginViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
required_scope = "plugins"
|
||||
serializer_class = serializers.serializers.Serializer
|
||||
queryset = models.PluginConfiguration.objects.none()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
user_plugins = [p for p in plugins._plugins.values() if p["user"] is True]
|
||||
|
||||
return response.Response(
|
||||
[
|
||||
plugins.serialize_plugin(p, confs=plugins.get_confs(user=user))
|
||||
for p in user_plugins
|
||||
]
|
||||
)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
user_plugin = [
|
||||
p
|
||||
for p in plugins._plugins.values()
|
||||
if p["user"] is True and p["name"] == kwargs["pk"]
|
||||
]
|
||||
if not user_plugin:
|
||||
return response.Response(status=404)
|
||||
|
||||
return response.Response(
|
||||
plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.create(request, *args, **kwargs)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
confs = plugins.get_confs(user=user)
|
||||
|
||||
user_plugin = [
|
||||
p
|
||||
for p in plugins._plugins.values()
|
||||
if p["user"] is True and p["name"] == kwargs["pk"]
|
||||
]
|
||||
if kwargs["pk"] not in confs:
|
||||
return response.Response(status=404)
|
||||
plugins.set_conf(kwargs["pk"], request.data, user)
|
||||
return response.Response(
|
||||
plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
|
||||
)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
confs = plugins.get_confs(user=user)
|
||||
if kwargs["pk"] not in confs:
|
||||
return response.Response(status=404)
|
||||
|
||||
user.plugins.filter(code=kwargs["pk"]).delete()
|
||||
return response.Response(status=204)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def enable(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
if kwargs["pk"] not in plugins._plugins:
|
||||
return response.Response(status=404)
|
||||
plugins.enable_conf(kwargs["pk"], True, user)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def disable(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
if kwargs["pk"] not in plugins._plugins:
|
||||
return response.Response(status=404)
|
||||
plugins.enable_conf(kwargs["pk"], False, user)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def scan(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
if kwargs["pk"] not in plugins._plugins:
|
||||
return response.Response(status=404)
|
||||
conf = plugins.get_conf(kwargs["pk"], user=user)
|
||||
|
||||
if not conf["enabled"]:
|
||||
return response.Response(status=405)
|
||||
|
||||
library = request.user.actor.libraries.get(uuid=conf["conf"]["library"])
|
||||
hook = [
|
||||
hook
|
||||
for p, hook in plugins._hooks.get(plugins.SCAN, [])
|
||||
if p == kwargs["pk"]
|
||||
]
|
||||
|
||||
if not hook:
|
||||
return response.Response(status=405)
|
||||
|
||||
hook[0](library=library, conf=conf["conf"])
|
||||
|
||||
return response.Response({}, status=200)
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
from config import plugins
|
||||
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
from . import scrobbler
|
||||
|
||||
# https://listenbrainz.org/lastfm-proxy
|
||||
DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
||||
def forward_to_scrobblers(listening, conf, **kwargs):
|
||||
if not conf:
|
||||
raise plugins.Skip()
|
||||
|
||||
username = conf.get("username")
|
||||
password = conf.get("password")
|
||||
url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL
|
||||
if username and password:
|
||||
PLUGIN["logger"].info("Forwarding scrobbler to %s", url)
|
||||
session = plugins.get_session()
|
||||
session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1(
|
||||
session=session, url=url, username=username, password=password
|
||||
)
|
||||
scrobbler.submit_now_playing_v1(
|
||||
session=session,
|
||||
track=listening.track,
|
||||
session_key=session_key,
|
||||
now_playing_url=now_playing_url,
|
||||
)
|
||||
scrobbler.submit_scrobble_v1(
|
||||
session=session,
|
||||
track=listening.track,
|
||||
scrobble_time=listening.creation_date,
|
||||
session_key=session_key,
|
||||
scrobble_url=scrobble_url,
|
||||
)
|
||||
else:
|
||||
PLUGIN["logger"].debug("No scrobbler configuration for user, skipping")
|
|
@ -0,0 +1,27 @@
|
|||
from config import plugins
|
||||
|
||||
PLUGIN = plugins.get_plugin_config(
|
||||
name="scrobbler",
|
||||
label="Scrobbler",
|
||||
description="A plugin that enables scrobbling to ListenBrainz and Last.fm",
|
||||
version="0.1",
|
||||
user=True,
|
||||
conf=[
|
||||
{
|
||||
"name": "url",
|
||||
"type": "url",
|
||||
"allow_null": True,
|
||||
"allow_blank": True,
|
||||
"required": False,
|
||||
"label": "URL of the scrobbler service",
|
||||
"help": (
|
||||
"Suggested choices:\n\n"
|
||||
"- LastFM (default if left empty): http://post.audioscrobbler.com\n"
|
||||
"- ListenBrainz: http://proxy.listenbrainz.org/\n"
|
||||
"- Libre.fm: http://turtle.libre.fm/"
|
||||
),
|
||||
},
|
||||
{"name": "username", "type": "text", "label": "Your scrobbler username"},
|
||||
{"name": "password", "type": "password", "label": "Your scrobbler password"},
|
||||
],
|
||||
)
|
|
@ -0,0 +1,98 @@
|
|||
import hashlib
|
||||
import time
|
||||
|
||||
|
||||
# https://github.com/jlieth/legacy-scrobbler
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
|
||||
class ScrobblerException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def handshake_v1(session, url, username, password):
|
||||
timestamp = str(int(time.time())).encode("utf-8")
|
||||
password_hash = hashlib.md5(password.encode("utf-8")).hexdigest()
|
||||
auth = hashlib.md5(password_hash.encode("utf-8") + timestamp).hexdigest()
|
||||
params = {
|
||||
"hs": "true",
|
||||
"p": "1.2",
|
||||
"c": PLUGIN["name"],
|
||||
"v": PLUGIN["version"],
|
||||
"u": username,
|
||||
"t": timestamp,
|
||||
"a": auth,
|
||||
}
|
||||
|
||||
PLUGIN["logger"].debug(
|
||||
"Performing scrobbler handshake for username %s at %s", username, url
|
||||
)
|
||||
handshake_response = session.get(url, params=params)
|
||||
# process response
|
||||
result = handshake_response.text.split("\n")
|
||||
if len(result) >= 4 and result[0] == "OK":
|
||||
session_key = result[1]
|
||||
nowplaying_url = result[2]
|
||||
scrobble_url = result[3]
|
||||
elif result[0] == "BANNED":
|
||||
raise ScrobblerException("BANNED")
|
||||
elif result[0] == "BADAUTH":
|
||||
raise ScrobblerException("BADAUTH")
|
||||
elif result[0] == "BADTIME":
|
||||
raise ScrobblerException("BADTIME")
|
||||
else:
|
||||
raise ScrobblerException(handshake_response.text)
|
||||
|
||||
PLUGIN["logger"].debug("Handshake successful, scrobble url: %s", scrobble_url)
|
||||
return session_key, nowplaying_url, scrobble_url
|
||||
|
||||
|
||||
def submit_scrobble_v1(session, scrobble_time, track, session_key, scrobble_url):
|
||||
payload = get_scrobble_payload(track, scrobble_time)
|
||||
PLUGIN["logger"].debug("Sending scrobble with payload %s", payload)
|
||||
payload["s"] = session_key
|
||||
response = session.post(scrobble_url, payload)
|
||||
response.raise_for_status()
|
||||
if response.text.startswith("OK"):
|
||||
return
|
||||
elif response.text.startswith("BADSESSION"):
|
||||
raise ScrobblerException("Remote server says the session is invalid")
|
||||
else:
|
||||
raise ScrobblerException(response.text)
|
||||
|
||||
PLUGIN["logger"].debug("Scrobble successfull!")
|
||||
|
||||
|
||||
def submit_now_playing_v1(session, track, session_key, now_playing_url):
|
||||
payload = get_scrobble_payload(track, date=None, suffix="")
|
||||
PLUGIN["logger"].debug("Sending now playing with payload %s", payload)
|
||||
payload["s"] = session_key
|
||||
response = session.post(now_playing_url, payload)
|
||||
response.raise_for_status()
|
||||
if response.text.startswith("OK"):
|
||||
return
|
||||
elif response.text.startswith("BADSESSION"):
|
||||
raise ScrobblerException("Remote server says the session is invalid")
|
||||
else:
|
||||
raise ScrobblerException(response.text)
|
||||
|
||||
PLUGIN["logger"].debug("Now playing successfull!")
|
||||
|
||||
|
||||
def get_scrobble_payload(track, date, suffix="[0]"):
|
||||
"""
|
||||
Documentation available at https://web.archive.org/web/20190531021725/https://www.last.fm/api/submissions
|
||||
"""
|
||||
upload = track.uploads.filter(duration__gte=0).first()
|
||||
data = {
|
||||
"a{}".format(suffix): track.artist.name,
|
||||
"t{}".format(suffix): track.title,
|
||||
"l{}".format(suffix): upload.duration if upload else 0,
|
||||
"b{}".format(suffix): (track.album.title if track.album else "") or "",
|
||||
"n{}".format(suffix): track.position or "",
|
||||
"m{}".format(suffix): str(track.mbid) or "",
|
||||
"o{}".format(suffix): "P", # Source: P = chosen by user
|
||||
}
|
||||
if date:
|
||||
data["i{}".format(suffix)] = int(date.timestamp())
|
||||
return data
|
|
@ -2,6 +2,8 @@ from rest_framework import mixins, viewsets
|
|||
|
||||
from django.db.models import Prefetch
|
||||
|
||||
from config import plugins
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common import fields, permissions
|
||||
from funkwhale_api.music.models import Track
|
||||
|
@ -39,6 +41,11 @@ class ListeningViewSet(
|
|||
|
||||
def perform_create(self, serializer):
|
||||
r = super().perform_create(serializer)
|
||||
plugins.trigger_hook(
|
||||
plugins.LISTENING_CREATED,
|
||||
listening=serializer.instance,
|
||||
confs=plugins.get_confs(self.request.user),
|
||||
)
|
||||
record.send(serializer.instance)
|
||||
return r
|
||||
|
||||
|
|
|
@ -655,9 +655,7 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
|
|||
and to_update.track.attributed_to != library.actor
|
||||
):
|
||||
stdout.write(
|
||||
" Cannot update track metadata, track belongs to someone else".format(
|
||||
to_update.pk
|
||||
)
|
||||
" Cannot update track metadata, track belongs to someone else"
|
||||
)
|
||||
return
|
||||
else:
|
||||
|
@ -777,9 +775,7 @@ def check_upload(stdout, upload):
|
|||
)
|
||||
if upload.library.actor_id != upload.track.attributed_to_id:
|
||||
stdout.write(
|
||||
" Cannot update track metadata, track belongs to someone else".format(
|
||||
upload.pk
|
||||
)
|
||||
" Cannot update track metadata, track belongs to someone else"
|
||||
)
|
||||
else:
|
||||
track = models.Track.objects.select_related("artist", "album__artist").get(
|
||||
|
|
|
@ -103,7 +103,9 @@ class UserQuerySet(models.QuerySet):
|
|||
user=models.OuterRef("id"), primary=True
|
||||
).values("verified")[:1]
|
||||
subquery = models.Subquery(verified_emails)
|
||||
return qs.annotate(has_verified_primary_email=subquery)
|
||||
return qs.annotate(has_verified_primary_email=subquery).prefetch_related(
|
||||
"plugins"
|
||||
)
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
|
|
|
@ -23,6 +23,7 @@ BASE_SCOPES = [
|
|||
Scope("notifications", "Access personal notifications"),
|
||||
Scope("security", "Access security settings"),
|
||||
Scope("reports", "Access reports"),
|
||||
Scope("plugins", "Access plugins"),
|
||||
# Privileged scopes that require specific user permissions
|
||||
Scope("instance:settings", "Access instance settings"),
|
||||
Scope("instance:users", "Access local user accounts"),
|
||||
|
@ -81,7 +82,12 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | {
|
|||
"write:listenings",
|
||||
}
|
||||
|
||||
LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"}
|
||||
LOGGED_IN_SCOPES = COMMON_SCOPES | {
|
||||
"read:security",
|
||||
"write:security",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
}
|
||||
|
||||
# We don't allow admin access for oauth apps yet
|
||||
OAUTH_APP_SCOPES = COMMON_SCOPES
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[flake8]
|
||||
max-line-length = 120
|
||||
exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,tests/data,tests/music/conftest.py
|
||||
ignore = F405,W503,E203
|
||||
ignore = F405,W503,E203,E741
|
||||
|
||||
[isort]
|
||||
skip_glob = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
|
||||
|
@ -35,3 +35,4 @@ env =
|
|||
EXTERNAL_MEDIA_PROXY_ENABLED=true
|
||||
DISABLE_PASSWORD_VALIDATORS=false
|
||||
DISABLE_PASSWORD_VALIDATORS=false
|
||||
FUNKWHALE_PLUGINS=
|
||||
|
|
|
@ -67,11 +67,11 @@ def test_domain_create(superuser_api_client, mocker):
|
|||
"funkwhale_api.federation.tasks.update_domain_nodeinfo"
|
||||
)
|
||||
url = reverse("api:v1:manage:federation:domains-list")
|
||||
response = superuser_api_client.post(url, {"name": "test.federation"})
|
||||
response = superuser_api_client.post(url, {"name": "test.domain"})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert federation_models.Domain.objects.filter(pk="test.federation").exists()
|
||||
update_domain_nodeinfo.assert_called_once_with(domain_name="test.federation")
|
||||
assert federation_models.Domain.objects.filter(pk="test.domain").exists()
|
||||
update_domain_nodeinfo.assert_called_once_with(domain_name="test.domain")
|
||||
|
||||
|
||||
def test_domain_update_allowed(superuser_api_client, factories):
|
||||
|
@ -85,6 +85,8 @@ def test_domain_update_allowed(superuser_api_client, factories):
|
|||
|
||||
|
||||
def test_domain_update_cannot_change_name(superuser_api_client, factories):
|
||||
superuser_api_client.user.create_actor()
|
||||
|
||||
domain = factories["federation.Domain"]()
|
||||
old_name = domain.name
|
||||
url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": old_name})
|
||||
|
@ -96,7 +98,9 @@ def test_domain_update_cannot_change_name(superuser_api_client, factories):
|
|||
assert domain.name == old_name
|
||||
# changing the pk of a model and saving results in a new DB entry in django,
|
||||
# so we check that no other entry was created
|
||||
assert domain.__class__.objects.count() == 1
|
||||
assert (
|
||||
domain.__class__.objects.count() == 2
|
||||
) # one for pod domain, and the other one
|
||||
|
||||
|
||||
def test_domain_nodeinfo(factories, superuser_api_client, mocker):
|
||||
|
@ -131,8 +135,8 @@ def test_actor_list(factories, superuser_api_client, settings):
|
|||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == actor.id
|
||||
assert response.data["count"] == 2
|
||||
assert response.data["results"][1]["id"] == actor.id
|
||||
|
||||
|
||||
def test_actor_detail(factories, superuser_api_client):
|
||||
|
|
|
@ -0,0 +1,424 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import models
|
||||
from config import plugins
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plugins():
|
||||
plugins._filters.clear()
|
||||
plugins._hooks.clear()
|
||||
plugins._plugins.clear()
|
||||
yield
|
||||
plugins._filters.clear()
|
||||
plugins._hooks.clear()
|
||||
plugins._plugins.clear()
|
||||
|
||||
|
||||
def test_register_filter():
|
||||
filters = {}
|
||||
plugin_config = plugins.get_plugin_config("test", {})
|
||||
|
||||
def handler(value, conf):
|
||||
return value + 1
|
||||
|
||||
plugins.register_filter("test_filter", plugin_config, filters)(handler)
|
||||
plugins.register_filter("test_filter", plugin_config, filters)(handler)
|
||||
|
||||
assert len(filters["test_filter"]) == 2
|
||||
assert plugins.trigger_filter("test_filter", 1, confs={}, registry=filters) == 3
|
||||
|
||||
|
||||
def test_register_hook(mocker):
|
||||
hooks = {}
|
||||
plugin_config = plugins.get_plugin_config("test", {})
|
||||
mock = mocker.Mock()
|
||||
|
||||
def handler(conf):
|
||||
mock()
|
||||
|
||||
plugins.register_hook("test_hook", plugin_config, hooks)(handler)
|
||||
plugins.register_hook("test_hook", plugin_config, hooks)(handler)
|
||||
plugins.trigger_hook("test_hook", confs={}, registry=hooks)
|
||||
assert mock.call_count == 2
|
||||
assert len(hooks["test_hook"]) == 2
|
||||
|
||||
|
||||
def test_get_plugin_conf():
|
||||
_plugins = {}
|
||||
plugin_config = plugins.get_plugin_config(
|
||||
"test", description="Hello", registry=_plugins
|
||||
)
|
||||
assert plugin_config["name"] == "test"
|
||||
assert plugin_config["description"] == "Hello"
|
||||
assert plugin_config["user"] is False
|
||||
assert _plugins == {
|
||||
"test": plugin_config,
|
||||
}
|
||||
|
||||
|
||||
def test_set_plugin_conf_validates():
|
||||
_plugins = {}
|
||||
plugins.get_plugin_config(
|
||||
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
|
||||
)
|
||||
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
plugins.set_conf("test", {"foo": "noop"}, registry=_plugins)
|
||||
|
||||
|
||||
def test_set_plugin_conf_valid():
|
||||
_plugins = {}
|
||||
plugins.get_plugin_config(
|
||||
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
|
||||
)
|
||||
plugins.set_conf("test", {"foo": True}, registry=_plugins)
|
||||
|
||||
conf = models.PluginConfiguration.objects.latest("id")
|
||||
assert conf.code == "test"
|
||||
assert conf.conf == {"foo": True}
|
||||
assert conf.user is None
|
||||
|
||||
|
||||
def test_set_plugin_conf_valid_user(factories):
|
||||
user = factories["users.User"]()
|
||||
_plugins = {}
|
||||
plugins.get_plugin_config(
|
||||
"test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
|
||||
)
|
||||
|
||||
plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
|
||||
|
||||
conf = models.PluginConfiguration.objects.latest("id")
|
||||
assert conf.code == "test"
|
||||
assert conf.conf == {"foo": True}
|
||||
assert conf.user == user
|
||||
|
||||
|
||||
def test_get_confs(factories):
|
||||
plugins.get_plugin_config("test1")
|
||||
plugins.get_plugin_config("test2")
|
||||
factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
|
||||
factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
|
||||
|
||||
assert plugins.get_confs() == {
|
||||
"test1": {"conf": {"hello": "world"}, "enabled": False},
|
||||
"test2": {"conf": {"foo": "bar"}, "enabled": False},
|
||||
}
|
||||
|
||||
|
||||
def test_get_confs_user(factories):
|
||||
plugins.get_plugin_config("test1")
|
||||
plugins.get_plugin_config("test2")
|
||||
plugins.get_plugin_config("test3")
|
||||
user1 = factories["users.User"]()
|
||||
user2 = factories["users.User"]()
|
||||
factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
|
||||
factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
|
||||
factories["common.PluginConfiguration"](
|
||||
code="test3", conf={"user": True}, user=user1
|
||||
)
|
||||
factories["common.PluginConfiguration"](
|
||||
code="test4", conf={"user": False}, user=user2
|
||||
)
|
||||
|
||||
assert plugins.get_confs(user=user1) == {
|
||||
"test1": {"conf": {"hello": "world"}, "enabled": False},
|
||||
"test2": {"conf": {"foo": "bar"}, "enabled": False},
|
||||
"test3": {"conf": {"user": True}, "enabled": False},
|
||||
}
|
||||
|
||||
|
||||
def test_filter_is_called_with_plugin_conf(mocker, factories):
|
||||
plugins.get_plugin_config("test1",)
|
||||
plugins.get_plugin_config("test2",)
|
||||
factories["common.PluginConfiguration"](code="test1", enabled=True)
|
||||
factories["common.PluginConfiguration"](
|
||||
code="test2", conf={"foo": "baz"}, enabled=True
|
||||
)
|
||||
confs = plugins.get_confs()
|
||||
filters = {}
|
||||
plugin_config1 = plugins.get_plugin_config("test1", {})
|
||||
plugin_config2 = plugins.get_plugin_config("test2", {})
|
||||
|
||||
handler1 = mocker.Mock()
|
||||
handler2 = mocker.Mock()
|
||||
|
||||
plugins.register_filter("test_filter", plugin_config1, filters)(handler1)
|
||||
plugins.register_filter("test_filter", plugin_config2, filters)(handler2)
|
||||
|
||||
plugins.trigger_filter("test_filter", 1, confs=confs, registry=filters)
|
||||
|
||||
handler1.assert_called_once_with(1, conf=confs["test1"])
|
||||
handler2.assert_called_once_with(handler1.return_value, conf=confs["test2"])
|
||||
|
||||
|
||||
def test_get_serializer_from_conf_template():
|
||||
template = [
|
||||
{
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"label": "Enable plugin",
|
||||
},
|
||||
{
|
||||
"name": "api_url",
|
||||
"type": "url",
|
||||
"label": "URL of the scrobbler API",
|
||||
"validator": lambda self, v: v + "/test",
|
||||
},
|
||||
]
|
||||
|
||||
serializer_class = plugins.get_serializer_from_conf_template(template)
|
||||
|
||||
data = {
|
||||
"enabled": True,
|
||||
"api_url": "http://hello.world",
|
||||
}
|
||||
|
||||
serializer = serializer_class(data=data)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data == {
|
||||
"enabled": True,
|
||||
"api_url": "http://hello.world/test",
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_plugin():
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
)
|
||||
|
||||
expected = {
|
||||
"name": "test_plugin",
|
||||
"enabled": False,
|
||||
"description": "Hello world",
|
||||
"conf": [{"name": "foo", "type": "boolean"}],
|
||||
"user": False,
|
||||
"source": False,
|
||||
"label": "test_plugin",
|
||||
"values": None,
|
||||
}
|
||||
|
||||
assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected
|
||||
|
||||
|
||||
def test_serialize_plugin_user(factories):
|
||||
user = factories["users.User"]()
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
|
||||
expected = {
|
||||
"name": "test_plugin",
|
||||
"enabled": False,
|
||||
"description": "Hello world",
|
||||
"conf": [{"name": "foo", "type": "boolean"}],
|
||||
"user": True,
|
||||
"source": False,
|
||||
"label": "test_plugin",
|
||||
"values": None,
|
||||
}
|
||||
|
||||
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
||||
|
||||
|
||||
def test_serialize_plugin_user_enabled(factories):
|
||||
user = factories["users.User"]()
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
|
||||
factories["common.PluginConfiguration"](
|
||||
code="test_plugin", user=user, enabled=True, conf={"foo": "bar"}
|
||||
)
|
||||
expected = {
|
||||
"name": "test_plugin",
|
||||
"enabled": True,
|
||||
"description": "Hello world",
|
||||
"conf": [{"name": "foo", "type": "boolean"}],
|
||||
"user": True,
|
||||
"source": False,
|
||||
"label": "test_plugin",
|
||||
"values": {"foo": "bar"},
|
||||
}
|
||||
|
||||
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
||||
|
||||
|
||||
def test_can_list_user_plugins(logged_in_api_client):
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.get_plugin_config(name="test_plugin2", user=False)
|
||||
url = reverse("api:v1:plugins-list")
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == [
|
||||
plugins.serialize_plugin(plugin, plugins.get_confs(logged_in_api_client.user))
|
||||
]
|
||||
|
||||
|
||||
def test_can_retrieve_user_plugin(logged_in_api_client):
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.get_plugin_config(name="test_plugin2", user=False)
|
||||
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == plugins.serialize_plugin(
|
||||
plugin, plugins.get_confs(logged_in_api_client.user)
|
||||
)
|
||||
|
||||
|
||||
def test_can_update_user_plugin(logged_in_api_client):
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.get_plugin_config(name="test_plugin2", user=False)
|
||||
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
|
||||
response = logged_in_api_client.post(url, {"foo": True})
|
||||
assert response.status_code == 200
|
||||
assert logged_in_api_client.user.plugins.latest("id").conf == {"foo": True}
|
||||
assert response.data == plugins.serialize_plugin(
|
||||
plugin, plugins.get_confs(logged_in_api_client.user)
|
||||
)
|
||||
|
||||
|
||||
def test_can_destroy_user_plugin(logged_in_api_client):
|
||||
plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
|
||||
plugins.get_plugin_config(name="test_plugin2", user=False)
|
||||
url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
|
||||
response = logged_in_api_client.delete(url, {"enabled": True})
|
||||
assert response.status_code == 204
|
||||
|
||||
with pytest.raises(models.PluginConfiguration.DoesNotExist):
|
||||
assert logged_in_api_client.user.plugins.latest("id")
|
||||
|
||||
|
||||
def test_can_enable_user_plugin(logged_in_api_client):
|
||||
plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
|
||||
url = reverse("api:v1:plugins-enable", kwargs={"pk": "test_plugin"})
|
||||
response = logged_in_api_client.post(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert logged_in_api_client.user.plugins.latest("id").enabled is True
|
||||
|
||||
|
||||
def test_can_disable_user_plugin(logged_in_api_client):
|
||||
plugins.get_plugin_config(
|
||||
name="test_plugin",
|
||||
description="Hello world",
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
user=True,
|
||||
)
|
||||
plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
|
||||
url = reverse("api:v1:plugins-disable", kwargs={"pk": "test_plugin"})
|
||||
response = logged_in_api_client.post(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert logged_in_api_client.user.plugins.latest("id").enabled is False
|
||||
|
||||
|
||||
def test_can_install_dependencies(mocker):
|
||||
dependencies = ["depa==12", "depb"]
|
||||
check_call = mocker.patch("subprocess.check_call")
|
||||
expected = [
|
||||
os.path.join(os.path.dirname(sys.executable), "pip"),
|
||||
"install",
|
||||
] + dependencies
|
||||
plugins.install_dependencies(dependencies)
|
||||
check_call.assert_called_once_with(expected)
|
||||
|
||||
|
||||
def test_set_plugin_source_conf_invalid(factories):
|
||||
user = factories["users.User"]()
|
||||
_plugins = {}
|
||||
plugins.get_plugin_config(
|
||||
"test",
|
||||
source=True,
|
||||
registry=_plugins,
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
)
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
|
||||
|
||||
|
||||
def test_set_plugin_source_conf_valid(factories):
|
||||
library = factories["music.Library"](actor__local=True)
|
||||
_plugins = {}
|
||||
plugins.get_plugin_config(
|
||||
"test",
|
||||
source=True,
|
||||
registry=_plugins,
|
||||
conf=[{"name": "foo", "type": "boolean"}],
|
||||
)
|
||||
plugins.set_conf(
|
||||
"test",
|
||||
{"foo": True, "library": library.uuid},
|
||||
user=library.actor.user,
|
||||
registry=_plugins,
|
||||
)
|
||||
conf = models.PluginConfiguration.objects.latest("id")
|
||||
assert conf.code == "test"
|
||||
assert conf.conf == {"foo": True, "library": str(library.uuid)}
|
||||
assert conf.user == library.actor.user
|
||||
|
||||
|
||||
def test_can_trigger_scan(logged_in_api_client, mocker, factories):
|
||||
library = factories["music.Library"](actor=logged_in_api_client.user.create_actor())
|
||||
plugin = plugins.get_plugin_config(
|
||||
name="test_plugin", description="Hello world", conf=[], source=True,
|
||||
)
|
||||
handler = mocker.Mock()
|
||||
plugins.register_hook(plugins.SCAN, plugin)(handler)
|
||||
plugins.set_conf(
|
||||
"test_plugin", {"library": library.uuid}, user=logged_in_api_client.user
|
||||
)
|
||||
url = reverse("api:v1:plugins-scan", kwargs={"pk": "test_plugin"})
|
||||
plugins.enable_conf("test_plugin", True, logged_in_api_client.user)
|
||||
response = logged_in_api_client.post(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
handler.assert_called_once_with(
|
||||
library=library, conf={"library": str(library.uuid)}
|
||||
)
|
|
@ -34,6 +34,9 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:listenings",
|
||||
"read:security",
|
||||
"write:security",
|
||||
"write:listenings",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
"read:instance:policies",
|
||||
"write:instance:policies",
|
||||
"read:instance:accounts",
|
||||
|
@ -85,6 +88,8 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:listenings",
|
||||
"read:security",
|
||||
"write:security",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
"read:instance:policies",
|
||||
"write:instance:policies",
|
||||
"read:instance:accounts",
|
||||
|
@ -132,6 +137,8 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:listenings",
|
||||
"read:security",
|
||||
"write:security",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
"read:instance:policies",
|
||||
"write:instance:policies",
|
||||
"read:instance:accounts",
|
||||
|
@ -173,6 +180,8 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:listenings",
|
||||
"read:security",
|
||||
"write:security",
|
||||
"read:plugins",
|
||||
"write:plugins",
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
@ -23,6 +23,7 @@ import datetime
|
|||
from shutil import copyfile
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../api"))
|
||||
sys.path.insert(0, os.path.abspath("../api/config"))
|
||||
|
||||
import funkwhale_api # NOQA
|
||||
|
||||
|
@ -30,9 +31,9 @@ FUNKWHALE_CONFIG = {
|
|||
"FUNKWHALE_URL": "mypod.funkwhale",
|
||||
"FUNKWHAL_PROTOCOL": "https",
|
||||
"DATABASE_URL": "postgres://localhost:5432/db",
|
||||
"AWS_ACCESS_KEY_ID": 'my_access_key',
|
||||
"AWS_SECRET_ACCESS_KEY": 'my_secret_key',
|
||||
"AWS_STORAGE_BUCKET_NAME": 'my_bucket',
|
||||
"AWS_ACCESS_KEY_ID": "my_access_key",
|
||||
"AWS_SECRET_ACCESS_KEY": "my_secret_key",
|
||||
"AWS_STORAGE_BUCKET_NAME": "my_bucket",
|
||||
}
|
||||
for key, value in FUNKWHALE_CONFIG.items():
|
||||
os.environ[key] = value
|
||||
|
@ -46,7 +47,7 @@ for key, value in FUNKWHALE_CONFIG.items():
|
|||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ["sphinx.ext.graphviz", "sphinx.ext.autodoc"]
|
||||
autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap"]
|
||||
autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap", "persisting_theory", "rest_framework"]
|
||||
add_module_names = False
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
|
|
@ -13,5 +13,6 @@ Reference
|
|||
architecture
|
||||
../api
|
||||
./authentication
|
||||
./plugins
|
||||
../federation/index
|
||||
subsonic
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
Funkwhale plugins
|
||||
=================
|
||||
|
||||
Starting with Funkwhale 1.0, it is now possible to implement new features
|
||||
via plugins.
|
||||
|
||||
Some plugins are maintained by the Funkwhale team (e.g. this is the case of the ``scrobbler`` plugin),
|
||||
or by third-parties.
|
||||
|
||||
Installing a plugin
|
||||
-------------------
|
||||
|
||||
To install a plugin, ensure its directory is present in the ``FUNKWHALE_PLUGINS_PATH`` directory.
|
||||
|
||||
Then, add its name to the ``FUNKWHALE_PLUGINS`` environment variable, like this::
|
||||
|
||||
FUNKWHALE_PLUGINS=myplugin,anotherplugin
|
||||
|
||||
We provide a command to make it easy to install third-party plugins::
|
||||
|
||||
python manage.py fw plugins install https://pluginurl.zip
|
||||
|
||||
.. note::
|
||||
|
||||
If you use the command, you will still need to append the plugin name to ``FUNKWHALE_PLUGINS``
|
||||
|
||||
|
||||
Types of plugins
|
||||
----------------
|
||||
|
||||
There are two types of plugins:
|
||||
|
||||
1. Plugins that are accessible to end-users, a.k.a. user-level plugins. This is the case of our Scrobbler plugin
|
||||
2. Pod-level plugins that are configured by pod admins and are not tied to a particular user
|
||||
|
||||
Additionally, user-level plugins can be regular plugins or source plugins. A source plugin provides
|
||||
a way to import files from a third-party service, e.g via webdav, FTP or something similar.
|
||||
|
||||
Hooks and filters
|
||||
-----------------
|
||||
|
||||
Funkwhale includes two kind of entrypoints for plugins to use: hooks and filters. B
|
||||
|
||||
Hooks should be used when you want to react to some change. For instance, the ``LISTENING_CREATED`` hook
|
||||
notify each registered callback that a listening was created. Our ``scrobbler`` plugin has a callback
|
||||
registered to this hook, so that it can notify Last.fm properly:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from config import plugins
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
||||
def notify_lastfm(listening, conf, **kwargs):
|
||||
# do something
|
||||
|
||||
Filters work slightly differently, and expect callbacks to return a value that will be used by Funkwhale.
|
||||
|
||||
For instance, the ``PLUGINS_DEPENDENCIES`` filter can be used as a way to install additional dependencies needed by your plugin:
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# funkwhale_startup.py
|
||||
# ...
|
||||
from config import plugins
|
||||
|
||||
@plugins.register_filter(plugins.PLUGINS_DEPENDENCIES, PLUGIN)
|
||||
def dependencies(dependencies, **kwargs):
|
||||
return dependencies + ["django_prometheus"]
|
||||
|
||||
To sum it up, hooks are used when you need to react to something, and filters when you need to alter something.
|
||||
|
||||
Writing a plugin
|
||||
----------------
|
||||
|
||||
Regardless of the type of plugin you want to write, lots of concepts are similar.
|
||||
|
||||
First, a plugin need three files:
|
||||
|
||||
- a ``__init__.py`` file, since it's a Python package
|
||||
- a ``funkwhale_startup.py`` file, that is loaded during Funkwhale initialization
|
||||
- a ``funkwhale_ready.py`` file, that is loaded when Funkwhale is configured and ready
|
||||
|
||||
So your plugin directory should look like this::
|
||||
|
||||
myplugin
|
||||
├── funkwhale_ready.py
|
||||
├── funkwhale_startup.py
|
||||
└── __init__.py
|
||||
|
||||
Now, let's write our plugin!
|
||||
|
||||
``funkwhale_startup.py`` is where you declare your plugin and it's configuration options:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# funkwhale_startup.py
|
||||
from config import plugins
|
||||
|
||||
PLUGIN = plugins.get_plugin_config(
|
||||
name="myplugin",
|
||||
label="My Plugin",
|
||||
description="An example plugin that greets you",
|
||||
version="0.1",
|
||||
# here, we write a user-level plugin
|
||||
user=True,
|
||||
conf=[
|
||||
# this configuration options are editable by each user
|
||||
{"name": "greeting", "type": "text", "label": "Greeting", "default": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
Now that our plugin is declared and configured, let's implement actual functionality in ``funkwhale_ready.py``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# funkwhale_ready.py
|
||||
from django.urls import path
|
||||
from rest_framework import response
|
||||
from rest_framework import views
|
||||
|
||||
from config import plugins
|
||||
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
# Our greeting view, where the magic happens
|
||||
class GreetingView(views.APIView):
|
||||
permission_classes = []
|
||||
def get(self, request, *args, **kwargs):
|
||||
# retrieve plugin configuration for the current user
|
||||
conf = plugins.get_conf(PLUGIN["name"], request.user)
|
||||
if not conf["enabled"]:
|
||||
# plugin is disabled for this user
|
||||
return response.Response(status=405)
|
||||
greeting = conf["conf"]["greeting"]
|
||||
data = {
|
||||
"greeting": "{} {}!".format(greeting, request.user.username)
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
# Ensure our view is known by Django and available at /greeting
|
||||
@plugins.register_filter(plugins.URLS, PLUGIN)
|
||||
def register_view(urls, **kwargs):
|
||||
return urls + [
|
||||
path('greeting', GreetingView.as_view())
|
||||
]
|
||||
|
||||
And that's pretty much it. Now, login, visit https://yourpod.domain/settings/plugins, set a value in the ``greeting`` field and enable the plugin.
|
||||
|
||||
After that, you should be greeted properly if you go to https://yourpod.domain/greeting.
|
||||
|
||||
Hooks reference
|
||||
---------------
|
||||
|
||||
.. autodata:: config.plugins.LISTENING_CREATED
|
||||
|
||||
Filters reference
|
||||
-----------------
|
||||
|
||||
.. autodata:: config.plugins.PLUGINS_DEPENDENCIES
|
||||
.. autodata:: config.plugins.PLUGINS_APPS
|
||||
.. autodata:: config.plugins.PLUGINSMIDDLEWARES_BEFORE_DEPENDENCIES
|
||||
.. autodata:: config.plugins.MIDDLEWARES_AFTER
|
||||
.. autodata:: config.plugins.URLS
|
|
@ -79,6 +79,7 @@ GLOBAL_REPLACES = [
|
|||
("#ff4335", "var(--danger-focus-color)"),
|
||||
]
|
||||
|
||||
|
||||
def discard_unused_icons(rule):
|
||||
"""
|
||||
Add an icon to this list if you want to use it in the app.
|
||||
|
@ -890,7 +891,9 @@ def replace_vars(source, dest):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Replace hardcoded values by CSS vars and strip unused rules")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replace hardcoded values by CSS vars and strip unused rules"
|
||||
)
|
||||
parser.add_argument(
|
||||
"source", help="Source path of the fomantic-ui-less distribution to fix"
|
||||
)
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<form :class="['ui form', {loading: isLoading}]" @submit.prevent="submit">
|
||||
<h3>{{ plugin.label }}</h3>
|
||||
<div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
|
||||
<div class="ui clearing hidden divider"></div>
|
||||
<div v-if="errors.length > 0" role="alert" class="ui negative message">
|
||||
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input :id="`${plugin.name}-enabled`" type="checkbox" v-model="enabled" />
|
||||
<label :for="`${plugin.name}-enabled`"><translate translate-context="*/*/*">Enabled</translate></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui clearing hidden divider"></div>
|
||||
<div v-if="plugin.source" class="field">
|
||||
<label for="plugin-library"><translate translate-context="*/*/*/Noun">Library</translate></label>
|
||||
<select id="plugin-library" v-model="values['library']">
|
||||
<option :value="l.uuid" v-for="l in libraries" :key="l.uuid">{{ l.name }}</option>
|
||||
</select>
|
||||
<div>
|
||||
<translate translate-context="*/*/Paragraph/Noun">Library where files should be imported.</translate>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="plugin.conf && plugin.conf.length > 0" v-for="field in plugin.conf">
|
||||
<div v-if="field.type === 'text'" class="field">
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input :id="`plugin-${field.name}`" type="text" v-model="values[field.name]">
|
||||
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
|
||||
</div>
|
||||
<div v-if="field.type === 'long_text'" class="field">
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<textarea :id="`plugin-${field.name}`" type="text" v-model="values[field.name]" rows="5" />
|
||||
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
|
||||
</div>
|
||||
<div v-if="field.type === 'url'" class="field">
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input :id="`plugin-${field.name}`" type="url" v-model="values[field.name]">
|
||||
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'password'" class="field">
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input :id="`plugin-${field.name}`" type="password" v-model="values[field.name]">
|
||||
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
type="submit"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
|
||||
<translate translate-context="Content/*/Button.Label/Verb">Save</translate>
|
||||
</button>
|
||||
<button
|
||||
type="scan"
|
||||
v-if="plugin.source"
|
||||
@click.prevent="submitAndScan"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
|
||||
<translate translate-context="Content/*/Button.Label/Verb">Scan</translate>
|
||||
</button>
|
||||
<div class="ui clearing hidden divider"></div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios"
|
||||
import lodash from '@/lodash'
|
||||
import showdown from 'showdown'
|
||||
export default {
|
||||
props: ['plugin', "libraries"],
|
||||
data () {
|
||||
return {
|
||||
markdown: new showdown.Converter(),
|
||||
isLoading: false,
|
||||
enabled: this.plugin.enabled,
|
||||
values: lodash.clone(this.plugin.values || {}),
|
||||
errors: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit () {
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
let url = `plugins/${this.plugin.name}`
|
||||
let enableUrl = this.enabled ? `${url}/enable` : `${url}/enable`
|
||||
await axios.post(enableUrl)
|
||||
try {
|
||||
await axios.post(url, this.values)
|
||||
} catch (e) {
|
||||
this.errors = e.backendErrors
|
||||
}
|
||||
this.isLoading = false
|
||||
},
|
||||
async scan () {
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
let url = `plugins/${this.plugin.name}/scan`
|
||||
try {
|
||||
await axios.post(url, this.values)
|
||||
} catch (e) {
|
||||
this.errors = e.backendErrors
|
||||
}
|
||||
this.isLoading = false
|
||||
},
|
||||
async submitAndScan () {
|
||||
await this.submit()
|
||||
await this.scan()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -257,6 +257,20 @@
|
|||
</translate>
|
||||
</empty-state>
|
||||
</section>
|
||||
|
||||
<section class="ui text container" id="plugins">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
<i class="code icon"></i>
|
||||
<div class="content">
|
||||
<translate translate-context="Content/Settings/Title/Noun">Plugins</translate>
|
||||
</div>
|
||||
</h2>
|
||||
<p><translate translate-context="Content/Settings/Paragraph">Use plugins to extend Funkwhale and get additional features.</translate></p>
|
||||
<router-link class="ui basic success button" :to="{name: 'settings.plugins'}">
|
||||
<translate translate-context="Content/Settings/Button.Label">Manage plugins</translate>
|
||||
</router-link>
|
||||
</section>
|
||||
<section class="ui text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
|
|
|
@ -154,6 +154,14 @@ export default new Router({
|
|||
/* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew"
|
||||
)
|
||||
},
|
||||
{
|
||||
path: "/settings/plugins",
|
||||
name: "settings.plugins",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "settings" */ "@/views/auth/Plugins"
|
||||
)
|
||||
},
|
||||
{
|
||||
path: "/settings/applications/:id/edit",
|
||||
name: "settings.applications.edit",
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<main class="main pusher" v-title="labels.title">
|
||||
<section class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>{{ labels.title }}</h2>
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
|
||||
<plugin-form
|
||||
v-if="plugins && plugins.length > 0"
|
||||
v-for="plugin in plugins"
|
||||
:plugin="plugin"
|
||||
:libraries="libraries"
|
||||
:key="plugin.name"></plugin-form>
|
||||
<empty-state v-else></empty-state>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import PluginForm from '@/components/auth/Plugin'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PluginForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
plugins: null,
|
||||
libraries: null,
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
await this.fetchData()
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
let title = this.$pgettext('Head/Login/Title', "Manage plugins")
|
||||
return {
|
||||
title
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchData () {
|
||||
this.isLoading = true
|
||||
let response = await axios.get('plugins')
|
||||
this.plugins = response.data
|
||||
response = await axios.get('libraries', {paramis: {scope: 'me', page_size: 50}})
|
||||
this.libraries = response.data.results
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
Loading…
Reference in New Issue