api.py 23.3 KB
Newer Older
1
# Patchwork - automated patch tracking system
2
3
4
# coding=utf-8
#
# Copyright (C) 2014,2015 Intel Corporation
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#
# This file is part of the Patchwork package.
#
# Patchwork is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# Patchwork is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Patchwork; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

22
from django.core.exceptions import FieldDoesNotExist, PermissionDenied
23
24
from django.conf import settings
from django.core import mail
25
from django.db.models import Q
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
26
from django.http import HttpResponse, HttpResponseNotFound
27
from patchwork.tasks import send_reviewer_notification
28
from patchwork.models import (Project, Series, SeriesRevision, Patch, EventLog,
29
                              State, Test, TestResult, TestState, Person,
30
                              RevisionState)
31
32
from rest_framework import (views, viewsets, mixins, filters, permissions,
                            status)
33
from rest_framework.authentication import BasicAuthentication
34
from rest_framework.decorators import detail_route
35
36
from rest_framework.response import Response
from rest_framework.generics import get_object_or_404
37
from rest_framework.pagination import PageNumberPagination
38
39
40
from patchwork.serializers import (ProjectSerializer, SeriesSerializer,
                                   RevisionSerializer, PatchSerializer,
                                   EventLogSerializer, TestResultSerializer)
41
from patchwork.views import patch_to_mbox, revision_cover_letter_to_mbox
42
from patchwork.views.patch import mbox as patch_mbox
43
from patchwork.permissions import Can
44
from patchwork.utils import group
45
import django_filters
46
47


48
API_REVISION = 4
49

50

51
52
53
54
55
class RelatedOrderingFilter(filters.OrderingFilter):
    """
    Extends OrderingFilter to support ordering by fields in related models.
    """

56
57
58
59
    def get_ordering(self, request, queryset, view):
        params = super(RelatedOrderingFilter, self).get_ordering(request,
                                                                 queryset,
                                                                 view)
60
61
62
63
        if params:
            return [param.replace('.', '__') for param in params]

    def is_valid_field(self, model, field):
64
        components = field.split('.', 1)
65
        try:
66
            field = model._meta.get_field(components[0])
67
68
69
70
71
72
73
74

            # foreign key
            if field.rel and len(components) == 2:
                return self.is_valid_field(field.rel.to, components[1])
            return True
        except FieldDoesNotExist:
            return False

75
    def remove_invalid_fields(self, queryset, ordering, view, request):
76
77
78
        return [term for term in ordering
                if self.is_valid_field(queryset.model, term.lstrip('-'))]

79

80
class MaintainerPermission(permissions.BasePermission):
81

82
83
84
85
86
    def has_object_permission(self, request, view, obj):
        # read only for everyone
        if request.method in permissions.SAFE_METHODS:
            return True

87
        return Can(request.user).edit(obj.project)
88

89
90
91
92
93
94
95
96

class RequestDjangoFilterBackend(filters.DjangoFilterBackend):
    """A DjangoFilterBackend class that also includes the request"""

    def filter_queryset(self, request, queryset, view):
        filter_class = self.get_filter_class(view, queryset)

        if filter_class:
97
            filter = filter_class(request.query_params, queryset=queryset)
98
99
100
101
102
103
            filter.request = request
            return filter.qs

        return queryset


104
105
106
107
class API(views.APIView):
    permission_classes = (permissions.AllowAny,)

    def get(self, request, format=None):
108
109
        return Response({'revision': API_REVISION})

110

111
112
class ListPagination(PageNumberPagination):
    page_size = 20
113
    page_size_query_param = 'perpage'
114
115
116
117
    max_page_size = 100


class ListMixin(object):
118
    filter_backends = (RelatedOrderingFilter, )
119
    pagination_class = ListPagination
120

121

122
class SeriesFilter(django_filters.FilterSet):
123

124
    def filter_submitter(self, queryset, name, submitter):
125
126
127
128
        try:
            submitter = int(submitter)
            queryset = queryset.filter(submitter=submitter)
        except ValueError:
