Unverified Commit 8c587d07 authored by Agate's avatar Agate 💬
Browse files

Moar plugins polishing and sugar

parent 4c4ab591
...@@ -14,9 +14,25 @@ class ConfigError(ValueError): ...@@ -14,9 +14,25 @@ class ConfigError(ValueError):
class Plugin(AppConfig): class Plugin(AppConfig):
conf = {} conf = {}
path = "noop" path = "noop"
conf_serializer = None
def get_conf(self): def get_conf(self):
return {"enabled": self.plugin_settings.enabled} return self.instance.conf
def set_conf(self, data):
if self.conf_serializer:
s = self.conf_serializer(data=data)
s.is_valid(raise_exception=True)
data = s.validated_data
instance = self.instance()
instance.conf = data
instance.save(update_fields=["conf"])
def instance(self):
"""Return the DB object that match the plugin"""
from funkwhale_api.common import models
return models.PodPlugin.objects.get_or_create(code=self.name)[0]
def plugin_settings(self): def plugin_settings(self):
""" """
...@@ -94,7 +110,13 @@ plugins_manager.add_hookspecs(HookSpec()) ...@@ -94,7 +110,13 @@ plugins_manager.add_hookspecs(HookSpec())
def register(plugin_class): def register(plugin_class):
return plugins_manager.register(plugin_class("noop", "noop")) return plugins_manager.register(plugin_class(plugin_class.name, "noop"))
def save(plugin_class):
from funkwhale_api.common.models import PodPlugin
return PodPlugin.objects.get_or_create(code=plugin_class.name)[0]
def trigger_hook(name, *args, **kwargs): def trigger_hook(name, *args, **kwargs):
...@@ -104,6 +126,13 @@ def trigger_hook(name, *args, **kwargs): ...@@ -104,6 +126,13 @@ def trigger_hook(name, *args, **kwargs):
@register @register
class DefaultPlugin(Plugin): class DefaultPlugin(Plugin):
name = "default"
verbose_name = "Default plugin"
@plugin_hook @plugin_hook
def database_engine(self): def database_engine(self):
return "django.db.backends.postgresql" return "django.db.backends.postgresql"
@plugin_hook
def urls(self):
return []
...@@ -24,7 +24,11 @@ class Plugins(persisting_theory.Registry): ...@@ -24,7 +24,11 @@ class Plugins(persisting_theory.Registry):
look_into = "entrypoint" look_into = "entrypoint"
PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p] PLUGINS = [
"funkwhale_plugin_{}".format(p)
for p in env.list("FUNKWHALE_PLUGINS", default=[])
if p
]
""" """
List of Funkwhale plugins to load. List of Funkwhale plugins to load.
""" """
...@@ -33,8 +37,6 @@ from config import plugins # noqa ...@@ -33,8 +37,6 @@ from config import plugins # noqa
plugins_registry = Plugins() plugins_registry = Plugins()
plugins_registry.autodiscover(PLUGINS) plugins_registry.autodiscover(PLUGINS)
# plugins.plugins_manager.register(Plugin("noop", "noop"))
LOGLEVEL = env("LOGLEVEL", default="info").upper() LOGLEVEL = env("LOGLEVEL", default="info").upper()
""" """
Default logging level for the Funkwhale processes""" # pylint: disable=W0105 Default logging level for the Funkwhale processes""" # pylint: disable=W0105
...@@ -272,7 +274,7 @@ List of Django apps to load in addition to Funkwhale plugins and apps. ...@@ -272,7 +274,7 @@ List of Django apps to load in addition to Funkwhale plugins and apps.
PLUGINS_APPS = tuple() PLUGINS_APPS = tuple()
for p in plugins.trigger_hook("register_apps"): for p in plugins.trigger_hook("register_apps"):
PLUGINS_APPS += (p,) PLUGINS_APPS += tuple(p)
INSTALLED_APPS = ( INSTALLED_APPS = (
DJANGO_APPS DJANGO_APPS
......
import os import os
import shutil
import subprocess import subprocess
import sys
import tempfile
import click import click
...@@ -8,12 +11,27 @@ from django.conf import settings ...@@ -8,12 +11,27 @@ from django.conf import settings
from . import base from . import base
PIP = os.path.join(sys.prefix, "bin", "pip")
@base.cli.group() @base.cli.group()
def plugins(): def plugins():
"""Install, configure and remove plugins""" """Install, configure and remove plugins"""
pass pass
def get_all_plugins():
plugins = [
f.path
for f in os.scandir(settings.FUNKWHALE_PLUGINS_PATH)
if "/funkwhale_plugin_" in f.path
]
plugins = [
p.split("-")[0].split("/")[-1].replace("funkwhale_plugin_", "") for p in plugins
]
return plugins
@plugins.command("install") @plugins.command("install")
@click.argument("name_or_url", nargs=-1) @click.argument("name_or_url", nargs=-1)
@click.option("--builtins", is_flag=True) @click.option("--builtins", is_flag=True)
...@@ -21,17 +39,56 @@ def plugins(): ...@@ -21,17 +39,56 @@ def plugins():
def install(name_or_url, builtins, pip_args): def install(name_or_url, builtins, pip_args):
""" """
Installed the specified plug using their name. Installed the specified plug using their name.
If --builtins is provided, it will also install
plugins present at FUNKWHALE_PLUGINS_PATH
""" """
pip_args = pip_args or "" pip_args = pip_args or ""
target_path = settings.FUNKWHALE_PLUGINS_PATH all_plugins = []
builtins_path = os.path.join(settings.APPS_DIR, "plugins") for p in name_or_url:
builtins_plugins = [f.path for f in os.scandir(builtins_path) if f.is_dir()] builtin_path = os.path.join(
command = "pip install {} --target={} {}".format( settings.APPS_DIR, "plugins", "funkwhale_plugin_{}".format(p)
pip_args, target_path, " ".join(builtins_plugins) )
if os.path.exists(builtin_path):
all_plugins.append(builtin_path)
else:
all_plugins.append(p)
install_plugins(pip_args, all_plugins)
click.echo(
"Installation completed, ensure FUNKWHALE_PLUGINS={} is present in your .env file".format(
",".join(get_all_plugins())
)
) )
def install_plugins(pip_args, all_plugins):
with tempfile.TemporaryDirectory() as tmpdirname:
command = "{} install {} --target {} --build={} {}".format(
PIP,
pip_args,
settings.FUNKWHALE_PLUGINS_PATH,
tmpdirname,
" ".join(all_plugins),
)
subprocess.run(
command, shell=True, check=True,
)
@plugins.command("uninstall")
@click.argument("name", nargs=-1)
def uninstall(name):
"""
Remove plugins
"""
to_remove = ["funkwhale_plugin_{}".format(n) for n in name]
command = "{} uninstall -y {}".format(PIP, " ".join(to_remove))
subprocess.run( subprocess.run(
command, shell=True, check=True, command, shell=True, check=True,
) )
for f in os.scandir(settings.FUNKWHALE_PLUGINS_PATH):
for n in name:
if "/funkwhale_plugin_{}".format(n) in f.path:
shutil.rmtree(f.path)
click.echo(
"Removal completed, set FUNKWHALE_PLUGINS={} in your .env file".format(
",".join(get_all_plugins())
)
)
# Generated by Django 3.0.6 on 2020-06-17 19:02
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('common', '0007_auto_20200116_1610'),
]
operations = [
migrations.CreateModel(
name='PodPlugin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('conf', django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True, blank=True)),
('code', models.CharField(max_length=100, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.AlterField(
model_name='attachment',
name='url',
field=models.URLField(blank=True, max_length=500, null=True),
),
]
...@@ -18,6 +18,7 @@ from django.urls import reverse ...@@ -18,6 +18,7 @@ from django.urls import reverse
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from config import plugins
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from . import utils from . import utils
...@@ -363,3 +364,17 @@ def remove_attached_content(sender, instance, **kwargs): ...@@ -363,3 +364,17 @@ def remove_attached_content(sender, instance, **kwargs):
getattr(instance, field).delete() getattr(instance, field).delete()
except Content.DoesNotExist: except Content.DoesNotExist:
pass pass
class PodPlugin(models.Model):
conf = JSONField(default=None, null=True, blank=True)
code = models.CharField(max_length=100, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
@property
def plugin(self):
"""Links to the Plugin instance in entryposint.py"""
candidates = plugins.plugins_manager.get_plugins()
for p in candidates:
if p.name == self.code:
return p
...@@ -339,3 +339,21 @@ class NullToEmptDict(object): ...@@ -339,3 +339,21 @@ class NullToEmptDict(object):
if not v: if not v:
return v return v
return super().to_representation(v) return super().to_representation(v)
class PodPluginSerializer(serializers.Serializer):
code = serializers.CharField(read_only=True)
enabled = serializers.BooleanField()
conf = serializers.JSONField()
label = serializers.SerializerMethodField()
class Meta:
fields = [
"code",
"label",
"enabled",
"conf",
]
def get_label(self, o):
return o.plugin.verbose_name
...@@ -5,7 +5,8 @@ from config import plugins ...@@ -5,7 +5,8 @@ from config import plugins
@plugins.register @plugins.register
class Plugin(plugins.Plugin): class Plugin(plugins.Plugin):
name = "prometheus_exporter" name = "funkwhale_plugin_prometheus"
verbose_name = "Prometheus metrics exporter"
@plugins.plugin_hook @plugins.plugin_hook
def database_engine(self): def database_engine(self):
...@@ -13,7 +14,7 @@ class Plugin(plugins.Plugin): ...@@ -13,7 +14,7 @@ class Plugin(plugins.Plugin):
@plugins.plugin_hook @plugins.plugin_hook
def register_apps(self): def register_apps(self):
return "django_prometheus" return ["django_prometheus"]
@plugins.plugin_hook @plugins.plugin_hook
def middlewares_before(self): def middlewares_before(self):
......
[metadata] [metadata]
name = funkwhale-prometheus name = funkwhale-plugin-prometheus
description = "A prometheus metric exporter for your Funkwhale pod" description = "A prometheus metrics exporter for your Funkwhale pod"
version = 0.1.dev0 version = 0.1.dev0
author = Agate Blue author = Agate Blue
author_email = me@agate.blue author_email = me@agate.blue
......
import pytest
from rest_framework import serializers
from config import plugins
from funkwhale_api.common import models
def test_plugin_validate_set_conf():
class S(serializers.Serializer):
test = serializers.CharField()
foo = serializers.BooleanField()
class P(plugins.Plugin):
conf_serializer = S
p = P("noop", "noop")
with pytest.raises(serializers.ValidationError):
assert p.set_conf({"test": "hello", "foo": "bar"})
def test_plugin_validate_set_conf_persists():
class S(serializers.Serializer):
test = serializers.CharField()
foo = serializers.BooleanField()
class P(plugins.Plugin):
name = "test_plugin"
conf_serializer = S
p = P("noop", "noop")
p.set_conf({"test": "hello", "foo": False})
assert p.instance() == models.PodPlugin.objects.latest("id")
assert p.instance().conf == {"test": "hello", "foo": False}
...@@ -6,6 +6,7 @@ from django.urls import reverse ...@@ -6,6 +6,7 @@ from django.urls import reverse
import django_filters import django_filters
from config import plugins
from funkwhale_api.common import serializers from funkwhale_api.common import serializers
from funkwhale_api.common import utils from funkwhale_api.common import utils
from funkwhale_api.users import models from funkwhale_api.users import models
...@@ -267,3 +268,21 @@ def test_content_serializer(factories): ...@@ -267,3 +268,21 @@ def test_content_serializer(factories):
serializer = serializers.ContentSerializer(content) serializer = serializers.ContentSerializer(content)
assert serializer.data == expected assert serializer.data == expected
def test_plugin_serializer():
class TestPlugin(plugins.Plugin):
name = "test_plugin"
verbose_name = "A test plugin"
plugins.register(TestPlugin)
instance = plugins.save(TestPlugin)
assert isinstance(instance.plugin, TestPlugin)
expected = {
"code": "test_plugin",
"label": "A test plugin",
"enabled": True,
"conf": None,
}
assert serializers.PodPluginSerializer(instance).data == expected
...@@ -24,6 +24,7 @@ from aioresponses import aioresponses ...@@ -24,6 +24,7 @@ from aioresponses import aioresponses
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
...@@ -429,3 +430,13 @@ def clear_license_cache(db): ...@@ -429,3 +430,13 @@ def clear_license_cache(db):
@pytest.fixture @pytest.fixture
def faker(): def faker():
return factory.Faker._get_faker() return factory.Faker._get_faker()
@pytest.fixture
def plugins_manager():
return plugins.PluginManager("tests")
@pytest.fixture
def hook(plugins_manager):
return plugins.HookimplMarker("tests")
...@@ -13,5 +13,6 @@ Reference ...@@ -13,5 +13,6 @@ Reference
architecture architecture
../api ../api
./authentication ./authentication
./plugins
../federation/index ../federation/index
subsonic subsonic
Funkwhale Plugins
=================
With version 1.0, Funkwhale makes it possible for third party to write plugins
and distribute them.
Funkwhale plugins are regular django apps, that can register models, API
endpoints, and react to specific events (e.g a son was listened, a federation message was delivered, etc.)
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment