mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-06 05:29:55 +02:00
[EPIC] Audio metadata update - UI / API
This commit is contained in:
parent
1a1c62ab37
commit
e0c5ffcb16
59 changed files with 2793 additions and 436 deletions
|
@ -1,6 +1,9 @@
|
|||
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]
|
||||
|
|
13
api/funkwhale_api/common/apps.py
Normal file
13
api/funkwhale_api/common/apps.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
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)
|
|
@ -1,5 +1,17 @@
|
|||
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)
|
||||
|
|
25
api/funkwhale_api/common/factories.py
Normal file
25
api/funkwhale_api/common/factories.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
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()
|
|
@ -1,4 +1,5 @@
|
|||
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)
|
||||
|
|
126
api/funkwhale_api/common/filters.py
Normal file
126
api/funkwhale_api/common/filters.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
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"]
|
91
api/funkwhale_api/common/migrations/0002_mutation.py
Normal file
91
api/funkwhale_api/common/migrations/0002_mutation.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
89
api/funkwhale_api/common/models.py
Normal file
89
api/funkwhale_api/common/models.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
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
|
150
api/funkwhale_api/common/mutations.py
Normal file
150
api/funkwhale_api/common/mutations.py
Normal file
|
@ -0,0 +1,150 @@
|
|||
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 = {}
|
||||
for field in fields:
|
||||
value = getattr(obj, field)
|
||||
if isinstance(value, models.Model):
|
||||
# we store the related object id and repr for better UX
|
||||
id_field = serialized_relations[field]
|
||||
related_value = getattr(value, id_field)
|
||||
state[field] = {"value": related_value, "repr": str(value)}
|
||||
else:
|
||||
state[field] = {"value": value}
|
||||
|
||||
return state
|
||||
|
||||
|
||||
registry = Registry()
|
|
@ -103,9 +103,7 @@ class SearchConfig:
|
|||
return
|
||||
|
||||
matching = [t for t in tokens if t["key"] in self.filter_fields]
|
||||
queries = [
|
||||
Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching
|
||||
]
|
||||
queries = [self.get_filter_query(token) for token in matching]
|
||||
query = None
|
||||
for q in queries:
|
||||
if not query:
|
||||
|
@ -114,6 +112,26 @@ class SearchConfig:
|
|||
query = query & q
|
||||
return query
|
||||
|
||||
def get_filter_query(self, token):
|
||||
raw_value = token["value"]
|
||||
try:
|
||||
field = self.filter_fields[token["key"]]["field"]
|
||||
value = field.clean(raw_value)
|
||||
except KeyError:
|
||||
# no cleaning to apply
|
||||
value = raw_value
|
||||
try:
|
||||
query_field = self.filter_fields[token["key"]]["to"]
|
||||
return Q(**{query_field: value})
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# we don't have a basic filter -> field mapping, this likely means we
|
||||
# have a dynamic handler in the config
|
||||
handler = self.filter_fields[token["key"]]["handler"]
|
||||
value = handler(value)
|
||||
return value
|
||||
|
||||
def clean_types(self, tokens):
|
||||
if not self.types:
|
||||
return []
|
||||
|
|
|
@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
|||
from django.utils.encoding import smart_text
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class RelatedField(serializers.RelatedField):
|
||||
default_error_messages = {
|
||||
|
@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField):
|
|||
return SimpleUploadedFile(
|
||||
file_obj.name, content, content_type=file_obj.content_type
|
||||
)
|
||||
|
||||
|
||||
from funkwhale_api.federation import serializers as federation_serializers # noqa
|
||||
|
||||
TARGET_ID_TYPE_MAPPING = {
|
||||
"music.Track": ("id", "track"),
|
||||
"music.Artist": ("id", "artist"),
|
||||
"music.Album": ("id", "album"),
|
||||
}
|
||||
|
||||
|
||||
class APIMutationSerializer(serializers.ModelSerializer):
|
||||
created_by = federation_serializers.APIActorSerializer(read_only=True)
|
||||
target = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Mutation
|
||||
fields = [
|
||||
"fid",
|
||||
"uuid",
|
||||
"type",
|
||||
"creation_date",
|
||||
"applied_date",
|
||||
"is_approved",
|
||||
"is_applied",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
"summary",
|
||||
"payload",
|
||||
"previous_state",
|
||||
"target",
|
||||
]
|
||||
read_only_fields = [
|
||||
"uuid",
|
||||
"creation_date",
|
||||
"fid",
|
||||
"is_applied",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
"previous_state",
|
||||
]
|
||||
|
||||
def get_target(self, obj):
|
||||
target = obj.target
|
||||
if not target:
|
||||
return
|
||||
|
||||
id_field, type = TARGET_ID_TYPE_MAPPING[target._meta.label]
|
||||
return {"type": type, "id": getattr(target, id_field), "repr": str(target)}
|
||||
|
||||
def validate_type(self, value):
|
||||
if value not in self.context["registry"]:
|
||||
raise serializers.ValidationError("Invalid mutation type {}".format(value))
|
||||
return value
|
||||
|
|
6
api/funkwhale_api/common/signals.py
Normal file
6
api/funkwhale_api/common/signals.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import django.dispatch
|
||||
|
||||
mutation_created = django.dispatch.Signal(providing_args=["mutation"])
|
||||
mutation_updated = django.dispatch.Signal(
|
||||
providing_args=["mutation", "old_is_approved", "new_is_approved"]
|
||||
)
|
59
api/funkwhale_api/common/tasks.py
Normal file
59
api/funkwhale_api/common/tasks.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
from funkwhale_api.common import channels
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import models
|
||||
from . import serializers
|
||||
from . import signals
|
||||
|
||||
|
||||
@celery.app.task(name="common.apply_mutation")
|
||||
@transaction.atomic
|
||||
@celery.require_instance(
|
||||
models.Mutation.objects.exclude(is_applied=True).select_for_update(), "mutation"
|
||||
)
|
||||
def apply_mutation(mutation):
|
||||
mutation.apply()
|
||||
|
||||
|
||||
@receiver(signals.mutation_created)
|
||||
def broadcast_mutation_created(mutation, **kwargs):
|
||||
group = "instance_activity"
|
||||
channels.group_send(
|
||||
group,
|
||||
{
|
||||
"type": "event.send",
|
||||
"text": "",
|
||||
"data": {
|
||||
"type": "mutation.created",
|
||||
"mutation": serializers.APIMutationSerializer(mutation).data,
|
||||
"pending_review_count": models.Mutation.objects.filter(
|
||||
is_approved=None
|
||||
).count(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.mutation_updated)
|
||||
def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwargs):
|
||||
group = "instance_activity"
|
||||
channels.group_send(
|
||||
group,
|
||||
{
|
||||
"type": "event.send",
|
||||
"text": "",
|
||||
"data": {
|
||||
"type": "mutation.updated",
|
||||
"mutation": serializers.APIMutationSerializer(mutation).data,
|
||||
"pending_review_count": models.Mutation.objects.filter(
|
||||
is_approved=None
|
||||
).count(),
|
||||
"old_is_approved": old_is_approved,
|
||||
"new_is_approved": new_is_approved,
|
||||
},
|
||||
},
|
||||
)
|
|
@ -1,3 +1,21 @@
|
|||
from django.db import transaction
|
||||
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework import exceptions
|
||||
from rest_framework import mixins
|
||||
from rest_framework import permissions
|
||||
from rest_framework import response
|
||||
from rest_framework import viewsets
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import mutations
|
||||
from . import serializers
|
||||
from . import signals
|
||||
from . import tasks
|
||||
from . import utils
|
||||
|
||||
|
||||
class SkipFilterForGetObject:
|
||||
def get_object(self, *args, **kwargs):
|
||||
setattr(self.request, "_skip_filters", True)
|
||||
|
@ -7,3 +25,98 @@ class SkipFilterForGetObject:
|
|||
if getattr(self.request, "_skip_filters", False):
|
||||
return queryset
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
|
||||
class MutationViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
lookup_field = "uuid"
|
||||
queryset = (
|
||||
models.Mutation.objects.all()
|
||||
.order_by("-creation_date")
|
||||
.select_related("created_by", "approved_by")
|
||||
.prefetch_related("target")
|
||||
)
|
||||
serializer_class = serializers.APIMutationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
ordering_fields = ("creation_date",)
|
||||
filterset_class = filters.MutationFilter
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.is_applied:
|
||||
raise exceptions.PermissionDenied("You cannot delete an applied mutation")
|
||||
|
||||
actor = self.request.user.actor
|
||||
is_owner = actor == instance.created_by
|
||||
|
||||
if not any(
|
||||
[
|
||||
is_owner,
|
||||
mutations.registry.has_perm(
|
||||
perm="approve", type=instance.type, obj=instance.target, actor=actor
|
||||
),
|
||||
]
|
||||
):
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def approve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.is_applied:
|
||||
return response.Response(
|
||||
{"error": "This mutation was already applied"}, status=403
|
||||
)
|
||||
actor = self.request.user.actor
|
||||
can_approve = mutations.registry.has_perm(
|
||||
perm="approve", type=instance.type, obj=instance.target, actor=actor
|
||||
)
|
||||
|
||||
if not can_approve:
|
||||
raise exceptions.PermissionDenied()
|
||||
previous_is_approved = instance.is_approved
|
||||
instance.approved_by = actor
|
||||
instance.is_approved = True
|
||||
instance.save(update_fields=["approved_by", "is_approved"])
|
||||
utils.on_commit(tasks.apply_mutation.delay, mutation_id=instance.id)
|
||||
utils.on_commit(
|
||||
signals.mutation_updated.send,
|
||||
sender=None,
|
||||
mutation=instance,
|
||||
old_is_approved=previous_is_approved,
|
||||
new_is_approved=instance.is_approved,
|
||||
)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def reject(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.is_applied:
|
||||
return response.Response(
|
||||
{"error": "This mutation was already applied"}, status=403
|
||||
)
|
||||
actor = self.request.user.actor
|
||||
can_approve = mutations.registry.has_perm(
|
||||
perm="approve", type=instance.type, obj=instance.target, actor=actor
|
||||
)
|
||||
|
||||
if not can_approve:
|
||||
raise exceptions.PermissionDenied()
|
||||
previous_is_approved = instance.is_approved
|
||||
instance.approved_by = actor
|
||||
instance.is_approved = False
|
||||
instance.save(update_fields=["approved_by", "is_approved"])
|
||||
utils.on_commit(
|
||||
signals.mutation_updated.send,
|
||||
sender=None,
|
||||
mutation=instance,
|
||||
old_is_approved=previous_is_approved,
|
||||
new_is_approved=instance.is_approved,
|
||||
)
|
||||
return response.Response({}, status=200)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue