[PATCH v4 6/9] api: add comments detail endpoint

Daniel Axtens dja at axtens.net
Mon Aug 23 22:14:03 AEST 2021


Raxel Gutierrez <raxel at google.com> writes:

I've merged this with some small changes as detailed below.

> Add new endpoint for patch and cover comments at api/.../comments/<comment_id>.
> This involves updating the API version to v1.3 to reflect the new
> endpoints as a minor change, following the usual semantic versioning
> convention.
>
> The endpoint will make it possible to use the REST API to update the new
> `addressed` field for individual patch and cover comments with JavaScript
> on the client side. In the process of these changes, clean up the use of
> the CurrentPatchDefault context so that it exists in base.py and can be
> used throughout the API (e.g. Check and Comment REST endpoints).
>
> The tests cover retrieval and update requests and also handle calls from
> the various API versions. Also, they cover permissions for update
> requests and handle invalid update values for the new `addressed` field.
>
> Signed-off-by: Raxel Gutierrez <raxel at google.com>
> ---
>  docs/api/schemas/generate-schemas.py |   4 +-
>  docs/api/schemas/patchwork.j2        | 165 ++++++++++++
>  patchwork/api/base.py                |  24 +-
>  patchwork/api/check.py               |  20 +-
>  patchwork/api/comment.py             | 146 ++++++++--
>  patchwork/tests/api/test_comment.py  | 388 ++++++++++++++++++++++++---
>  patchwork/urls.py                    |  20 +-
>  7 files changed, 682 insertions(+), 85 deletions(-)
>
> diff --git a/docs/api/schemas/generate-schemas.py b/docs/api/schemas/generate-schemas.py
> index a0c1e45f..3a436a16 100755
> --- a/docs/api/schemas/generate-schemas.py
> +++ b/docs/api/schemas/generate-schemas.py
> @@ -14,8 +14,8 @@ except ImportError:
>      yaml = None
>  
>  ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
> -VERSIONS = [(1, 0), (1, 1), (1, 2), None]
> -LATEST_VERSION = (1, 2)
> +VERSIONS = [(1, 0), (1, 1), (1, 2), (1, 3), None]
> +LATEST_VERSION = (1, 3)
>  
>  
>  def generate_schemas():
> diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
> index af20743d..3b4ad2f6 100644
> --- a/docs/api/schemas/patchwork.j2
> +++ b/docs/api/schemas/patchwork.j2
> @@ -324,6 +324,74 @@ paths:
>                  $ref: '#/components/schemas/Error'
>        tags:
>          - comments
> +{% if version >= (1, 3) %}
> +  /api/{{ version_url }}covers/{cover_id}/comments/{comment_id}/:
> +    parameters:
> +      - in: path
> +        name: cover_id
> +        description: A unique integer value identifying the parent cover.
> +        required: true
> +        schema:
> +          title: Cover ID
> +          type: integer
> +      - in: path
> +        name: comment_id
> +        description: A unique integer value identifying this comment.
> +        required: true
> +        schema:
> +          title: Comment ID
> +          type: integer
> +    get:
> +      description: Show a cover comment.
> +      operationId: cover_comments_read
> +      responses:
> +        '200':
> +          description: ''
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Comment'
> +        '404':
> +          description: Not found
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +      tags:
> +        - comments
> +    patch:
> +      description: Update a cover comment (partial).
> +      operationId: cover_comments_partial_update
> +      requestBody:
> +        $ref: '#/components/requestBodies/Comment'
> +      responses:
> +        '200':
> +          description: ''
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Comment'
> +        '400':
> +          description: Invalid Request
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/ErrorCommentUpdate'
> +        '403':
> +          description: Forbidden
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +        '404':
> +          description: Not found
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +      tags:
> +        - comments
> +{% endif %}
>    /api/{{ version_url }}events/:
>      get:
>        description: List events.
> @@ -656,6 +724,74 @@ paths:
>                  $ref: '#/components/schemas/Error'
>        tags:
>          - comments
> +{% if version >= (1, 3) %}
> +  /api/{{ version_url }}patches/{patch_id}/comments/{comment_id}/:
> +    parameters:
> +      - in: path
> +        name: patch_id
> +        description: A unique integer value identifying the parent patch.
> +        required: true
> +        schema:
> +          title: Patch ID
> +          type: integer
> +      - in: path
> +        name: comment_id
> +        description: A unique integer value identifying this comment.
> +        required: true
> +        schema:
> +          title: Comment ID
> +          type: integer
> +    get:
> +      description: Show a patch comment.
> +      operationId: patch_comments_read
> +      responses:
> +        '200':
> +          description: ''
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Comment'
> +        '404':
> +          description: Not found
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +      tags:
> +        - comments
> +    patch:
> +      description: Update a patch comment (partial).
> +      operationId: patch_comments_partial_update
> +      requestBody:
> +        $ref: '#/components/requestBodies/Comment'
> +      responses:
> +        '200':
> +          description: ''
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Comment'
> +        '400':
> +          description: Invalid Request
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/ErrorCommentUpdate'
> +        '403':
> +          description: Forbidden
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +        '404':
> +          description: Not found
> +          content:
> +            application/json:
> +              schema:
> +                $ref: '#/components/schemas/Error'
> +      tags:
> +        - comments
> +{% endif %}
>    /api/{{ version_url }}patches/{patch_id}/checks/:
>      parameters:
>        - in: path
> @@ -1277,6 +1413,14 @@ components:
>          application/x-www-form-urlencoded:
>            schema:
>              $ref: '#/components/schemas/CheckCreate'
> +{% if version >= (1, 3) %}
> +    Comment:
> +      required: true
> +      content:
> +        application/json:
> +          schema:
> +            $ref: '#/components/schemas/CommentUpdate'
> +{% endif %}
>      Patch:
>        required: true
>        content:
> @@ -1586,6 +1730,17 @@ components:
>                additionalProperties:
>                  type: string
>            readOnly: true
> +{% if version >= (1, 3) %}
> +        addressed:
> +          title: Addressed
> +          type: boolean
> +    CommentUpdate:
> +      type: object
> +      properties:
> +        addressed:
> +          title: Addressed
> +          type: boolean
> +{% endif %}
>      CoverList:
>        type: object
>        properties:
> @@ -2659,6 +2814,16 @@ components:
>            items:
>              type: string
>            readOnly: true
> +{% if version >= (1, 3) %}
> +    ErrorCommentUpdate:
> +      type: object
> +      properties:
> +        addressed:
> +          title: Addressed
> +          type: array
> +          items:
> +            type: string
> +{% endif %}
>      ErrorPatchUpdate:
>        type: object
>        properties:
> diff --git a/patchwork/api/base.py b/patchwork/api/base.py
> index 89a43114..a98644ee 100644
> --- a/patchwork/api/base.py
> +++ b/patchwork/api/base.py
> @@ -3,6 +3,7 @@
>  #
>  # SPDX-License-Identifier: GPL-2.0-or-later
>  
> +import rest_framework
>  
>  from django.conf import settings
>  from django.shortcuts import get_object_or_404
> @@ -15,6 +16,24 @@ from rest_framework.serializers import HyperlinkedModelSerializer
>  from patchwork.api import utils
>  
>  
> +DRF_VERSION = tuple(int(x) for x in rest_framework.__version__.split('.'))
> +
> +
> +if DRF_VERSION > (3, 11):
> +    class CurrentPatchDefault(object):
> +        requires_context = True
> +
> +        def __call__(self, serializer_field):
> +            return serializer_field.context['request'].patch
> +else:
> +    class CurrentPatchDefault(object):
> +        def set_context(self, serializer_field):
> +            self.patch = serializer_field.context['request'].patch
> +
> +        def __call__(self):
> +            return self.patch
> +
> +
>  class LinkHeaderPagination(PageNumberPagination):
>      """Provide pagination based on rfc5988.
>  
> @@ -44,7 +63,10 @@ class LinkHeaderPagination(PageNumberPagination):
>  
>  
>  class PatchworkPermission(permissions.BasePermission):
> -    """This permission works for Project and Patch model objects"""
> +    """
> +    This permission works for Project, Patch, PatchComment
> +    and CoverComment model objects
> +    """
>      def has_object_permission(self, request, view, obj):
>          # read only for everyone
>          if request.method in permissions.SAFE_METHODS:
> diff --git a/patchwork/api/check.py b/patchwork/api/check.py
> index a6bf5f8c..2049d2f9 100644
> --- a/patchwork/api/check.py
> +++ b/patchwork/api/check.py
> @@ -6,7 +6,6 @@
>  from django.http import Http404
>  from django.http.request import QueryDict
>  from django.shortcuts import get_object_or_404
> -import rest_framework
>  from rest_framework.exceptions import PermissionDenied
>  from rest_framework.generics import ListCreateAPIView
>  from rest_framework.generics import RetrieveAPIView
> @@ -17,30 +16,13 @@ from rest_framework.serializers import ValidationError
>  
>  from patchwork.api.base import CheckHyperlinkedIdentityField
>  from patchwork.api.base import MultipleFieldLookupMixin
> +from patchwork.api.base import CurrentPatchDefault
>  from patchwork.api.embedded import UserSerializer
>  from patchwork.api.filters import CheckFilterSet
>  from patchwork.models import Check
>  from patchwork.models import Patch
>  
>  
> -DRF_VERSION = tuple(int(x) for x in rest_framework.__version__.split('.'))
> -
> -
> -if DRF_VERSION > (3, 11):
> -    class CurrentPatchDefault(object):
> -        requires_context = True
> -
> -        def __call__(self, serializer_field):
> -            return serializer_field.context['request'].patch
> -else:
> -    class CurrentPatchDefault(object):
> -        def set_context(self, serializer_field):
> -            self.patch = serializer_field.context['request'].patch
> -
> -        def __call__(self):
> -            return self.patch
> -
> -
>  class CheckSerializer(HyperlinkedModelSerializer):
>  
>      url = CheckHyperlinkedIdentityField('api-check-detail')
> diff --git a/patchwork/api/comment.py b/patchwork/api/comment.py
> index 5d7a77a1..334016d7 100644
> --- a/patchwork/api/comment.py
> +++ b/patchwork/api/comment.py
> @@ -4,13 +4,19 @@
>  # SPDX-License-Identifier: GPL-2.0-or-later
>  
>  import email.parser
> +import rest_framework
>  
> +from django.shortcuts import get_object_or_404

I replaced this with the rest_framework.generics one.

>  from django.http import Http404
>  from rest_framework.generics import ListAPIView
> +from rest_framework.generics import RetrieveUpdateAPIView
> +from rest_framework.serializers import HiddenField
>  from rest_framework.serializers import SerializerMethodField
>  
>  from patchwork.api.base import BaseHyperlinkedModelSerializer
> +from patchwork.api.base import MultipleFieldLookupMixin
>  from patchwork.api.base import PatchworkPermission
> +from patchwork.api.base import CurrentPatchDefault
>  from patchwork.api.embedded import PersonSerializer
>  from patchwork.models import Cover
>  from patchwork.models import CoverComment
> @@ -18,6 +24,24 @@ from patchwork.models import Patch
>  from patchwork.models import PatchComment
>  
>  
> +DRF_VERSION = tuple(int(x) for x in rest_framework.__version__.split('.'))
> +
> +
> +if DRF_VERSION > (3, 11):
> +    class CurrentCoverDefault(object):
> +        requires_context = True
> +
> +        def __call__(self, serializer_field):
> +            return serializer_field.context['request'].cover
> +else:
> +    class CurrentCoverDefault(object):
> +        def set_context(self, serializer_field):
> +            self.patch = serializer_field.context['request'].cover
> +
> +        def __call__(self):
> +            return self.cover
> +
> +

I moved these into base.py.

>  class BaseCommentListSerializer(BaseHyperlinkedModelSerializer):
>  
>      web_url = SerializerMethodField()
> @@ -49,65 +73,133 @@ class BaseCommentListSerializer(BaseHyperlinkedModelSerializer):
>  
>      class Meta:
>          fields = ('id', 'web_url', 'msgid', 'list_archive_url', 'date',
> -                  'subject', 'submitter', 'content', 'headers')
> -        read_only_fields = fields
> +                  'subject', 'submitter', 'content', 'headers', 'addressed')
> +        read_only_fields = ('id', 'web_url', 'msgid', 'list_archive_url',
> +                            'date', 'subject', 'submitter', 'content',
> +                            'headers')
>          versioned_fields = {
>              '1.1': ('web_url', ),
>              '1.2': ('list_archive_url',),
> +            '1.3': ('addressed',),
>          }
>  
>  
> -class CoverCommentListSerializer(BaseCommentListSerializer):
> +class CoverCommentSerializer(BaseCommentListSerializer):
> +
> +    cover = HiddenField(default=CurrentCoverDefault())
>  
>      class Meta:
>          model = CoverComment
> -        fields = BaseCommentListSerializer.Meta.fields
> -        read_only_fields = fields
> +        fields = BaseCommentListSerializer.Meta.fields + (
> +            'cover', 'addressed')
> +        read_only_fields = BaseCommentListSerializer.Meta.read_only_fields + (
> +            'cover', )
>          versioned_fields = BaseCommentListSerializer.Meta.versioned_fields
> +        extra_kwargs = {
> +            'url': {'view_name': 'api-cover-comment-detail'}
> +        }
> +
>  
> +class CoverCommentMixin(object):
> +
> +    permission_classes = (PatchworkPermission,)
> +    serializer_class = CoverCommentSerializer
>  
> -class PatchCommentListSerializer(BaseCommentListSerializer):
> +    def get_object(self):
> +        queryset = self.filter_queryset(self.get_queryset())
> +        comment_id = self.kwargs['comment_id']
> +        obj = get_object_or_404(queryset, id=int(comment_id))

I got rid of both int() calls; they should no longer be necessary and
also not-fully-guarded calls to int() caused us 500 errors recently.

> +        self.check_object_permissions(self.request, obj)
> +        return obj
> +
> +    def get_queryset(self):
> +        cover_id = self.kwargs['cover_id']
> +        if not Cover.objects.filter(id=cover_id).exists():
> +            raise Http404
> +
> +        return CoverComment.objects.filter(
> +            cover=cover_id
> +        ).select_related('submitter')
> +
> +
> +class PatchCommentSerializer(BaseCommentListSerializer):
> +
> +    patch = HiddenField(default=CurrentPatchDefault())
>  
>      class Meta:
>          model = PatchComment
> -        fields = BaseCommentListSerializer.Meta.fields
> -        read_only_fields = fields
> +        fields = BaseCommentListSerializer.Meta.fields + ('patch', )
> +        read_only_fields = BaseCommentListSerializer.Meta.read_only_fields + (
> +            'patch', )
>          versioned_fields = BaseCommentListSerializer.Meta.versioned_fields
> +        extra_kwargs = {
> +            'url': {'view_name': 'api-patch-comment-detail'}
> +        }
>  
>  
> -class CoverCommentList(ListAPIView):
> -    """List cover comments"""
> +class PatchCommentMixin(object):
>  
>      permission_classes = (PatchworkPermission,)
> -    serializer_class = CoverCommentListSerializer
> +    serializer_class = PatchCommentSerializer
> +
> +    def get_object(self):
> +        queryset = self.filter_queryset(self.get_queryset())
> +        comment_id = self.kwargs['comment_id']
> +        obj = get_object_or_404(queryset, id=int(comment_id))
> +        self.check_object_permissions(self.request, obj)
> +        return obj
> +
> +    def get_queryset(self):
> +        patch_id = self.kwargs['patch_id']
> +        if not Patch.objects.filter(id=patch_id).exists():
> +            raise Http404

I replaced these (raise Http404) with get_object_or_404 calls.

> +
> +        return PatchComment.objects.filter(
> +            patch=patch_id
> +        ).select_related('submitter')
> +
> +
> +class CoverCommentList(CoverCommentMixin, ListAPIView):
> +    """List cover comments"""
> +
>      search_fields = ('subject',)
>      ordering_fields = ('id', 'subject', 'date', 'submitter')
>      ordering = 'id'
>      lookup_url_kwarg = 'cover_id'
>  
> -    def get_queryset(self):
> -        if not Cover.objects.filter(id=self.kwargs['cover_id']).exists():
> -            raise Http404
>  
> -        return CoverComment.objects.filter(
> -            cover=self.kwargs['cover_id']
> -        ).select_related('submitter')
> +class CoverCommentDetail(CoverCommentMixin, MultipleFieldLookupMixin,
> +                         RetrieveUpdateAPIView):
> +    """
> +    get:
> +    Show a cover comment.

