...
 
Commits (97)
...@@ -12,6 +12,7 @@ MUSIC_DIRECTORY_PATH=/music ...@@ -12,6 +12,7 @@ MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True BROWSABLE_API_ENABLED=True
FORWARDED_PROTO=http FORWARDED_PROTO=http
LDAP_ENABLED=False LDAP_ENABLED=False
FUNKWHALE_SPA_HTML_ROOT=http://nginx/front/
# Uncomment this if you're using traefik/https # Uncomment this if you're using traefik/https
# FORCE_HTTPS_URLS=True # FORCE_HTTPS_URLS=True
......
<!-- <!--
Hi there! You are reporting a bug on this project, and we want to thank you! Hi there! You are reporting a bug on this project, and we want to thank you!
If it's the first time you post here, please take a moment to read our Code of Conduct
(https://funkwhale.audio/code-of-conduct/) and ensure your issue respect our guidelines.
To ensure your bug report is as useful as possible, please try to stick To ensure your bug report is as useful as possible, please try to stick
to the following structure. You can leave the parts text between `<!- ->` to the following structure. You can leave the parts text between `<!- ->`
markers untouched, they won't be displayed in your final message. markers untouched, they won't be displayed in your final message.
......
<!-- <!--
Hi there! You are about to share feature request or an idea, and we want to thank you! Hi there! You are about to share feature request or an idea, and we want to thank you!
If it's the first time you post here, please take a moment to read our Code of Conduct
(https://funkwhale.audio/code-of-conduct/) and ensure your issue respect our guidelines.
To ensure we can deal with your idea or request, please try to stick To ensure we can deal with your idea or request, please try to stick
to the following structure. You can leave the parts text between `<!- ->` to the following structure. You can leave the parts text between `<!- ->`
markers untouched, they won't be displayed in your final message. markers untouched, they won't be displayed in your final message.
......
...@@ -2203,7 +2203,7 @@ On both docker and non-docker setup, you'll also have to update your nginx ...@@ -2203,7 +2203,7 @@ On both docker and non-docker setup, you'll also have to update your nginx
configuration for websocket support. Ensure you have the following blocks configuration for websocket support. Ensure you have the following blocks
included in your virtualhost file: included in your virtualhost file:
.. code-block:: txt .. code-block:: text
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
......
...@@ -172,6 +172,10 @@ and metadata. ...@@ -172,6 +172,10 @@ and metadata.
Launch all services Launch all services
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
Before the first Funkwhale launch, it is required to run this::
docker-compose -f dev.yml run --rm front yarn run i18n-compile
Then you can run everything with:: Then you can run everything with::
docker-compose -f dev.yml up front api nginx celeryworker docker-compose -f dev.yml up front api nginx celeryworker
...@@ -276,7 +280,8 @@ When working on federation with traefik, ensure you have this in your ``env``:: ...@@ -276,7 +280,8 @@ When working on federation with traefik, ensure you have this in your ``env``::
EXTERNAL_REQUESTS_VERIFY_SSL=false EXTERNAL_REQUESTS_VERIFY_SSL=false
# this ensure you don't have incorrect urls pointing to http resources # this ensure you don't have incorrect urls pointing to http resources
FUNKWHALE_PROTOCOL=https FUNKWHALE_PROTOCOL=https
# Disable host ports binding for the nginx container, as traefik is serving everything
NGINX_PORTS_MAPPING=80
Typical workflow for a contribution Typical workflow for a contribution
----------------------------------- -----------------------------------
...@@ -363,17 +368,17 @@ Translations in HTML use the ``<translate>`` tag:: ...@@ -363,17 +368,17 @@ Translations in HTML use the ``<translate>`` tag::
<template> <template>
<div> <div>
<h1><translate :translate-context="'Content/Profile/Header'">User profile</translate></h1> <h1><translate translate-context="Content/Profile/Header">User profile</translate></h1>
<p> <p>
<translate <translate
:translate-context="'Content/Profile/Paragraph'" translate-context="Content/Profile/Paragraph"
:translate-params="{username: 'alice'}"> :translate-params="{username: 'alice'}">
You are logged in as %{ username } You are logged in as %{ username }
</translate> </translate>
</p> </p>
<p> <p>
<translate <translate
:translate-context="'Content/Profile/Paragraph'" translate-context="Content/Profile/Paragraph"
translate-plural="You have %{ count } new messages, that's a lot!" translate-plural="You have %{ count } new messages, that's a lot!"
:translate-n="unreadMessagesCount" :translate-n="unreadMessagesCount"
:translate-params="{count: unreadMessagesCount}"> :translate-params="{count: unreadMessagesCount}">
...@@ -512,7 +517,7 @@ It's possible to nest multiple component parts to reach a higher level of detail ...@@ -512,7 +517,7 @@ It's possible to nest multiple component parts to reach a higher level of detail
- ``Content/*/Form.Help text`` - ``Content/*/Form.Help text``
Collecting translatable strings Collecting translatable strings
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If you want to ensure your translatable strings are correctly marked for translation, If you want to ensure your translatable strings are correctly marked for translation,
you can try to extract them. you can try to extract them.
......
...@@ -31,4 +31,9 @@ are outlined in `CONTRIBUTING <CONTRIBUTING.rst>`_. ...@@ -31,4 +31,9 @@ are outlined in `CONTRIBUTING <CONTRIBUTING.rst>`_.
Translate Translate
^^^^^^^^^ ^^^^^^^^^
Translators willing to help can refer to `TRANSLATORS <TRANSLATORS>`_ for instructions. Translators willing to help can refer to `TRANSLATORS <TRANSLATORS.rst>`_ for instructions.
Code of Conduct
---------------
`Our Code of Conduct <https://funkwhale.audio/code-of-conduct/>`_ applies to all the community spaces, including our GitLab instance. Please, take a moment to read it.
...@@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns ...@@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
from rest_framework_jwt import views as jwt_views from rest_framework_jwt import views as jwt_views
from funkwhale_api.activity import views as activity_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.music import views
from funkwhale_api.playlists import views as playlists_views from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet from funkwhale_api.subsonic.views import SubsonicViewSet
...@@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") ...@@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register( router.register(
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
) )
router.register(r"mutations", common_views.MutationViewSet, "mutations")
v1_patterns = router.urls v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False) subsonic_router = routers.SimpleRouter(trailing_slash=False)
...@@ -73,6 +75,9 @@ v1_patterns += [ ...@@ -73,6 +75,9 @@ v1_patterns += [
r"^users/", r"^users/",
include(("funkwhale_api.users.api_urls", "users"), namespace="users"), include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
), ),
url(
r"^auth/", include(("funkwhale_api.users.auth_urls", "auth"), namespace="auth")
),
url(r"^token/$", jwt_views.obtain_jwt_token, name="token"), url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"), url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
] ]
......
import os import os
import django os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
import django # noqa
django.setup() django.setup()
from .routing import application # noqa from .routing import application # noqa
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
from channels.routing import ProtocolTypeRouter, URLRouter from channels.routing import ProtocolTypeRouter, URLRouter
from channels.sessions import SessionMiddlewareStack
from django.conf.urls import url from django.conf.urls import url
from funkwhale_api.common.auth import TokenAuthMiddleware from funkwhale_api.common.auth import TokenAuthMiddleware
...@@ -7,8 +8,12 @@ from funkwhale_api.instance import consumers ...@@ -7,8 +8,12 @@ from funkwhale_api.instance import consumers
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
# Empty for now (http->django views is added by default) # Empty for now (http->django views is added by default)
"websocket": TokenAuthMiddleware( "websocket": SessionMiddlewareStack(
URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)]) TokenAuthMiddleware(
URLRouter(
[url("^api/v1/activity$", consumers.InstanceActivityConsumer)]
)
)
) )
} }
) )
...@@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None) ...@@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None)
if env_file: if env_file:
# we have an explicitely specified env file # we have an explicitely specified env file
# so we try to load and it fail loudly if it does not exist # so we try to load and it fail loudly if it does not exist
print("ENV_FILE", env_file)
env.read_env(env_file) env.read_env(env_file)
else: else:
# we try to load from .env and config/.env # we try to load from .env and config/.env
...@@ -150,7 +149,7 @@ if RAVEN_ENABLED: ...@@ -150,7 +149,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here. # Apps specific for this project go here.
LOCAL_APPS = ( LOCAL_APPS = (
"funkwhale_api.common", "funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.activity.apps.ActivityConfig", "funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app "funkwhale_api.users", # custom users app
# Your stuff: custom apps go here # Your stuff: custom apps go here
...@@ -331,7 +330,7 @@ AUTHENTICATION_BACKENDS = ( ...@@ -331,7 +330,7 @@ AUTHENTICATION_BACKENDS = (
"funkwhale_api.users.auth_backends.ModelBackend", "funkwhale_api.users.auth_backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend", "allauth.account.auth_backends.AuthenticationBackend",
) )
SESSION_COOKIE_HTTPONLY = False SESSION_COOKIE_HTTPONLY = True
# Some really nice defaults # Some really nice defaults
ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
...@@ -538,7 +537,7 @@ MUSICBRAINZ_HOSTNAME = env("MUSICBRAINZ_HOSTNAME", default="musicbrainz.org") ...@@ -538,7 +537,7 @@ MUSICBRAINZ_HOSTNAME = env("MUSICBRAINZ_HOSTNAME", default="musicbrainz.org")
# Custom Admin URL, use {% url 'admin:index' %} # Custom Admin URL, use {% url 'admin:index' %}
ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/") ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/")
CSRF_USE_SESSIONS = True CSRF_USE_SESSIONS = False
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.cache"
# Playlist settings # Playlist settings
......
...@@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False ...@@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False
# Your local stuff: Below this line define 3rd party library settings # 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] CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
......
from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from . import models
from . import tasks
def register(model): def register(model):
""" """
...@@ -17,3 +20,28 @@ def register(model): ...@@ -17,3 +20,28 @@ def register(model):
return initial_register(model)(modeladmin) return initial_register(model)(modeladmin)
return decorator 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 asgiref.sync import async_to_sync
from channels.generic.websocket import JsonWebsocketConsumer from channels.generic.websocket import JsonWebsocketConsumer
from channels import auth
from funkwhale_api.common import channels from funkwhale_api.common import channels
class JsonAuthConsumer(JsonWebsocketConsumer): class JsonAuthConsumer(JsonWebsocketConsumer):
def connect(self): def connect(self):
try: if "user" not in self.scope:
assert self.scope["user"].pk is not None try:
except (AssertionError, AttributeError, KeyError): self.scope["user"] = async_to_sync(auth.get_user)(self.scope)
return self.close() except (ValueError, AssertionError, AttributeError, KeyError):
return self.close()
return self.accept() if self.scope["user"] and self.scope["user"].is_authenticated:
return self.accept()
else:
return self.close()
def accept(self): def accept(self):
super().accept() super().accept()
......
from rest_framework import response from django.db import transaction
from rest_framework import decorators 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): def action_route(serializer_class):
...@@ -12,3 +24,67 @@ def action_route(serializer_class): ...@@ -12,3 +24,67 @@ def action_route(serializer_class):
return response.Response(result, status=200) return response.Response(result, status=200)
return action 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 import django_filters
from django import forms
from django.db import models from django.db import models
from . import search from . import search
...@@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter): ...@@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter):
def filter(self, qs, value): def filter(self, qs, value):
if not value: if not value:
return qs return qs
cleaned = self.config.clean(value) try:
cleaned = self.config.clean(value)
except forms.ValidationError:
return qs.none()
return search.apply(qs, cleaned) 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.conf import settings
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 LocalFromFidQuerySet:
def local(self, include=True):
host = settings.FEDERATION_HOSTNAME
query = models.Q(fid__startswith="http://{}/".format(host)) | models.Q(
fid__startswith="https://{}/".format(host)
)
if include:
return self.filter(query)
else:
return self.filter(~query)
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()