129
            if submitter == 'self' and self.request.user.is_authenticated:
130
131
132
133
                people = Person.objects.filter(user=self.request.user)
                queryset = queryset.filter(submitter__in=people)
        return queryset

134
    def filter_reviewer(self, queryset, name, reviewer):
135
136
137
138
139
140
141
142
        try:
            reviewer = int(reviewer)
            queryset = queryset.filter(reviewer=reviewer)
        except ValueError:
            if reviewer == 'null':
                queryset = queryset.filter(reviewer__isnull=True)
        return queryset

143
    def filter_test_state(self, queryset, name, test_states):
144
145
146
        if not test_states:
            return queryset

147
        try:
148
149
150
            states = map(TestState.from_string, test_states.split(','))
            queryset = queryset.filter(
                last_revision__test_state__in=states)
151
        except KeyError:
152
            if test_states == 'null':
153
154
                queryset = queryset.filter(
                        last_revision__test_state__isnull=True)
155

156
157
        return queryset

158
    def filter_state(self, queryset, name, state_names):
159
160
161
162
163
164
165
166
167
        if not state_names:
            return queryset

        try:
            states = map(RevisionState.from_string, state_names.split(','))
            return queryset.filter(last_revision__state__in=states)
        except KeyError:
            return queryset

168
    submitted_since = django_filters.CharFilter(field_name='submitted',
169
                                                lookup_expr='gt')
170
    updated_since = django_filters.CharFilter(field_name='last_updated',
171
                                              lookup_expr='gt')
172
    submitted_before = django_filters.CharFilter(field_name='submitted',
173
                                                 lookup_expr='lte')
174
175
    updated_before = django_filters.CharFilter(field_name='last_updated',
                                               lookup_expr='lte')
176
177
178
179
180
    submitter = django_filters.CharFilter(method='filter_submitter')
    reviewer = django_filters.CharFilter(method='filter_reviewer')
    test_state = django_filters.CharFilter(method='filter_test_state')
    name = django_filters.CharFilter(lookup_expr='icontains')
    state = django_filters.CharFilter(method='filter_state')
181
182
183

    class Meta:
        model = Series
184
        fields = ['project']
185

186

187
188
189
class SeriesListMixin(ListMixin):
    queryset = Series.objects.all()
    serializer_class = SeriesSerializer
190
191
    select_fields = ('last_revision', )
    select_fields__expand = ('project', 'submitter', 'reviewer')
192
193
    filter_backends = (RequestDjangoFilterBackend, RelatedOrderingFilter)
    filter_class = SeriesFilter
194
    permission_classes = (MaintainerPermission, )
195

196

197
class SelectRelatedMixin(object):
198

199
    def select_related(self, queryset):
200
        select_fields = getattr(self, 'select_fields', ())
201

202
        related = self.request.query_params.get('related')
203
204
205
206
        if related:
            select_fields += getattr(self, 'select_fields__expand', ())

        if not select_fields:
207
208
209
210
            return queryset

        return queryset.select_related(*select_fields)

211

212
213
214
215
216
217
218
def is_integer(s):
    try:
        int(s)
        return True
    except ValueError:
        return False

219

220
221
class ProjectViewSet(mixins.ListModelMixin, ListMixin,
                     viewsets.GenericViewSet):
222
223
224
225
226
227
228
229
230
231
232
233
    permission_classes = (MaintainerPermission, )
    queryset = Project.objects.all()
    serializer_class = ProjectSerializer

    def retrieve(self, request, pk=None):
        if is_integer(pk):
            queryset = get_object_or_404(Project, pk=pk)
        else:
            queryset = get_object_or_404(Project, linkname=pk)
        serializer = ProjectSerializer(queryset)
        return Response(serializer.data)

234

235
236
class SeriesListViewSet(mixins.ListModelMixin,
                        SeriesListMixin,
237
                        SelectRelatedMixin,
238
239
240
241
242
243
244
245
246
                        viewsets.GenericViewSet):

    def get_queryset(self):

        pk = self.kwargs['project_pk']
        if is_integer(pk):
            queryset = self.queryset.filter(project__pk=pk)
        else:
            queryset = self.queryset.filter(project__linkname=pk)
