Skip to content
Snippets Groups Projects
Unverified Commit 8c587d07 authored by Agate's avatar Agate 💬
Browse files

Moar plugins polishing and sugar

parent 4c4ab591
No related branches found
No related tags found
No related merge requests found
Showing
with 242 additions and 18 deletions
......@@ -14,9 +14,25 @@ class ConfigError(ValueError):
class Plugin(AppConfig):
conf = {}
path = "noop"
conf_serializer = None
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):
"""
......@@ -94,7 +110,13 @@ plugins_manager.add_hookspecs(HookSpec())
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):
......@@ -104,6 +126,13 @@ def trigger_hook(name, *args, **kwargs):
@register
class DefaultPlugin(Plugin):
name = "default"
verbose_name = "Default plugin"
@plugin_hook
def database_engine(self):
return "django.db.backends.postgresql"
@plugin_hook
def urls(self):
return []
......@@ -24,7 +24,11 @@ class Plugins(persisting_theory.Registry):
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.
"""
......@@ -33,8 +37,6 @@ from config import plugins # noqa
plugins_registry = Plugins()
plugins_registry.autodiscover(PLUGINS)
# plugins.plugins_manager.register(Plugin("noop", "noop"))
LOGLEVEL = env("LOGLEVEL", default="info").upper()
"""
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.
PLUGINS_APPS = tuple()
for p in plugins.trigger_hook("register_apps"):
PLUGINS_APPS += (p,)
PLUGINS_APPS += tuple(p)
INSTALLED_APPS = (
DJANGO_APPS
......
import os
import shutil
import subprocess
import sys
import tempfile
import click
......@@ -8,12 +11,27 @@ from django.conf import settings
from . import base
PIP = os.path.join(sys.prefix, "bin", "pip")
@base.cli.group()
def plugins():
"""Install, configure and remove plugins"""
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")
@click.argument("name_or_url", nargs=-1)
@click.option("--builtins", is_flag=True)
......@@ -21,17 +39,56 @@ def plugins():
def install(name_or_url, builtins, pip_args):
"""
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 ""
target_path = settings.FUNKWHALE_PLUGINS_PATH
builtins_path = os.path.join(settings.APPS_DIR, "plugins")
builtins_plugins = [f.path for f in os.scandir(builtins_path) if f.is_dir()]
command = "pip install {} --target={} {}".format(
pip_args, target_path, " ".join(builtins_plugins)
all_plugins = []
for p in name_or_url:
builtin_path = os.path.join(
settings.APPS_DIR, "plugins", "funkwhale_plugin_{}".format(p)
)
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(
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
from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from config import plugins
from funkwhale_api.federation import utils as federation_utils
from . import utils
......@@ -363,3 +364,17 @@ def remove_attached_content(sender, instance, **kwargs):
getattr(instance, field).delete()
except Content.DoesNotExist:
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):
if not v:
return 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
@plugins.register
class Plugin(plugins.Plugin):
name = "prometheus_exporter"
name = "funkwhale_plugin_prometheus"
verbose_name = "Prometheus metrics exporter"
@plugins.plugin_hook
def database_engine(self):
......@@ -13,7 +14,7 @@ class Plugin(plugins.Plugin):
@plugins.plugin_hook
def register_apps(self):
return "django_prometheus"
return ["django_prometheus"]
@plugins.plugin_hook
def middlewares_before(self):
......
[metadata]
name = funkwhale-prometheus
description = "A prometheus metric exporter for your Funkwhale pod"
name = funkwhale-plugin-prometheus
description = "A prometheus metrics exporter for your Funkwhale pod"
version = 0.1.dev0
author = 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
import django_filters
from config import plugins
from funkwhale_api.common import serializers
from funkwhale_api.common import utils
from funkwhale_api.users import models
......@@ -267,3 +268,21 @@ def test_content_serializer(factories):
serializer = serializers.ContentSerializer(content)
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
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient, APIRequestFactory
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.federation import actors
from funkwhale_api.moderation import mrf
......@@ -429,3 +430,13 @@ def clear_license_cache(db):
@pytest.fixture
def 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
architecture
../api
./authentication
./plugins
../federation/index
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.)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment