diff options
Diffstat (limited to 'patchwork/api/patch.py')
-rw-r--r-- | patchwork/api/patch.py | 125 |
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') |