247
        return self.select_related(queryset)
248

249

250
251
class SeriesViewSet(mixins.ListModelMixin,
                    mixins.RetrieveModelMixin,
252
                    mixins.UpdateModelMixin,
253
254
255
                    SeriesListMixin,
                    viewsets.GenericViewSet):

256
257
    def perform_update(self, serializer):
        series = self.get_object()
258
259
        self._old_reviewer = series.reviewer

260
261
        series = serializer.save()

262
263
264
265
266
267
268
269
        if self._old_reviewer != series.reviewer:
            old = self._old_reviewer.pk if self._old_reviewer else None
            new = series.reviewer.pk if series.reviewer else None
            url = self.request.build_absolute_uri(series.get_absolute_url())
            send_reviewer_notification.delay(series.pk, url,
                                             self.request.user.pk,
                                             old, new)

270

Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
271
def series_mbox(request, revision, with_cover=False, cover_only=False):
272
273
274
275
    options = {
        'patch-link': request.GET.get('link', None),
        'request': request,
    }
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291

    mails = []

    if cover_only and not revision.cover_letter:
        return HttpResponseNotFound('no cover letter for this series/revision')

    if cover_only or with_cover:
        cover_mail = revision_cover_letter_to_mbox(revision).as_string(True)

    if (with_cover and revision.cover_letter) or cover_only:
        mails = [cover_mail]

    if not cover_only:
        patches = revision.ordered_patches()
        mails += [patch_to_mbox(x, options).as_string(True) for x in patches]

292
    data = '\n'.join(mails)
293
294
295
296
    response = HttpResponse(content_type="text/plain")
    response.write(data)
    response['Content-Disposition'] = 'attachment; filename=' + \
        revision.series.filename()
Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
297

298
299
    return response

300

301
302
class RevisionViewSet(mixins.ListModelMixin, ListMixin,
                      viewsets.GenericViewSet):
303
304
305
306
307
308
309
310
311
312
313
314
315
316
    permission_classes = (MaintainerPermission, )
    queryset = SeriesRevision.objects.all()
    serializer_class = RevisionSerializer

    def get_queryset(self):
        series_pk = self.kwargs['series_pk']
        return self.queryset.filter(series=series_pk)

    def retrieve(self, request, series_pk=None, pk=None):
        rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk)
        serializer = RevisionSerializer(rev,
                                        context=self.get_serializer_context())
        return Response(serializer.data)

317
318
319
    @detail_route(methods=['get'])
    def mbox(self, request, series_pk=None, pk=None):
        rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk)
320
        return series_mbox(request, rev)
321

322
323
324
325
326
    @detail_route(methods=['get'])
    def mbox_with_cover(self, request, series_pk=None, pk=None):
        rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk)
        return series_mbox(request, rev, with_cover=True)

Arkadiusz Hiler's avatar
Arkadiusz Hiler committed
327
328
329
330
331
    @detail_route(methods=['get'])
    def cover(self, request, series_pk=None, pk=None):
        rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk)
        return series_mbox(request, rev, cover_only=True)

332
333
334
335
    @detail_route(methods=['post'])
    def newrevision(self, request, series_pk=None, pk=None):
        rev = get_object_or_404(SeriesRevision, series=series_pk, version=pk)

336
337
338
339
340
341
        # we are not using the permissions from django-rest-framework
        #
        # they would make us to define a class, call check_object_permissions
        # here and introduce a level of indirectness just for this single use
        if not Can(request.user).retest(rev.series):
            raise PermissionDenied
342
343

        # log event
344
345
346
        new_rev = rev.duplicate()
        new_rev.is_rerun = True
        new_rev.save()
347
348
        return HttpResponse()

349

350
class ResultMixin(object):
351

