summaryrefslogtreecommitdiff
path: root/patchwork/api/patch.py
blob: f6cb276d7007dc21fe79059f6686b7181a03907f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# 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 gettext_lazy as _
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 import status

from patchwork.api.base import BaseHyperlinkedModelSerializer
from patchwork.api.base import PatchworkPermission
from patchwork.api.embedded import PatchSerializer
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.api.filters import PatchFilterSet
from patchwork.models import Patch
from patchwork.models import PatchRelation
from patchwork.models import State
from patchwork.parser import clean_subject


class StateField(RelatedField):
    """Avoid the need for a state endpoint.

    TODO(stephenfin): Consider switching to SlugRelatedField for the v2.0 API.
    """
    default_error_messages = {
        'required': _('This field is required.'),
        'invalid_choice': _('Invalid state {name}. Expected one of: '
                            '{choices}.'),
        'incorrect_type': _('Incorrect type. Expected string value, received '
                            '{data_type}.'),
    }

    def to_internal_value(self, data):
        data = slugify(data.lower())
        try:
            return self.get_queryset().get(slug=data)
        except State.DoesNotExist:
            self.fail('invalid_choice', name=data, choices=', '.join([
                x.slug for x in self.get_queryset()]))

    def to_representation(self, obj):
        return obj.slug

    def get_queryset(self):
        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()
    project = ProjectSerializer(read_only=True)
    state = StateField()
    submitter = PersonSerializer(read_only=True)
    delegate = UserSerializer(allow_null=True)
    mbox = SerializerMethodField()
    series = SeriesSerializer(read_only=True)
    comments = SerializerMethodField()
    check = SerializerMethodField()
    checks = SerializerMethodField()
    tags = SerializerMethodField()
    related = PatchSerializer(
        source='related.patches', many=True, default=[])

    def get_web_url(self, instance):
        request = self.context.get('request')
        return request.build_absolute_uri(instance.get_absolute_url())

    def get_mbox(self, instance):
        request = self.context.get('request')
        return request.build_absolute_uri(instance.get_mbox_url())

    def get_comments(self, patch):
        return self.context.get('request').build_absolute_uri(
            reverse('api-patch-comment-list', kwargs={'pk': patch.id}))

    def get_check(self, instance):
        return instance.combined_check_state

    def get_checks(self, instance):
        return self.context.get('request').build_absolute_uri(
            reverse('api-check-list', kwargs={'patch_id': instance.id}))

    def get_tags(self, instance):
        # TODO(stephenfin): Make tags performant, possibly by reworking the
        # model
        return {}

    def validate_delegate(self, value):
        """Check that the delgate is a maintainer of the patch's project."""
        if not value:
            return value

        if not value.profile.maintainer_projects.only('id').filter(
                id=self.instance.project.id).exists():
            raise ValidationError("User '%s' is not a maintainer for project "
                                  "'%s'" % (value, self.instance.project))
        return value

    def to_representation(self, instance):
        # NOTE(stephenfin): This is here to ensure our API looks the same even
        # after we changed the series-patch relationship from M:N to 1:N. It
        # will be removed in API v2
        data = super(PatchListSerializer, self).to_representation(instance)
        data['series'] = [data['series']] if data['series'] else []

        # Remove this patch from 'related'
        if 'related' in data and data['related']:
            data['related'] = [
                x for x in data['related'] if x['id'] != data['id']
            ]

        return data

    class Meta:
        model = Patch
        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', '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', 'related',),
        }
        extra_kwargs = {
            'url': {'view_name': 'api-patch-detail'},
        }


class PatchDetailSerializer(PatchListSerializer):

    headers = SerializerMethodField()
    prefixes = SerializerMethodField()

    def get_headers(self, patch):
        headers = {}

        if patch.headers:
            parsed = email.parser.Parser().parsestr(patch.headers, True)
            for key in parsed.keys():
                headers[key] = parsed.get_all(key)
                # Let's return a single string instead of a list if only one
                # header with this key is present
                if len(headers[key]) == 1:
                    headers[key] = headers[key][0]

        return headers

    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 + (
            'headers', 'content', 'diff', 'prefixes')
        read_only_fields = PatchListSerializer.Meta.read_only_fields + (
            'headers', 'content', 'diff', 'prefixes')
        versioned_fields = PatchListSerializer.Meta.versioned_fields
        extra_kwargs = PatchListSerializer.Meta.extra_kwargs


class PatchList(ListAPIView):
    """List patches."""

    permission_classes = (PatchworkPermission,)
    serializer_class = PatchListSerializer
    filter_class = filterset_class = PatchFilterSet
    search_fields = ('name',)
    ordering_fields = ('id', 'name', 'project', 'date', 'state', 'archived',
                       'submitter', 'check')
    ordering = 'id'

    def get_queryset(self):
        # TODO(dja): we need to revisit this after the patch migration, paying
        # particular attention to cases with filtering
        return Patch.objects.all()\
            .prefetch_related(
                'check_set', 'delegate', 'project', 'series__project',
                'related__patches__project')\
            .select_related('state', 'submitter', 'series')\
            .defer('content', 'diff', 'headers')


class PatchDetail(RetrieveUpdateAPIView):
    """
    get:
    Show a patch.

    patch:
    Update a patch.

    put:
    Update a patch.
    """
    permission_classes = (PatchworkPermission,)
    serializer_class = PatchDetailSerializer

    def get_queryset(self):
        return Patch.objects.all()\
            .prefetch_related('check_set', 'related__patches__project')\
            .select_related('project', 'state', 'submitter', 'delegate',
                            'series')