I added blank lines between methods for consistency with other views
(see e.g. PatchDetail)

> +    patch:
> +    Update a cover comment.
> +    put:
> +    Update a cover comment.
> +    """
> +    lookup_url_kwargs = ('cover_id', 'comment_id')
> +    lookup_fields = ('cover_id', 'id')
>  
>  
> -class PatchCommentList(ListAPIView):
> -    """List comments"""
> +class PatchCommentList(PatchCommentMixin, ListAPIView):
> +    """List patch comments"""
>  
> -    permission_classes = (PatchworkPermission,)
> -    serializer_class = PatchCommentListSerializer
>      search_fields = ('subject',)
>      ordering_fields = ('id', 'subject', 'date', 'submitter')
>      ordering = 'id'
>      lookup_url_kwarg = 'patch_id'
>  
> -    def get_queryset(self):
> -        if not Patch.objects.filter(id=self.kwargs['patch_id']).exists():
> -            raise Http404
>  
> -        return PatchComment.objects.filter(
> -            patch=self.kwargs['patch_id']
> -        ).select_related('submitter')
> +class PatchCommentDetail(PatchCommentMixin, MultipleFieldLookupMixin,
> +                         RetrieveUpdateAPIView):
> +    """
> +    get:
> +    Show a patch comment.
> +    patch:
> +    Update a patch comment.
> +    put:
> +    Update a patch comment.
> +    """
> +    lookup_url_kwargs = ('patch_id', 'comment_id')
> +    lookup_fields = ('patch_id', 'id')
> diff --git a/patchwork/tests/api/test_comment.py b/patchwork/tests/api/test_comment.py
> index 53abf8f0..033a1416 100644
> --- a/patchwork/tests/api/test_comment.py
> +++ b/patchwork/tests/api/test_comment.py
> @@ -9,11 +9,17 @@ from django.conf import settings
>  from django.urls import NoReverseMatch
>  from django.urls import reverse
>  
> +from patchwork.models import PatchComment
> +from patchwork.models import CoverComment
>  from patchwork.tests.api import utils
>  from patchwork.tests.utils import create_cover
>  from patchwork.tests.utils import create_cover_comment
>  from patchwork.tests.utils import create_patch
>  from patchwork.tests.utils import create_patch_comment
> +from patchwork.tests.utils import create_maintainer
> +from patchwork.tests.utils import create_project
> +from patchwork.tests.utils import create_person
> +from patchwork.tests.utils import create_user
>  from patchwork.tests.utils import SAMPLE_CONTENT
>  
>  if settings.ENABLE_REST_API:
> @@ -23,18 +29,26 @@ if settings.ENABLE_REST_API:
>  @unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
>  class TestCoverComments(utils.APITestCase):
>      @staticmethod
> -    def api_url(cover, version=None):
> -        kwargs = {}
> +    def api_url(cover, version=None, item=None):
> +        kwargs = {'cover_id': cover.id}
>          if version:
>              kwargs['version'] = version
> -        kwargs['cover_id'] = cover.id
> +        if item is None:
> +            return reverse('api-cover-comment-list', kwargs=kwargs)
> +        kwargs['comment_id'] = item.id
> +        return reverse('api-cover-comment-detail', kwargs=kwargs)
>  
> -        return reverse('api-cover-comment-list', kwargs=kwargs)
> +    def setUp(self):
> +        super(TestCoverComments, self).setUp()
> +        self.project = create_project()
> +        self.user = create_maintainer(self.project)
> +        self.cover = create_cover(project=self.project)
>  
>      def assertSerialized(self, comment_obj, comment_json):
>          self.assertEqual(comment_obj.id, comment_json['id'])
>          self.assertEqual(comment_obj.submitter.id,
>                           comment_json['submitter']['id'])
> +        self.assertEqual(comment_obj.addressed, comment_json['addressed'])
>          self.assertIn(SAMPLE_CONTENT, comment_json['content'])
>  
>      def test_list_empty(self):
> @@ -47,10 +61,9 @@ class TestCoverComments(utils.APITestCase):
>      @utils.store_samples('cover-comment-list')
>      def test_list(self):
>          """List cover letter comments."""
> -        cover = create_cover()
> -        comment = create_cover_comment(cover=cover)
> +        comment = create_cover_comment(cover=self.cover)
>  
> -        resp = self.client.get(self.api_url(cover))
> +        resp = self.client.get(self.api_url(self.cover))
>          self.assertEqual(status.HTTP_200_OK, resp.status_code)
>          self.assertEqual(1, len(resp.data))
>          self.assertSerialized(comment, resp.data[0])
> @@ -58,23 +71,21 @@ class TestCoverComments(utils.APITestCase):
>  
>      def test_list_version_1_1(self):
>          """List cover letter comments using API v1.1."""
> -        cover = create_cover()
> -        comment = create_cover_comment(cover=cover)
> +        create_cover_comment(cover=self.cover)
>  
> -        resp = self.client.get(self.api_url(cover, version='1.1'))
> +        resp = self.client.get(self.api_url(self.cover, version='1.1'))
>          self.assertEqual(status.HTTP_200_OK, resp.status_code)
>          self.assertEqual(1, len(resp.data))
> -        self.assertSerialized(comment, resp.data[0])
>          self.assertNotIn('list_archive_url', resp.data[0])
> +        self.assertNotIn('addressed', resp.data[0])
>  
>      def test_list_version_1_0(self):
> -        """List cover letter comments using API v1.0."""
> -        cover = create_cover()
> -        create_cover_comment(cover=cover)
> +        """List cover letter comments using API v1.0.
>  
> -        # check we can't access comments using the old version of the API
> +        Ensure we can't access cover comments using the old version of the API.
> +        """
>          with self.assertRaises(NoReverseMatch):
> -            self.client.get(self.api_url(cover, version='1.0'))
> +            self.client.get(self.api_url(self.cover, version='1.0'))
>  
>      def test_list_invalid_cover(self):
>          """Ensure we get a 404 for a non-existent cover letter."""
> @@ -82,38 +93,193 @@ class TestCoverComments(utils.APITestCase):
>              reverse('api-cover-comment-list', kwargs={'cover_id': '99999'}))
>          self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
>  
> +    @utils.store_samples('cover-comment-detail')
> +    def test_detail(self):
> +        """Show a cover letter comment."""
> +        comment = create_cover_comment(cover=self.cover)
> +
> +        resp = self.client.get(self.api_url(self.cover, item=comment))
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertSerialized(comment, resp.data)
> +
> +    def test_detail_version_1_3(self):
> +        """Show a cover letter comment using API v1.3."""
> +        comment = create_cover_comment(cover=self.cover)
> +
> +        resp = self.client.get(
> +            self.api_url(self.cover, version='1.3', item=comment))
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertSerialized(comment, resp.data)
> +
> +    def test_detail_version_1_2(self):
> +        """Show a cover letter comment using API v1.2."""
> +        comment = create_cover_comment(cover=self.cover)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.cover, version='1.2', item=comment))
> +
> +    def test_detail_version_1_1(self):
> +        """Show a cover letter comment using API v1.1."""
> +        comment = create_cover_comment(cover=self.cover)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.cover, version='1.1', item=comment))
> +
> +    def test_detail_version_1_0(self):
> +        """Show a cover letter comment using API v1.0."""
> +        comment = create_cover_comment(cover=self.cover)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.cover, version='1.0', item=comment))
> +
> +    @utils.store_samples('cover-comment-detail-error-not-found')
> +    def test_detail_invalid_cover(self):
> +        """Ensure we handle non-existent cover letters."""
> +        comment = create_cover_comment()
> +        resp = self.client.get(
> +            reverse('api-cover-comment-detail', kwargs={
> +                'cover_id': '99999',
> +                'comment_id': comment.id}
> +            ),
> +        )
> +        self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
> +
> +    def _test_update(self, person, **kwargs):
> +        submitter = kwargs.get('submitter', person)
> +        cover = kwargs.get('cover', self.cover)
> +        comment = create_cover_comment(submitter=submitter, cover=cover)
> +
> +        if kwargs.get('authenticate', True):
> +            self.client.force_authenticate(user=person.user)
> +        return self.client.patch(
> +            self.api_url(cover, item=comment),
> +            {'addressed': kwargs.get('addressed', True)},
> +            validate_request=kwargs.get('validate_request', True)
> +        )
> +
> +    @utils.store_samples('cover-comment-detail-update-authorized')
> +    def test_update_authorized(self):
> +        """Update an existing cover letter comment as an authorized user.
> +
> +        To be authorized users must meet at least one of the following:
> +        - project maintainer, cover letter submitter, or cover letter
> +          comment submitter
> +
> +        Ensure updates can only be performed by authorized users.
> +        """
> +        # Update as maintainer
> +        person = create_person(user=self.user)
> +        resp = self._test_update(person=person)
> +        self.assertEqual(1, CoverComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +        # Update as cover letter submitter
> +        person = create_person(name='cover-submitter', user=create_user())
> +        cover = create_cover(submitter=person)
> +        resp = self._test_update(person=person, cover=cover)
> +        self.assertEqual(2, CoverComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +        # Update as cover letter comment submitter
> +        person = create_person(name='comment-submitter', user=create_user())
> +        cover = create_cover()
> +        resp = self._test_update(person=person, cover=cover, submitter=person)
> +        self.assertEqual(3, CoverComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +    @utils.store_samples('cover-comment-detail-update-not-authorized')
> +    def test_update_not_authorized(self):
> +        """Update an existing cover letter comment when not signed in and
> +           not authorized.
> +
> +        To be authorized users must meet at least one of the following:
> +        - project maintainer, cover letter submitter, or cover letter
> +          comment submitter
> +
> +        Ensure updates can only be performed by authorized users.
> +        """
> +        person = create_person(user=self.user)
> +        resp = self._test_update(person=person, authenticate=False)
> +        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
> +
> +        person = create_person()  # normal user without edit permissions
> +        resp = self._test_update(person=person)  # signed-in
> +        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
> +
> +    @utils.store_samples('cover-comment-detail-update-error-bad-request')
> +    def test_update_invalid_addressed(self):
> +        """Update an existing cover letter comment using invalid values.
> +
> +        Ensure we handle invalid cover letter comment addressed values.
> +        """
> +        person = create_person(name='cover-submitter', user=create_user())
> +        cover = create_cover(submitter=person)
> +        resp = self._test_update(person=person,
> +                                 cover=cover,
> +                                 addressed='not-valid',
> +                                 validate_request=False)
> +        self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code)
> +        self.assertFalse(
> +            getattr(CoverComment.objects.all().first(), 'addressed')
> +        )
> +
> +    def test_create_delete(self):
> +        """Ensure creates and deletes aren't allowed"""
> +        comment = create_cover_comment(cover=self.cover)
> +        self.user.is_superuser = True
> +        self.user.save()
> +        self.client.force_authenticate(user=self.user)
> +
> +        resp = self.client.post(self.api_url(self.cover, item=comment))
> +        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
> +
> +        resp = self.client.delete(self.api_url(self.cover, item=comment))
> +        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
> +
>  
>  @unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
>  class TestPatchComments(utils.APITestCase):
>      @staticmethod
> -    def api_url(patch, version=None):
> -        kwargs = {}
> +    def api_url(patch, version=None, item=None):
> +        kwargs = {'patch_id': patch.id}
>          if version:
>              kwargs['version'] = version
> -        kwargs['patch_id'] = patch.id
> +        if item is None:
> +            return reverse('api-patch-comment-list', kwargs=kwargs)
> +        kwargs['comment_id'] = item.id
> +        return reverse('api-patch-comment-detail', kwargs=kwargs)
>  
> -        return reverse('api-patch-comment-list', kwargs=kwargs)
> +    def setUp(self):
> +        super(TestPatchComments, self).setUp()
> +        self.project = create_project()
> +        self.user = create_maintainer(self.project)
> +        self.patch = create_patch(project=self.project)
>  
>      def assertSerialized(self, comment_obj, comment_json):
>          self.assertEqual(comment_obj.id, comment_json['id'])
>          self.assertEqual(comment_obj.submitter.id,
>                           comment_json['submitter']['id'])
> +        self.assertEqual(comment_obj.addressed, comment_json['addressed'])
>          self.assertIn(SAMPLE_CONTENT, comment_json['content'])
>  
>      def test_list_empty(self):
>          """List patch comments when none are present."""
> -        patch = create_patch()
> -        resp = self.client.get(self.api_url(patch))
> +        resp = self.client.get(self.api_url(self.patch))
>          self.assertEqual(status.HTTP_200_OK, resp.status_code)
>          self.assertEqual(0, len(resp.data))
>  
>      @utils.store_samples('patch-comment-list')
>      def test_list(self):
>          """List patch comments."""
> -        patch = create_patch()
> -        comment = create_patch_comment(patch=patch)
> +        comment = create_patch_comment(patch=self.patch)
>  
> -        resp = self.client.get(self.api_url(patch))
> +        resp = self.client.get(self.api_url(self.patch))
>          self.assertEqual(status.HTTP_200_OK, resp.status_code)
>          self.assertEqual(1, len(resp.data))
>          self.assertSerialized(comment, resp.data[0])
> @@ -121,26 +287,180 @@ class TestPatchComments(utils.APITestCase):
>  
>      def test_list_version_1_1(self):
>          """List patch comments using API v1.1."""
> -        patch = create_patch()
> -        comment = create_patch_comment(patch=patch)
> +        create_patch_comment(patch=self.patch)
>  
> -        resp = self.client.get(self.api_url(patch, version='1.1'))
> +        resp = self.client.get(self.api_url(self.patch, version='1.1'))
>          self.assertEqual(status.HTTP_200_OK, resp.status_code)
>          self.assertEqual(1, len(resp.data))
> -        self.assertSerialized(comment, resp.data[0])
>          self.assertNotIn('list_archive_url', resp.data[0])
> +        self.assertNotIn('addressed', resp.data[0])
>  
>      def test_list_version_1_0(self):
> -        """List patch comments using API v1.0."""
> -        patch = create_patch()
> -        create_patch_comment(patch=patch)
> +        """List patch comments using API v1.0.
>  
> -        # check we can't access comments using the old version of the API
> +        Ensure we can't access comments using the old version of the API.
> +        """
>          with self.assertRaises(NoReverseMatch):
> -            self.client.get(self.api_url(patch, version='1.0'))
> +            self.client.get(self.api_url(self.patch, version='1.0'))
>  
>      def test_list_invalid_patch(self):
>          """Ensure we get a 404 for a non-existent patch."""
>          resp = self.client.get(
>              reverse('api-patch-comment-list', kwargs={'patch_id': '99999'}))
>          self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
> +
> +    @utils.store_samples('patch-comment-detail')
> +    def test_detail(self):
> +        """Show a patch comment."""
> +        comment = create_patch_comment(patch=self.patch)
> +
> +        resp = self.client.get(self.api_url(self.patch, item=comment))
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertSerialized(comment, resp.data)
> +
> +    def test_detail_version_1_3(self):
> +        """Show a patch comment using API v1.3."""
> +        comment = create_patch_comment(patch=self.patch)
> +
> +        resp = self.client.get(
> +            self.api_url(self.patch, version='1.3', item=comment))
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertSerialized(comment, resp.data)
> +
> +    def test_detail_version_1_2(self):
> +        """Show a patch comment using API v1.2."""
> +        comment = create_patch_comment(patch=self.patch)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.patch, version='1.2', item=comment))
> +
> +    def test_detail_version_1_1(self):
> +        """Show a patch comment using API v1.1."""
> +        comment = create_patch_comment(patch=self.patch)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.patch, version='1.1', item=comment))
> +
> +    def test_detail_version_1_0(self):
> +        """Show a patch comment using API v1.0."""
> +        comment = create_patch_comment(patch=self.patch)
> +
> +        with self.assertRaises(NoReverseMatch):
> +            self.client.get(
> +                self.api_url(self.patch, version='1.0', item=comment))
> +
> +    @utils.store_samples('patch-comment-detail-error-not-found')
> +    def test_detail_invalid_patch(self):
> +        """Ensure we handle non-existent patches."""
> +        comment = create_patch_comment()
> +        resp = self.client.get(
> +            reverse('api-patch-comment-detail', kwargs={
> +                'patch_id': '99999',
> +                'comment_id': comment.id}
> +            ),
> +        )
> +        self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
> +
> +    def _test_update(self, person, **kwargs):
> +        submitter = kwargs.get('submitter', person)
> +        patch = kwargs.get('patch', self.patch)
> +        comment = create_patch_comment(submitter=submitter, patch=patch)
> +
> +        if kwargs.get('authenticate', True):
> +            self.client.force_authenticate(user=person.user)
> +        return self.client.patch(
> +            self.api_url(patch, item=comment),
> +            {'addressed': kwargs.get('addressed', True)},
> +            validate_request=kwargs.get('validate_request', True)
> +        )
> +
> +    @utils.store_samples('patch-comment-detail-update-authorized')
> +    def test_update_authorized(self):
> +        """Update an existing patch comment as an authorized user.
> +
> +        To be authorized users must meet at least one of the following:
> +        - project maintainer, patch submitter, patch delegate, or
> +          patch comment submitter
> +
> +        Ensure updates can only be performed by authorized users.
> +        """
> +        # Update as maintainer
> +        person = create_person(user=self.user)
> +        resp = self._test_update(person=person)
> +        self.assertEqual(1, PatchComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +        # Update as patch submitter
> +        person = create_person(name='patch-submitter', user=create_user())
> +        patch = create_patch(submitter=person)
> +        resp = self._test_update(person=person, patch=patch)
> +        self.assertEqual(2, PatchComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +        # Update as patch delegate
> +        person = create_person(name='patch-delegate', user=create_user())
> +        patch = create_patch(delegate=person.user)
> +        resp = self._test_update(person=person, patch=patch)
> +        self.assertEqual(3, PatchComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +        # Update as patch comment submitter
> +        person = create_person(name='comment-submitter', user=create_user())
> +        patch = create_patch()
> +        resp = self._test_update(person=person, patch=patch, submitter=person)
> +        self.assertEqual(4, PatchComment.objects.all().count())
> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)
> +        self.assertTrue(resp.data['addressed'])
> +
> +    @utils.store_samples('patch-comment-detail-update-not-authorized')
> +    def test_update_not_authorized(self):
> +        """Update an existing patch comment when not signed in and not authorized.
> +
> +        To be authorized users must meet at least one of the following:
> +        - project maintainer, patch submitter, patch delegate, or
> +          patch comment submitter
> +
> +        Ensure updates can only be performed by authorized users.
> +        """
> +        person = create_person(user=self.user)
> +        resp = self._test_update(person=person, authenticate=False)
> +        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
> +
> +        person = create_person()  # normal user without edit permissions
> +        resp = self._test_update(person=person)  # signed-in
> +        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
> +
> +    @utils.store_samples('patch-comment-detail-update-error-bad-request')
> +    def test_update_invalid_addressed(self):
> +        """Update an existing patch comment using invalid values.
> +
> +        Ensure we handle invalid patch comment addressed values.
> +        """
> +        person = create_person(name='patch-submitter', user=create_user())
> +        patch = create_patch(submitter=person)
> +        resp = self._test_update(person=person,
> +                                 patch=patch,
> +                                 addressed='not-valid',
> +                                 validate_request=False)
> +        self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code)
> +        self.assertFalse(
> +            getattr(PatchComment.objects.all().first(), 'addressed')
> +        )
> +
> +    def test_create_delete(self):
> +        """Ensure creates and deletes aren't allowed"""
> +        comment = create_patch_comment(patch=self.patch)
> +        self.user.is_superuser = True
> +        self.user.save()
> +        self.client.force_authenticate(user=self.user)
> +
> +        resp = self.client.post(self.api_url(self.patch, item=comment))
> +        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
> +
> +        resp = self.client.delete(self.api_url(self.patch, item=comment))
> +        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
> diff --git a/patchwork/urls.py b/patchwork/urls.py
> index 0180e76d..b3f40cbf 100644
> --- a/patchwork/urls.py
> +++ b/patchwork/urls.py
> @@ -343,12 +343,28 @@ if settings.ENABLE_REST_API:
>          ),
>      ]
>  
> +    api_1_3_patterns = [
> +        path(
> +            'patches/<patch_id>/comments/<comment_id>/',
> +            api_comment_views.PatchCommentDetail.as_view(),
> +            name='api-patch-comment-detail',
> +        ),
> +        path(
> +            'covers/<cover_id>/comments/<comment_id>/',
> +            api_comment_views.CoverCommentDetail.as_view(),
> +            name='api-cover-comment-detail',
> +        ),
> +    ]

I added int: filters to these per the weekend's bugs.

> +
>      urlpatterns += [
>          re_path(
> -            r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)
> +            r'^api/(?:(?P<version>(1.0|1.1|1.2|1.3))/)?', include(api_patterns)
> +        ),
> +        re_path(
> +            r'^api/(?:(?P<version>(1.1|1.2|1.3))/)?', include(api_1_1_patterns)
>          ),
>          re_path(
> -            r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)
> +            r'^api/(?:(?P<version>(1.3))/)?', include(api_1_3_patterns)
>          ),
>          # token change
>          path(
> -- 
> 2.33.0.rc2.250.ged5fa647cd-goog
>
> _______________________________________________
> Patchwork mailing list
> Patchwork at lists.ozlabs.org
> https://lists.ozlabs.org/listinfo/patchwork


More information about the Patchwork mailing list