352
353
354
355
356
357
358
    def _object_type(self, obj):
        if isinstance(obj, SeriesRevision):
            return "Series"
        else:
            return "Patch"

    def _prepare_mail(self, request, result, obj, check_obj):
359
        if result.state == TestState.STATE_SUCCESS:
360
            tick = u"✓"
361
362
        elif result.state == TestState.STATE_INFO:
            tick = u"○"
363
364
        else:
            tick = u"✗"
365
366
        subject = tick + u" %s: %s for %s" % (result.test.name,
                                              result.get_state_display(),
367
                                              obj.human_name())
368
        body = ''
369
        body += '== %s Details ==\n\n' % self._object_type(obj)
370
        body += 'Series: ' + obj.human_name() + '\n'
371
372
373
374
        body += 'URL   : ' + \
                request.build_absolute_uri(check_obj.get_absolute_url()) + '\n'
        body += 'State : ' + result.get_state_display() + '\n'
        body += '\n'
375
376
377
378
379
380
381
382
383
384
385
386
387
388
        if result.summary:
            body += "== Summary ==\n\n"
            body += result.summary
            if body.endswith('\n'):
                body += "\n"
            else:
                body += "\n\n"
        if result.url:
            body += "== Logs ==\n\n"
            body += "For more details see: " + result.url
            body += "\n"

        return (subject, body)

389
390
391
392
393
394
    def _get_msgid(self, obj):
        try:
            return getattr(obj, 'root_msgid')
        except AttributeError:
            return getattr(obj, 'msgid')

395
    def handle_test_results(self, request, obj, check_obj, q, ctx):
396
        # auth
397
        if 'test_name' not in request.data:
398
399
400
401
402
            return Response({'test_name': ['This field is required.', ]},
                            status=status.HTTP_400_BAD_REQUEST)

        self.check_object_permissions(request, check_obj)

403
        # update test result and prepare the JSON response
404
        try:
405
            test = request.data['test_name']
406
407
408
409
410
411
412
413
            instance = TestResult.objects.get(q, test__name=test)
        except TestResult.DoesNotExist:
            instance = None

        ctx.update({
            'project': check_obj.project,
            'user': request.user,
        })
414
        result = TestResultSerializer(instance, data=request.data, context=ctx)
415
416
417
        if not result.is_valid():
            return Response(result.errors, status=status.HTTP_400_BAD_REQUEST)

418
419
420
421
422
423
424
425
426
427
        instance = result.save()

        # mailing, done synchronously with the request, for now
        to = []
        cc = []
        if instance.test.mail_recipient == Test.RECIPIENT_SUBMITTER:
            to.append(check_obj.submitter.email_name())
        elif instance.test.mail_recipient == Test.RECIPIENT_MAILING_LIST:
            to.append(check_obj.submitter.email_name())
            cc.append(check_obj.project.listemail)
428
429
        elif instance.test.mail_recipient == Test.RECIPIENT_MAILING_LIST_ONLY:
            to.append(check_obj.project.listemail)
430

431
432
433
434
435
        if instance.test.mail_recipient != Test.RECIPIENT_NONE and \
           (instance.test.mail_to_list or instance.test.mail_cc_list):
            to += instance.test.get_to_list()
            cc += instance.test.get_cc_list()

436
437
        if to:
            # never send mail on pending
438
            if instance.state == TestState.STATE_PENDING:
439
440
                to = []

441
442
443
444
445
446
447
448
            elif (instance.test.mail_condition ==
                  Test.CONDITION_ON_WARNING and
                  instance.state not in (TestState.STATE_WARNING,
                                         TestState.STATE_FAILURE)):
                to = []

            elif (instance.test.mail_condition == Test.CONDITION_ON_FAILURE and
                  instance.state != TestState.STATE_FAILURE):
449
450
451
                to = []

        if to:
452
453
            subject, body = self._prepare_mail(request, instance,
                                               obj, check_obj)
454
            msgid = self._get_msgid(obj)
455
456
            headers = {
                'X-Patchwork-Hint': 'ignore',
457
458
                'References': msgid,
                'In-Reply-To': msgid,
459
                'Reply-To': check_obj.project.listemail,
460
            }
