Unfortunately, the use of embedded serializers for some fields breaks the ability to update these fields, either via the HTML interface (where the widget is totally busted) or via a client like 'git-pw'. What we actually want is to be able to update these fields like normal primary key but show them using the embedded serializer. We do just this by using a modified variant of the PrimaryKeyRelatedField and using the serializers simply for displaying.
Signed-off-by: Stephen Finucane <step...@that.guru> Closes: #216 --- I'm planning to backport this to stable/2.1 and stable/2.0. Technically this is a change in behavior but as this was a bug, I think it's a reasonable fix. --- patchwork/api/embedded.py | 272 +++++++++++------- patchwork/tests/api/test_patch.py | 10 +- ...fix-patch-delegation-74d7d90f0fb55989.yaml | 5 + 3 files changed, 174 insertions(+), 113 deletions(-) create mode 100644 releasenotes/notes/fix-patch-delegation-74d7d90f0fb55989.yaml diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py index be61cd5f..83df0e50 100644 --- a/patchwork/api/embedded.py +++ b/patchwork/api/embedded.py @@ -9,14 +9,54 @@ A collection of serializers. None of the serializers here should reference nested fields. """ +from collections import OrderedDict + from rest_framework.serializers import CharField from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import PrimaryKeyRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import CheckHyperlinkedIdentityField from patchwork import models +class SerializedRelatedField(PrimaryKeyRelatedField): + """ + A read-write field that expects a primary key for writes and returns a + serialized version of the underlying field on reads. + """ + + def use_pk_only_optimization(self): + # We're using embedded serializers so we want the whole object + return False + + def get_queryset(self): + return self._Serializer.Meta.model.objects.all() + + def get_choices(self, cutoff=None): + # Override this so we don't call 'to_representation', which no longer + # returns a flat value + queryset = self.get_queryset() + if queryset is None: + # Ensure that field.choices returns something sensible + # even when accessed with a read-only field. + return {} + + if cutoff is not None: + queryset = queryset[:cutoff] + + return OrderedDict([ + ( + item.pk, + self.display_value(item) + ) + for item in queryset + ]) + + def to_representation(self, data): + return self._Serializer(context=self.context).to_representation(data) + + class MboxMixin(BaseHyperlinkedModelSerializer): """Embed a link to the mbox URL. @@ -41,132 +81,150 @@ class WebURLMixin(BaseHyperlinkedModelSerializer): return request.build_absolute_uri(instance.get_absolute_url()) -class BundleSerializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): - - class Meta: - model = models.Bundle - fields = ('id', 'url', 'web_url', 'name', 'mbox') - read_only_fields = fields - versioned_field = { - '1.1': ('web_url', ), - } - extra_kwargs = { - 'url': {'view_name': 'api-bundle-detail'}, - } +class BundleSerializer(SerializedRelatedField): + + class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + + class Meta: + model = models.Bundle + fields = ('id', 'url', 'web_url', 'name', 'mbox') + read_only_fields = fields + versioned_field = { + '1.1': ('web_url', ), + } + extra_kwargs = { + 'url': {'view_name': 'api-bundle-detail'}, + } + + +class CheckSerializer(SerializedRelatedField): + + class _Serializer(BaseHyperlinkedModelSerializer): + + url = CheckHyperlinkedIdentityField('api-check-detail') + + def to_representation(self, instance): + data = super(CheckSerializer._Serializer, self).to_representation( + instance) + data['state'] = instance.get_state_display() + return data + + class Meta: + model = models.Check + fields = ('id', 'url', 'date', 'state', 'target_url', 'context') + read_only_fields = fields + extra_kwargs = { + 'url': {'view_name': 'api-check-detail'}, + } + + +class CoverLetterSerializer(SerializedRelatedField): + + class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + + class Meta: + model = models.CoverLetter + fields = ('id', 'url', 'web_url', 'msgid', 'date', 'name', 'mbox') + read_only_fields = fields + versioned_field = { + '1.1': ('web_url', 'mbox', ), + } + extra_kwargs = { + 'url': {'view_name': 'api-cover-detail'}, + } + + +class PatchSerializer(SerializedRelatedField): + + class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + + class Meta: + model = models.Patch + fields = ('id', 'url', 'web_url', 'msgid', 'date', 'name', 'mbox') + read_only_fields = fields + versioned_field = { + '1.1': ('web_url', ), + } + extra_kwargs = { + 'url': {'view_name': 'api-patch-detail'}, + } -class CheckSerializer(BaseHyperlinkedModelSerializer): +class PersonSerializer(SerializedRelatedField): - url = CheckHyperlinkedIdentityField('api-check-detail') + class _Serializer(BaseHyperlinkedModelSerializer): - def to_representation(self, instance): - data = super(CheckSerializer, self).to_representation(instance) - data['state'] = instance.get_state_display() - return data + class Meta: + model = models.Person + fields = ('id', 'url', 'name', 'email') + read_only_fields = fields + extra_kwargs = { + 'url': {'view_name': 'api-person-detail'}, + } - class Meta: - model = models.Check - fields = ('id', 'url', 'date', 'state', 'target_url', 'context') - read_only_fields = fields - extra_kwargs = { - 'url': {'view_name': 'api-check-detail'}, - } +class ProjectSerializer(SerializedRelatedField): + class _Serializer(BaseHyperlinkedModelSerializer): -class CoverLetterSerializer(MboxMixin, WebURLMixin, - BaseHyperlinkedModelSerializer): + link_name = CharField(max_length=255, source='linkname') + list_id = CharField(max_length=255, source='listid') + list_email = CharField(max_length=200, source='listemail') - class Meta: - model = models.CoverLetter - fields = ('id', 'url', 'web_url', 'msgid', 'date', 'name', 'mbox') - read_only_fields = fields - versioned_field = { - '1.1': ('web_url', 'mbox', ), - } - extra_kwargs = { - 'url': {'view_name': 'api-cover-detail'}, - } + class Meta: + model = models.Project + fields = ('id', 'url', 'name', 'link_name', 'list_id', + 'list_email', 'web_url', 'scm_url', 'webscm_url') + read_only_fields = fields + extra_kwargs = { + 'url': {'view_name': 'api-project-detail'}, + } -class PatchSerializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): +class SeriesSerializer(SerializedRelatedField): - class Meta: - model = models.Patch - fields = ('id', 'url', 'web_url', 'msgid', 'date', 'name', 'mbox') - read_only_fields = fields - versioned_field = { - '1.1': ('web_url', ), - } - extra_kwargs = { - 'url': {'view_name': 'api-patch-detail'}, - } + class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer): + class Meta: + model = models.Series + fields = ('id', 'url', 'date', 'name', 'version', 'mbox') + read_only_fields = fields + versioned_field = { + '1.1': ('web_url', ), + } + extra_kwargs = { + 'url': {'view_name': 'api-series-detail'}, + } -class PersonSerializer(BaseHyperlinkedModelSerializer): - class Meta: - model = models.Person - fields = ('id', 'url', 'name', 'email') - read_only_fields = fields - extra_kwargs = { - 'url': {'view_name': 'api-person-detail'}, - } +class UserSerializer(SerializedRelatedField): + class _Serializer(BaseHyperlinkedModelSerializer): -class ProjectSerializer(BaseHyperlinkedModelSerializer): + class Meta: + model = models.User + fields = ('id', 'url', 'username', 'first_name', 'last_name', + 'email') + read_only_fields = fields + extra_kwargs = { + 'url': {'view_name': 'api-user-detail'}, + } - link_name = CharField(max_length=255, source='linkname') - list_id = CharField(max_length=255, source='listid') - list_email = CharField(max_length=200, source='listemail') - class Meta: - model = models.Project - fields = ('id', 'url', 'name', 'link_name', 'list_id', 'list_email', - 'web_url', 'scm_url', 'webscm_url') - read_only_fields = fields - extra_kwargs = { - 'url': {'view_name': 'api-project-detail'}, - } +class UserProfileSerializer(SerializedRelatedField): + class _Serializer(BaseHyperlinkedModelSerializer): -class SeriesSerializer(MboxMixin, WebURLMixin, - BaseHyperlinkedModelSerializer): + username = CharField(source='user.username') + first_name = CharField(source='user.first_name') + last_name = CharField(source='user.last_name') + email = CharField(source='user.email') - class Meta: - model = models.Series - fields = ('id', 'url', 'date', 'name', 'version', 'mbox') - read_only_fields = fields - versioned_field = { - '1.1': ('web_url', ), - } - extra_kwargs = { - 'url': {'view_name': 'api-series-detail'}, - } - - -class UserSerializer(BaseHyperlinkedModelSerializer): - - class Meta: - model = models.User - fields = ('id', 'url', 'username', 'first_name', 'last_name', 'email') - read_only_fields = fields - extra_kwargs = { - 'url': {'view_name': 'api-user-detail'}, - } - - -class UserProfileSerializer(BaseHyperlinkedModelSerializer): - - username = CharField(source='user.username') - first_name = CharField(source='user.first_name') - last_name = CharField(source='user.last_name') - email = CharField(source='user.email') - - class Meta: - model = models.UserProfile - fields = ('id', 'url', 'username', 'first_name', 'last_name', 'email') - read_only_fields = fields - extra_kwargs = { - 'url': {'view_name': 'api-user-detail'}, - } + class Meta: + model = models.UserProfile + fields = ('id', 'url', 'username', 'first_name', 'last_name', + 'email') + read_only_fields = fields + extra_kwargs = { + 'url': {'view_name': 'api-user-detail'}, + } diff --git a/patchwork/tests/api/test_patch.py b/patchwork/tests/api/test_patch.py index 53099256..497cb2de 100644 --- a/patchwork/tests/api/test_patch.py +++ b/patchwork/tests/api/test_patch.py @@ -208,8 +208,7 @@ class TestPatchAPI(APITestCase): 'state': state.name, 'delegate': user.id}) self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) self.assertEqual(Patch.objects.get(id=patch.id).state, state) - # TODO(stephenfin): This is currently broken due to #216 - # self.assertEqual(Patch.objects.get(id=patch.id).delegate, user) + self.assertEqual(Patch.objects.get(id=patch.id).delegate, user) def test_update_invalid(self): """Ensure we handle invalid Patch updates.""" @@ -229,10 +228,9 @@ class TestPatchAPI(APITestCase): user_b = create_user() resp = self.client.patch(self.api_url(patch.id), {'delegate': user_b.id}) - # TODO(stephenfin): This is currently broken due to #216 - # self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code) - # self.assertContains(resp, "User '%s' is not a maintainer" % user_b, - # status_code=status.HTTP_400_BAD_REQUEST) + self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code) + self.assertContains(resp, "User '%s' is not a maintainer" % user_b, + status_code=status.HTTP_400_BAD_REQUEST) def test_delete(self): """Ensure deletions are always rejected.""" diff --git a/releasenotes/notes/fix-patch-delegation-74d7d90f0fb55989.yaml b/releasenotes/notes/fix-patch-delegation-74d7d90f0fb55989.yaml new file mode 100644 index 00000000..c3756aa0 --- /dev/null +++ b/releasenotes/notes/fix-patch-delegation-74d7d90f0fb55989.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + An issue that prevented updating of delegates using the REST API is + resolved. (`#216 <https://github.com/getpatchwork/patchwork/issues/216>`__) -- 2.17.1 _______________________________________________ Patchwork mailing list Patchwork@lists.ozlabs.org https://lists.ozlabs.org/listinfo/patchwork