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):
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)
data = s.validated_data
instance = self.instance()
instance.conf = data
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):
class DefaultPlugin(Plugin):
name = "default"
verbose_name = "Default plugin"
def database_engine(self):
return "django.db.backends.postgresql"
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]
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.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 += tuple(p)
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")
def plugins():
"""Install, configure and remove plugins"""
def get_all_plugins():
plugins = [
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
@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):
install_plugins(pip_args, all_plugins)
"Installation completed, ensure FUNKWHALE_PLUGINS={} is present in your .env file".format(
def install_plugins(pip_args, all_plugins):
with tempfile.TemporaryDirectory() as tmpdirname:
command = "{} install {} --target {} --build={} {}".format(
" ".join(all_plugins),
command, shell=True, check=True,
@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))
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:
"Removal completed, set FUNKWHALE_PLUGINS={} in your .env file".format(
# 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 = [
('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)),
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:
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)
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 = [
def get_label(self, o):
return o.plugin.verbose_name
......@@ -5,7 +5,8 @@ from config import plugins
class Plugin(plugins.Plugin):
name = "prometheus_exporter"
name = "funkwhale_plugin_prometheus"
verbose_name = "Prometheus metrics exporter"
def database_engine(self):
......@@ -13,7 +14,7 @@ class Plugin(plugins.Plugin):
def register_apps(self):
return "django_prometheus"
return ["django_prometheus"]
def middlewares_before(self):
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"
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):
def faker():
return factory.Faker._get_faker()
def plugins_manager():
return plugins.PluginManager("tests")
def hook(plugins_manager):
return plugins.HookimplMarker("tests")
......@@ -13,5 +13,6 @@ Reference
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