461
462
            email = mail.EmailMessage(subject, body,
                                      settings.DEFAULT_FROM_EMAIL,
463
                                      to=to, cc=cc, headers=headers)
464
465
            email.send()

466
467
        return Response(result.data, status=status.HTTP_201_CREATED)

468

469
470
471
472
473
474
475
class RevisionResultViewSet(viewsets.ViewSet, ResultMixin):
    permission_classes = (MaintainerPermission, )
    authentication_classes = (BasicAuthentication, )

    def create(self, request, series_pk, version_pk):
        rev = get_object_or_404(SeriesRevision, series=series_pk,
                                version=version_pk)
476
477
478
479
480
481
482
        response = self.handle_test_results(request, rev, rev.series,
                                            Q(revision=rev), {'revision': rev})

        if response.status_code == status.HTTP_201_CREATED:
            rev.refresh_test_state()

        return response
483

484
485
486
487
488
489
490
491
492
493
494
495
    def list(self, request, series_pk, version_pk):
        rev = get_object_or_404(SeriesRevision, series=series_pk,
                                version=version_pk)

        test_results = TestResult.objects \
            .filter(revision=rev, patch=None) \
            .order_by('test__name').select_related('test')

        serializer = TestResultSerializer(test_results, many=True)

        return Response(serializer.data)

496

497
498
class PatchFilter(django_filters.FilterSet):

499
    def filter_submitter(self, queryset, name, submitter):
500
501
502
503
        try:
            submitter = int(submitter)
            queryset = queryset.filter(submitter=submitter)
        except ValueError:
504
            if submitter == 'self' and self.request.user.is_authenticated:
505
506
507
508
                people = Person.objects.filter(user=self.request.user)
                queryset = queryset.filter(submitter__in=people)
        return queryset

509
    def filter_state(self, queryset, name, state_names):
510
511
512
513
514
515
516
517
518
        if not state_names:
            return queryset

        try:
            states = map(State.from_string, state_names.split(','))
            return queryset.filter(state__in=states)
        except State.DoesNotExist:
            return queryset

519
    submitted_since = django_filters.CharFilter(field_name='date',
520
                                                lookup_expr='gt')
521
    updated_since = django_filters.CharFilter(field_name='last_updated',
522
                                              lookup_expr='gt')
523
    submitted_before = django_filters.CharFilter(field_name='date',
524
                                                 lookup_expr='lte')
525
    updated_before = django_filters.CharFilter(field_name='last_updated',
526
527
528
529
                                              lookup_expr='lte')
    submitter = django_filters.CharFilter(method='filter_submitter')
    name = django_filters.CharFilter(lookup_expr='icontains')
    state = django_filters.CharFilter(method='filter_state')
530
531
532
533
534
535

    class Meta:
        model = Patch
        fields = ['project']


Damien Lespiau's avatar
Damien Lespiau committed
536
537
538
class PatchListMixin(ListMixin):
    queryset = Patch.objects.all()
    serializer_class = PatchSerializer
539
    select_fields__expand = ('project', 'submitter', 'state', 'pull_url')
Damien Lespiau's avatar
Damien Lespiau committed
540
    filter_backends = (RequestDjangoFilterBackend, RelatedOrderingFilter)
541
    filter_class = PatchFilter
542
    ordering_fields = '__all__'
Damien Lespiau's avatar
Damien Lespiau committed
543
544
545
    permission_classes = (MaintainerPermission, )


546
547
548
549
550
551
552
553
554
555
556
557
558
class PatchListViewSet(mixins.ListModelMixin,
                       PatchListMixin,
                       SelectRelatedMixin,
                       viewsets.GenericViewSet):

    def get_queryset(self):

        pk = self.kwargs['project_pk']
        if is_integer(pk):
            queryset = self.queryset.filter(project__pk=pk)
        else:
            queryset = self.queryset.filter(project__linkname=pk)
        return self.select_related(queryset)
