Plugins infrastructure
This commit is contained in:
parent
9964adfbf6
commit
d4028450a9
32 changed files with 1560 additions and 47 deletions
|
|
@ -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")
|
||||
|
|
|
|||
291
api/config/plugins.py
Normal file
291
api/config/plugins.py
Normal file
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue