Commit e0c5ffcb authored by Eliot Berriot's avatar Eliot Berriot 💬

[EPIC] Audio metadata update - UI / API

parent 1a1c62ab
......@@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
from rest_framework_jwt import views as jwt_views
from funkwhale_api.activity import views as activity_views
from funkwhale_api.common import views as common_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet
......@@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register(
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
)
router.register(r"mutations", common_views.MutationViewSet, "mutations")
v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False)
......
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
import django # noqa
django.setup()
from .routing import application # noqa
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
......@@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None)
if env_file:
# we have an explicitely specified env file
# so we try to load and it fail loudly if it does not exist
print("ENV_FILE", env_file)
env.read_env(env_file)
else:
# we try to load from .env and config/.env
......@@ -150,7 +149,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
"funkwhale_api.common",
"funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app
# Your stuff: custom apps go here
......
......@@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False
# Your local stuff: Below this line define 3rd party library settings
LOGGING = {
"version": 1,
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
"loggers": {
"django.request": {
"handlers": ["console"],
"propagate": True,
"level": "DEBUG",
},
"django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
"": {"level": "DEBUG", "handlers": ["console"]},
},
}
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
......
from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa
from django.db.models.fields.related import RelatedField
from . import models
from . import tasks
def register(model):
"""
......@@ -17,3 +20,28 @@ def register(model):
return initial_register(model)(modeladmin)
return decorator
def apply(modeladmin, request, queryset):
queryset.update(is_approved=True)
for id in queryset.values_list("id", flat=True):
tasks.apply_mutation.delay(mutation_id=id)
apply.short_description = "Approve and apply"
@register(models.Mutation)
class MutationAdmin(ModelAdmin):
list_display = [
"uuid",
"type",
"created_by",
"creation_date",
"applied_date",
"is_approved",
"is_applied",
]
search_fields = ["created_by__preferred_username"]
list_filter = ["type", "is_approved", "is_applied"]
actions = [apply]
from django.apps import AppConfig, apps
from . import mutations
class CommonConfig(AppConfig):
name = "funkwhale_api.common"
def ready(self):
super().ready()
app_names = [app.name for app in apps.app_configs.values()]
mutations.registry.autodiscover(app_names)
from rest_framework import response
from django.db import transaction
from rest_framework import decorators
from rest_framework import exceptions
from rest_framework import response
from rest_framework import status
from . import filters
from . import models
from . import mutations as common_mutations
from . import serializers
from . import signals
from . import tasks
from . import utils
def action_route(serializer_class):
......@@ -12,3 +24,67 @@ def action_route(serializer_class):
return response.Response(result, status=200)
return action
def mutations_route(types):
"""
Given a queryset and a list of mutation types, return a view
that can be included in any viewset, and serve:
GET /{id}/mutations/ - list of mutations for the given object
POST /{id}/mutations/ - create a mutation for the given object
"""
@transaction.atomic
def mutations(self, request, *args, **kwargs):
obj = self.get_object()
if request.method == "GET":
queryset = models.Mutation.objects.get_for_target(obj).filter(
type__in=types
)
queryset = queryset.order_by("-creation_date")
filterset = filters.MutationFilter(request.GET, queryset=queryset)
page = self.paginate_queryset(filterset.qs)
if page is not None:
serializer = serializers.APIMutationSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = serializers.APIMutationSerializer(queryset, many=True)
return response.Response(serializer.data)
if request.method == "POST":
if not request.user.is_authenticated:
raise exceptions.NotAuthenticated()
serializer = serializers.APIMutationSerializer(
data=request.data, context={"registry": common_mutations.registry}
)
serializer.is_valid(raise_exception=True)
if not common_mutations.registry.has_perm(
actor=request.user.actor,
type=serializer.validated_data["type"],
obj=obj,
perm="approve"
if serializer.validated_data.get("is_approved", False)
else "suggest",
):
raise exceptions.PermissionDenied()
final_payload = common_mutations.registry.get_validated_payload(
type=serializer.validated_data["type"],
payload=serializer.validated_data["payload"],
obj=obj,
)
mutation = serializer.save(
created_by=request.user.actor,
target=obj,
payload=final_payload,
is_approved=serializer.validated_data.get("is_approved", None),
)
if mutation.is_approved:
utils.on_commit(tasks.apply_mutation.delay, mutation_id=mutation.pk)
utils.on_commit(
signals.mutation_created.send, sender=None, mutation=mutation
)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action(methods=["get", "post"], detail=True)(mutations)
import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.federation import factories as federation_factories
@registry.register
class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
created_by = factory.SubFactory(federation_factories.ActorFactory)
summary = factory.Faker("paragraph")
type = "update"
class Meta:
model = "common.Mutation"
@factory.post_generation
def target(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return
self.target = extracted
self.save()
import django_filters
from django import forms
from django.db import models
from . import search
......@@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter):
def filter(self, qs, value):
if not value:
return qs
cleaned = self.config.clean(value)
try:
cleaned = self.config.clean(value)
except forms.ValidationError:
return qs.none()
return search.apply(qs, cleaned)
from django import forms
from django.db.models import Q
from django_filters import widgets
from django_filters import rest_framework as filters
from . import fields
from . import models
from . import search
class NoneObject(object):
def __eq__(self, other):
return other.__class__ == NoneObject
NONE = NoneObject()
NULL_BOOLEAN_CHOICES = [
(True, True),
("true", True),
("True", True),
("1", True),
("yes", True),
(False, False),
("false", False),
("False", False),
("0", False),
("no", False),
("None", NONE),
("none", NONE),
("Null", NONE),
("null", NONE),
]
class CoerceChoiceField(forms.ChoiceField):
"""
Same as forms.ChoiceField but will return the second value
in the choices tuple instead of the user provided one
"""
def clean(self, value):
if value is None:
return value
v = super().clean(value)
try:
return [b for a, b in self.choices if v == a][0]
except IndexError:
raise forms.ValidationError("Invalid value {}".format(value))
class NullBooleanFilter(filters.ChoiceFilter):
field_class = CoerceChoiceField
def __init__(self, *args, **kwargs):
self.choices = NULL_BOOLEAN_CHOICES
kwargs["choices"] = self.choices
super().__init__(*args, **kwargs)
def filter(self, qs, value):
if value in ["", None]:
return qs
if value == NONE:
value = None
qs = self.get_method(qs)(
**{"%s__%s" % (self.field_name, self.lookup_expr): value}
)
return qs.distinct() if self.distinct else qs
def clean_null_boolean_filter(v):
v = CoerceChoiceField(choices=NULL_BOOLEAN_CHOICES).clean(v)
if v == NONE:
v = None
return v
def get_null_boolean_filter(name):
return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})}
class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField):
def valid_value(self, value):
return True
class QueryArrayWidget(widgets.QueryArrayWidget):
"""
Until https://github.com/carltongibson/django-filter/issues/1047 is fixed
"""
def value_from_datadict(self, data, files, name):
data = data.copy()
return super().value_from_datadict(data, files, name)
class MultipleQueryFilter(filters.TypedMultipleChoiceFilter):
field_class = DummyTypedMultipleChoiceField
def __init__(self, *args, **kwargs):
kwargs["widget"] = QueryArrayWidget()
super().__init__(*args, **kwargs)
self.lookup_expr = "in"
class MutationFilter(filters.FilterSet):
is_approved = NullBooleanFilter("is_approved")
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
"summary": {"to": "summary"},
"fid": {"to": "fid"},
"type": {"to": "type"},
},
filter_fields={
"domain": {"to": "created_by__domain__name__iexact"},
"is_approved": get_null_boolean_filter("is_approved"),
"is_applied": {"to": "is_applied"},
},
)
)
class Meta:
model = models.Mutation
fields = ["is_approved", "is_applied", "type"]
# Generated by Django 2.1.5 on 2019-01-31 15:44
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("federation", "0017_auto_20190130_0926"),
("contenttypes", "0002_remove_content_type_name"),
("common", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Mutation",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("fid", models.URLField(db_index=True, max_length=500, unique=True)),
(
"uuid",
models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
("type", models.CharField(db_index=True, max_length=100)),
("is_approved", models.NullBooleanField(default=None)),
("is_applied", models.NullBooleanField(default=None)),
(
"creation_date",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
(
"applied_date",
models.DateTimeField(blank=True, db_index=True, null=True),
),
("summary", models.TextField(max_length=2000, blank=True, null=True)),
("payload", django.contrib.postgres.fields.jsonb.JSONField()),
(
"previous_state",
django.contrib.postgres.fields.jsonb.JSONField(
null=True, default=None
),
),
("target_id", models.IntegerField(null=True)),
(
"approved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_mutations",
to="federation.Actor",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_mutations",
to="federation.Actor",
),
),
(
"target_content_type",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="targeting_mutations",
to="contenttypes.ContentType",
),
),
],
)
]
import uuid
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.federation import utils as federation_utils
class MutationQuerySet(models.QuerySet):
def get_for_target(self, target):
content_type = ContentType.objects.get_for_model(target)
return self.filter(target_content_type=content_type, target_id=target.pk)
class Mutation(models.Model):
fid = models.URLField(unique=True, max_length=500, db_index=True)
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
created_by = models.ForeignKey(
"federation.Actor",
related_name="created_mutations",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
approved_by = models.ForeignKey(
"federation.Actor",
related_name="approved_mutations",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
type = models.CharField(max_length=100, db_index=True)
# None = no choice, True = approved, False = refused
is_approved = models.NullBooleanField(default=None)
# None = not applied, True = applied, False = failed
is_applied = models.NullBooleanField(default=None)
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
applied_date = models.DateTimeField(null=True, blank=True, db_index=True)
summary = models.TextField(max_length=2000, null=True, blank=True)
payload = JSONField()
previous_state = JSONField(null=True, default=None)
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType,
null=True,
on_delete=models.CASCADE,
related_name="targeting_mutations",
)
target = GenericForeignKey("target_content_type", "target_id")
objects = MutationQuerySet.as_manager()
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse("federation:edits-detail", kwargs={"uuid": self.uuid})
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)
@transaction.atomic
def apply(self):
from . import mutations
if self.is_applied:
raise ValueError("Mutation was already applied")
previous_state = mutations.registry.apply(
type=self.type, obj=self.target, payload=self.payload
)
self.previous_state = previous_state
self.is_applied = True
self.applied_date = timezone.now()
self.save(update_fields=["is_applied", "applied_date", "previous_state"])
return previous_state
import persisting_theory
from rest_framework import serializers
from django.db import models
class ConfNotFound(KeyError):
pass
class Registry(persisting_theory.Registry):
look_into = "mutations"
def connect(self, type, klass, perm_checkers=None):
def decorator(serializer_class):
t = self.setdefault(type, {})
t[klass] = {
"serializer_class": serializer_class,
"perm_checkers": perm_checkers or {},
}
return serializer_class
return decorator
def apply(self, type, obj, payload):
conf = self.get_conf(type, obj)
serializer = conf["serializer_class"](obj, data=payload)
serializer.is_valid(raise_exception=True)
previous_state = serializer.get_previous_state(obj, serializer.validated_data)
serializer.apply(obj, serializer.validated_data)
return previous_state
def is_valid(self, type, obj, payload):
conf = self.get_conf(type, obj)
serializer = conf["serializer_class"](obj, data=payload)
return serializer.is_valid(raise_exception=True)
def get_validated_payload(self, type, obj, payload):
conf = self.get_conf(type, obj)
serializer = conf["serializer_class"](obj, data=payload)
serializer.is_valid(raise_exception=True)
return serializer.payload_serialize(serializer.validated_data)
def has_perm(self, perm, type, obj, actor):
if perm not in ["approve", "suggest"]:
raise ValueError("Invalid permission {}".format(perm))
conf = self.get_conf(type, obj)
checker = conf["perm_checkers"].get(perm)
if not checker:
return False
return checker(obj=obj, actor=actor)
def get_conf(self, type, obj):
try:
type_conf = self[type]
except KeyError:
raise ConfNotFound("{} is not a registered mutation".format(type))
try:
conf = type_conf[obj.__class__]
except KeyError:
try:
conf = type_conf[None]
except KeyError:
raise ConfNotFound(
"No mutation configuration found for {}".format(obj.__class__)
)
return conf
class MutationSerializer(serializers.Serializer):
def apply(self, obj, validated_data):
raise NotImplementedError()
def get_previous_state(self, obj, validated_data):
return
def payload_serialize(self, data):
return data
class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
serialized_relations = {}
def __init__(self, *args, **kwargs):
# we force partial mode, because update mutations are partial
kwargs.setdefault("partial", True)
super().__init__(*args, **kwargs)
def apply(self, obj, validated_data):
return self.update(obj, validated_data)
def validate(self, validated_data):
if not validated_data:
raise serializers.ValidationError("You must update at least one field")
return super().validate(validated_data)
def db_serialize(self, validated_data):
data = {}
# ensure model fields are serialized properly
for key, value in list(validated_data.items()):
if not isinstance(value, models.Model):
data[key] = value
continue
field = self.serialized_relations[key]
data[key] = getattr(value, field)
return data
def payload_serialize(self, data):
data = super().payload_serialize(data)
# we use our serialized_relations configuration
# to ensure we store ids instead of model instances in our json
# payload
for field, attr in self.serialized_relations.items():
data[field] = getattr(data[field], attr)
return data
def create(self, validated_data):
validated_data = self.db_serialize(validated_data)
return super().create(validated_data)
def get_previous_state(self, obj, validated_data):
return get_update_previous_state(
obj,
*list(validated_data.keys()),
serialized_relations=self.serialized_relations
)
def get_update_previous_state(obj, *fields, serialized_relations={}):
if not fields:
raise ValueError("You need to provide at least one field")
state = {}