Skip to content
Snippets Groups Projects
Commit d4028450 authored by Agate's avatar Agate :speech_balloon:
Browse files

Plugins infrastructure

parent 9964adfb
No related branches found
No related tags found
No related merge requests found
Showing
with 746 additions and 35 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")
......
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,14 +275,17 @@ 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) + (
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",
......@@ -287,6 +300,8 @@ MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + (
"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
......
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)
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
......
# 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)
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")
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"},
],
)
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
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment