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
46
from django_filters.rest_framework import DjangoFilterBackend

47
import django_filters
48
49


50

51
API_REVISION = 4
52

53

54
55
56
57
58
class RelatedOrderingFilter(filters.OrderingFilter):
    """
    Extends OrderingFilter to support ordering by fields in related models.
    """

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

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

            # foreign key
72
73
            if field.remote_field and len(components) == 2:
                return self.is_valid_field(field.remote_field.model, components[1])
74
75
76
77
            return True
        except FieldDoesNotExist:
            return False

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

82

83
class MaintainerPermission(permissions.BasePermission):
84

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

90
        return Can(request.user).edit(obj.project)
91

92

93
class RequestDjangoFilterBackend(DjangoFilterBackend):
94
95
96
97
98
99
    """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:
100
            filter = filter_class(request.query_params, queryset=queryset)
101
102
103
104
105
106
            filter.request = request
            return filter.qs

        return queryset


107
108
109
110
class API(views.APIView):
    permission_classes = (permissions.AllowAny,)

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

113

114
115
class ListPagination(PageNumberPagination):
    page_size = 20
116
    page_size_query_param = 'perpage'
117
118
119
120
    max_page_size = 100


class ListMixin(object):
121
    filter_backends = (RelatedOrderingFilter, )
122
    pagination_class = ListPagination
123

124

125
class SeriesFilter(django_filters.FilterSet):
126

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

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

146
    def filter_test_state(self, queryset, name, test_states):
147
148
149
        if not test_states:
            return queryset

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

159
160
        return queryset

161
    def filter_state(self, queryset, name, state_names):
162
163
164
165
166
167
168
169
170
        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

171
    submitted_since = django_filters.CharFilter(field_name='submitted',
172
                                                lookup_expr='gt')
173
    updated_since = django_filters.CharFilter(field_name='last_updated',
174
                                              lookup_expr='gt')
175
    submitted_before = django_filters.CharFilter(field_name='submitted',
176
                                                 lookup_expr='lte')
177
178
    updated_before = django_filters.CharFilter(field_name='last_updated',
                                               lookup_expr='lte')
179
180
181
182
183
    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')
184
185
186

    class Meta:
        model = Series
187
        fields = ['project']
188

189

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

199

200
class SelectRelatedMixin(object):
201

202
    def select_related(self, queryset):
203
        select_fields = getattr(self, 'select_fields', ())
204

205
        related = self.request.query_params.get('related')
206
207
208
209
        if related:
            select_fields += getattr(self, 'select_fields__expand', ())

        if not select_fields:
210
211
212
213
            return queryset

        return queryset.select_related(*select_fields)

214

215
216
217
218
219
220
221
def is_integer(s):
    try:
        int(s)
        return True
    except ValueError:
        return False

222

223
224
class ProjectViewSet(mixins.ListModelMixin, ListMixin,
                     viewsets.GenericViewSet):
225
226
227
228
229
230
231
232
233
234
235
236
    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)

237

238
239
class SeriesListViewSet(mixins.ListModelMixin,
                        SeriesListMixin,
240
                        SelectRelatedMixin,
241
242
243
244
245
246
247
248
249
                        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)
250
        return self.select_related(queryset)
251

252

253
254
class SeriesViewSet(mixins.ListModelMixin,
                    mixins.RetrieveModelMixin,
255
                    mixins.UpdateModelMixin,
256
257
258
                    SeriesListMixin,
                    viewsets.GenericViewSet):

259
260
    def perform_update(self, serializer):
        series = self.get_object()
261
262
        self._old_reviewer = series.reviewer

263
264
        series = serializer.save()

265
266
267
268
269
270
271
272
        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)

273

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

    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]

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

301
302
    return response

303

304
305
class RevisionViewSet(mixins.ListModelMixin, ListMixin,
                      viewsets.GenericViewSet):
306
307
308
309
310
311
312
313
314
315
316
317
318
319
    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)

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

325
326
327
328
329
    @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
330
331
332
333
334
    @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)

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

339
340
341
342
343
344
        # 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
345
346

        # log event
347
348
349
        new_rev = rev.duplicate()
        new_rev.is_rerun = True
        new_rev.save()
350
351
        return HttpResponse()

352

353
class ResultMixin(object):
354

355
356
357
358
359
360
361
    def _object_type(self, obj):
        if isinstance(obj, SeriesRevision):
            return "Series"
        else:
            return "Patch"

    def _prepare_mail(self, request, result, obj, check_obj):
362
        if result.state == TestState.STATE_SUCCESS:
363
            tick = u"✓"
364
365
        elif result.state == TestState.STATE_INFO:
            tick = u"○"
366
367
        else:
            tick = u"✗"
368
369
        subject = tick + u" %s: %s for %s" % (result.test.name,
                                              result.get_state_display(),
370
                                              obj.human_name())
371
        body = ''
372
        body += '== %s Details ==\n\n' % self._object_type(obj)
373
        body += 'Series: ' + obj.human_name() + '\n'
374
375
376
377
        body += 'URL   : ' + \
                request.build_absolute_uri(check_obj.get_absolute_url()) + '\n'
        body += 'State : ' + result.get_state_display() + '\n'
        body += '\n'
378
379
380
381
382
383
384
385
386
387
388
389
390
391
        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)

392
393
394
395
396
397
    def _get_msgid(self, obj):
        try:
            return getattr(obj, 'root_msgid')
        except AttributeError:
            return getattr(obj, 'msgid')

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

        self.check_object_permissions(request, check_obj)

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

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

421
422
423
424
425
426
427
428
429
430
        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)
431
432
        elif instance.test.mail_recipient == Test.RECIPIENT_MAILING_LIST_ONLY:
            to.append(check_obj.project.listemail)
433

434
435
436
437
438
        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()

439
440
        if to:
            # never send mail on pending
441
            if instance.state == TestState.STATE_PENDING:
442
443
                to = []

444
445
446
447
448
449
450
451
            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):
452
453
454
                to = []

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

469
470
        return Response(result.data, status=status.HTTP_201_CREATED)

471

472
473
474
475
476
477
478
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)
479
480
481
482
483
484
485
        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
486

487
488
489
490
491
492
493
494
495
496
497
498
    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)

499

500
501
class PatchFilter(django_filters.FilterSet):

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

512
    def filter_state(self, queryset, name, state_names):
513
514
515
516
517
518
519
520
521
        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

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

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


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


549
550
551
552
553
554
555
556
557
558
559
560
561
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
562
563


564
565
class PatchViewSet(mixins.ListModelMixin,
                   mixins.RetrieveModelMixin,
Damien Lespiau's avatar
Damien Lespiau committed
566
                   PatchListMixin, ResultMixin,
567
                   viewsets.GenericViewSet):
Damien Lespiau's avatar
Damien Lespiau committed
568

569
570
571
572
    @detail_route(methods=['get'])
    def mbox(self, request, pk=None):
        return patch_mbox(request, pk)

573

574
575
576
class MsgidResultsView(views.APIView):
    def get(self, request, msgid):
        output = []
577

578
579
        if not (msgid.startswith('<') and msgid.endswith('>')):
            msgid = '<{}>'.format(msgid)
580

581
582
583
584
        patches = Patch.objects.filter(msgid=msgid)
        for patch in patches:
            series = patch.series()
            revisions = series.revisions().filter(patches=patch)
585

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

591
            output += [desc]
592

593
594
595
596
597
598
599
600
        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]
601

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


608
609
610
611
612
613
614
615
616
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})

617

618
class EventFilter(django_filters.FilterSet):
619

620
    def filter_name(self, queryset, name, event_names):
621
622
623
624
625
626
        if not event_names:
            return queryset

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

627
    since = django_filters.CharFilter(field_name='event_time', lookup_expr='gt')
628
    name = django_filters.CharFilter(method='filter_name')
629
630
    series = django_filters.NumberFilter()
    patch = django_filters.NumberFilter()
631
632
633

    class Meta:
        model = EventLog
634
        fields = []
635

636

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

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

Damien Lespiau's avatar
Damien Lespiau committed
653
        else:
654
655
656
            qs = qs.filter(Q(patch__project__linkname=pk) |
                           Q(series__project__linkname=pk)).distinct()
        return qs