aboutsummaryrefslogtreecommitdiff
path: root/patchwork/api/patch.py
diff options
context:
space:
mode:
Diffstat (limited to 'patchwork/api/patch.py')
-rw-r--r--patchwork/api/patch.py125
1 files changed, 120 insertions, 5 deletions
diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index 1a3ce90..efa38e1 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -1,27 +1,34 @@
# Patchwork - automated patch tracking system
# Copyright (C) 2016 Linaro Corporation
+# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
+# Copyright (C) 2020, IBM Corporation
#
# SPDX-License-Identifier: GPL-2.0-or-later
import email.parser
+from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
+from rest_framework import status
+from rest_framework.exceptions import APIException
+from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import ListAPIView
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.relations import RelatedField
from rest_framework.reverse import reverse
from rest_framework.serializers import SerializerMethodField
-from rest_framework.serializers import ValidationError
from patchwork.api.base import BaseHyperlinkedModelSerializer
from patchwork.api.base import PatchworkPermission
from patchwork.api.filters import PatchFilterSet
+from patchwork.api.embedded import PatchRelationSerializer
from patchwork.api.embedded import PersonSerializer
from patchwork.api.embedded import ProjectSerializer
from patchwork.api.embedded import SeriesSerializer
from patchwork.api.embedded import UserSerializer
from patchwork.models import Patch
+from patchwork.models import PatchRelation
from patchwork.models import State
from patchwork.parser import clean_subject
@@ -54,6 +61,15 @@ class StateField(RelatedField):
return State.objects.all()
+class PatchConflict(APIException):
+ status_code = status.HTTP_409_CONFLICT
+ default_detail = (
+ 'At least one patch is already part of another relation. You have to '
+ 'explicitly remove a patch from its existing relation before moving '
+ 'it to this one.'
+ )
+
+
class PatchListSerializer(BaseHyperlinkedModelSerializer):
web_url = SerializerMethodField()
@@ -67,6 +83,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer):
check = SerializerMethodField()
checks = SerializerMethodField()
tags = SerializerMethodField()
+ related = PatchRelationSerializer()
def get_web_url(self, instance):
request = self.context.get('request')
@@ -109,6 +126,16 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer):
# will be removed in API v2
data = super(PatchListSerializer, self).to_representation(instance)
data['series'] = [data['series']] if data['series'] else []
+
+ # stop the related serializer returning this patch in the list of
+ # related patches. Also make it return an empty list, not null/None
+ if 'related' in data:
+ if data['related']:
+ data['related'] = [p for p in data['related']
+ if p['id'] != instance.id]
+ else:
+ data['related'] = []
+
return data
class Meta:
@@ -116,13 +143,13 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer):
fields = ('id', 'url', 'web_url', 'project', 'msgid',
'list_archive_url', 'date', 'name', 'commit_ref', 'pull_url',
'state', 'archived', 'hash', 'submitter', 'delegate', 'mbox',
- 'series', 'comments', 'check', 'checks', 'tags')
+ 'series', 'comments', 'check', 'checks', 'tags', 'related',)
read_only_fields = ('web_url', 'project', 'msgid', 'list_archive_url',
'date', 'name', 'hash', 'submitter', 'mbox',
'series', 'comments', 'check', 'checks', 'tags')
versioned_fields = {
'1.1': ('comments', 'web_url'),
- '1.2': ('list_archive_url',),
+ '1.2': ('list_archive_url', 'related',),
}
extra_kwargs = {
'url': {'view_name': 'api-patch-detail'},
@@ -151,6 +178,94 @@ class PatchDetailSerializer(PatchListSerializer):
def get_prefixes(self, instance):
return clean_subject(instance.name)[1]
+ def update(self, instance, validated_data):
+ # d-r-f cannot handle writable nested models, so we handle that
+ # specifically ourselves and let d-r-f handle the rest
+ if 'related' not in validated_data:
+ return super(PatchDetailSerializer, self).update(
+ instance, validated_data)
+
+ related = validated_data.pop('related')
+
+ # Validation rules
+ # ----------------
+ #
+ # Permissions: to change a relation:
+ # for all patches in the relation, current and proposed,
+ # the user must be maintainer of the patch's project
+ # Note that this has a ratchet effect: if you add a cross-project
+ # relation, only you or another maintainer across both projects can
+ # modify that relationship in _any way_.
+ #
+ # Break before Make: a patch must be explicitly removed from a
+ # relation before being added to another
+ #
+ # No Read-Modify-Write for deletion:
+ # to delete a patch from a relation, clear _its_ related patch,
+ # don't modify one of the patches that are to remain.
+ #
+ # (As a consequence of those two, operations are additive:
+ # if 1 is in a relation with [1,2,3], then
+ # patching 1 with related=[2,4] gives related=[1,2,3,4])
+
+ # Permissions:
+ # Because we're in a serializer, not a view, this is a bit clunky
+ user = self.context['request'].user.profile
+ # Must be maintainer of:
+ # - current patch
+ self.check_user_maintains_all(user, [instance])
+ # - all patches currently in relation
+ # - all patches proposed to be in relation
+ patches = set(related['patches']) if related else {}
+ if instance.related is not None:
+ patches = patches.union(instance.related.patches.all())
+ self.check_user_maintains_all(user, patches)
+
+ # handle deletion
+ if not related['patches']:
+ # do not allow relations with a single patch
+ if instance.related and instance.related.patches.count() == 2:
+ instance.related.delete()
+ instance.related = None
+ return super(PatchDetailSerializer, self).update(
+ instance, validated_data)
+
+ # break before make
+ relations = {patch.related for patch in patches if patch.related}
+ if len(relations) > 1:
+ raise PatchConflict()
+ if relations:
+ relation = relations.pop()
+ else:
+ relation = None
+ if relation and instance.related is not None:
+ if instance.related != relation:
+ raise PatchConflict()
+
+ # apply
+ if relation is None:
+ relation = PatchRelation()
+ relation.save()
+ for patch in patches:
+ patch.related = relation
+ patch.save()
+ instance.related = relation
+ instance.save()
+
+ return super(PatchDetailSerializer, self).update(
+ instance, validated_data)
+
+ @staticmethod
+ def check_user_maintains_all(user, patches):
+ maintains = user.maintainer_projects.all()
+ if any(s.project not in maintains for s in patches):
+ detail = (
+ 'At least one patch is part of a project you are not '
+ 'maintaining.'
+ )
+ raise PermissionDenied(detail=detail)
+ return True
+
class Meta:
model = Patch
fields = PatchListSerializer.Meta.fields + (
@@ -174,7 +289,7 @@ class PatchList(ListAPIView):
def get_queryset(self):
return Patch.objects.all()\
- .prefetch_related('check_set')\
+ .prefetch_related('check_set', 'related__patches__project')\
.select_related('project', 'state', 'submitter', 'delegate',
'series__project')\
.defer('content', 'diff', 'headers')
@@ -196,6 +311,6 @@ class PatchDetail(RetrieveUpdateAPIView):
def get_queryset(self):
return Patch.objects.all()\
- .prefetch_related('check_set')\
+ .prefetch_related('check_set', 'related__patches__project')\
.select_related('project', 'state', 'submitter', 'delegate',
'series')