Damien Lespiau's avatar
Damien Lespiau committed
559
560


561
562
class PatchViewSet(mixins.ListModelMixin,
                   mixins.RetrieveModelMixin,
Damien Lespiau's avatar
Damien Lespiau committed
563
                   PatchListMixin, ResultMixin,
564
                   viewsets.GenericViewSet):
Damien Lespiau's avatar
Damien Lespiau committed
565

566
567
568
569
    @detail_route(methods=['get'])
    def mbox(self, request, pk=None):
        return patch_mbox(request, pk)

570

571
572
573
class MsgidResultsView(views.APIView):
    def get(self, request, msgid):
        output = []
574

575
576
        if not (msgid.startswith('<') and msgid.endswith('>')):
            msgid = '<{}>'.format(msgid)
577

578
579
580
581
        patches = Patch.objects.filter(msgid=msgid)
        for patch in patches:
            series = patch.series()
            revisions = series.revisions().filter(patches=patch)
582

583
584
585
586
            desc = {'patch_id': patch.id,
                    'project_id': patch.project_id,
                    'series_id': series.id,
                    'revision_ids': [rev.version for rev in revisions]}
587

588
            output += [desc]
589

590
591
592
593
594
595
596
597
        revisions = SeriesRevision.objects.filter(root_msgid=msgid,
                                                  cover_letter__isnull=False)
        for (series_id, rev_grp) in group(revisions, lambda x: x.series_id):
            rev_grp = list(rev_grp)
            desc = {'project_id': rev_grp[0].series.project_id,
                    'series_id': series_id,
                    'revision_ids': [rev.version for rev in rev_grp]}
            output += [desc]
598

599
600
601
602
        # let's mimic rest-framework's wrapping for consistency
        return Response({'count': len(output),
                         'next': None, 'previous': None,
                         'results': output})
603
604


605
606
607
608
609
610
611
612
613
class PatchResultViewSet(viewsets.ViewSet, ResultMixin):
    permission_classes = (MaintainerPermission, )
    authentication_classes = (BasicAuthentication, )

    def create(self, request, patch_pk=None):
        patch = get_object_or_404(Patch, pk=patch_pk)
        return self.handle_test_results(request, patch, patch, Q(patch=patch),
                                        {'patch': patch})

614

615
class EventFilter(django_filters.FilterSet):
616

617
    def filter_name(self, queryset, name, event_names):
618
619
620
621
622
623
        if not event_names:
            return queryset

        names = event_names.split(',')
        return queryset.filter(event__name__in=names)

624
    since = django_filters.CharFilter(field_name='event_time', lookup_expr='gt')
625
    name = django_filters.CharFilter(method='filter_name')
626
627
    series = django_filters.NumberFilter()
    patch = django_filters.NumberFilter()
628
629
630

    class Meta:
        model = EventLog
631
        fields = []
632

633

Damien Lespiau's avatar
Damien Lespiau committed
634
635
636
637
class EventLogViewSet(mixins.ListModelMixin,
                      ListMixin,
                      viewsets.GenericViewSet):
    permission_classes = (MaintainerPermission, )
638
    queryset = EventLog.objects.all().select_related('event')
Damien Lespiau's avatar
Damien Lespiau committed
639
    serializer_class = EventLogSerializer
640
    filter_backends = (filters.DjangoFilterBackend, filters.OrderingFilter)
641
    filter_class = EventFilter
Damien Lespiau's avatar
Damien Lespiau committed
642
643

    def get_queryset(self):
644
        qs = self.queryset
Damien Lespiau's avatar
Damien Lespiau committed
645
646
        pk = self.kwargs['project_pk']
        if is_integer(pk):
647
648
649
            qs = qs.filter(Q(patch__project__pk=pk) |
                           Q(series__project__pk=pk)).distinct()

Damien Lespiau's avatar
Damien Lespiau committed
650
        else:
651
652
653
            qs = qs.filter(Q(patch__project__linkname=pk) |
                           Q(series__project__linkname=pk)).distinct()
        return qs