Skip to content
Snippets Groups Projects
Commit 8e4320d1 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '689-mutations' into 'develop'

[EPIC] Audio metadata update - UI / API

See merge request funkwhale/funkwhale!621
parents 1a1c62ab e0c5ffcb
No related branches found
No related tags found
No related merge requests found
Showing
with 875 additions and 23 deletions
......@@ -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 = {}
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
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"]
)
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,
},
},
)
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)
......@@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
router.register(r"federation/shared", views.SharedViewSet, "shared")
router.register(r"federation/actors", views.ActorViewSet, "actors")
router.register(r"federation/edits", views.EditViewSet, "edits")
router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
......
......@@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
return response.Response({})
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer]
# queryset = common_models.Mutation.objects.local().select_related()
# serializer_class = serializers.ActorSerializer
class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment