<div><br></div><div><br><div class="gmail_quote"><div dir="auto">On Sa., 11. Jan. 2020 at 12:54, Daniel Axtens <<a href="mailto:dja@axtens.net">dja@axtens.net</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">Mete Polat <<a href="mailto:metepolat2000@gmail.com" target="_blank">metepolat2000@gmail.com</a>> writes:<br>
<br>
> Hi Daniel,<br>
><br>
> (sorry for the short delay)<br>
><br>
> On 06.11.19 16:12, Daniel Axtens wrote:<br>
>> Mete Polat <<a href="mailto:metepolat2000@gmail.com" target="_blank">metepolat2000@gmail.com</a>> writes:<br>
>> <br>
>>> View relations or add/update/delete them as a maintainer. Maintainers<br>
>>> can only create relations of submissions (patches/cover letters) which<br>
>>> are part of a project they maintain.<br>
>>><br>
>>> New REST API urls:<br>
>>> api/relations/<br>
>>> api/relations/<relation_id>/<br>
>>><br>
>>> Signed-off-by: Mete Polat <<a href="mailto:metepolat2000@gmail.com" target="_blank">metepolat2000@gmail.com</a>><br>
>>> ---<br>
>>> Previously it was possible to use the PatchSerializer. As we expanded the<br>
>>> support to submissions in general, it isn't a simple task anymore showing<br>
>>> hyperlinked submissions (as Patch and CoverLetter are not flattened into<br>
>>> one model yet). Right now only the submission ids are shown.<br>
>> <br>
>> Hmm, that's unfortunate. I didn't intend to lead you in to this problem,<br>
>> sorry! Having said that, it'd be good to supply some common information.<br>
>> <br>
>> How about this, which is super-gross but which I think is probably the<br>
>> best we can do unless Stephen can chime in with something better...<br>
>> <br>
>> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py<br>
>> index de4f31165ee7..8d44592b51f1 100644<br>
>> --- a/patchwork/api/embedded.py<br>
>> +++ b/patchwork/api/embedded.py<br>
>> @@ -102,6 +102,45 @@ class CheckSerializer(SerializedRelatedField):<br>
>>              }<br>
>>  <br>
>>  <br>
>> +def _upgrade_instance(instance):<br>
>> +    if hasattr(instance, 'patch'):<br>
>> +        return instance.patch<br>
>> +    else:<br>
>> +        return instance.coverletter<br>
>> +<br>
>> +<br>
>> +class SubmissionSerializer(SerializedRelatedField):<br>
>> +<br>
>> +    class _Serializer(BaseHyperlinkedModelSerializer):<br>
>> +        """We need to 'upgrade' or specialise the submission to the relevant<br>
>> +        subclass, so we can't use the mixins. This is gross but can go away<br>
>> +        once we flatten the models."""<br>
>> +        url = SerializerMethodField()<br>
>> +        web_url = SerializerMethodField()<br>
>> +        mbox = SerializerMethodField()<br>
>> +<br>
>> +        def get_url(self, instance):<br>
>> +            instance = _upgrade_instance(instance)<br>
>> +            request = self.context.get('request')<br>
>> +            return request.build_absolute_uri(instance.get_absolute_url())<br>
>> +<br>
>> +        def get_web_url(self, instance):<br>
>> +            instance = _upgrade_instance(instance)<br>
>> +            request = self.context.get('request')<br>
>> +            return request.build_absolute_uri(instance.get_absolute_url())<br>
>> +<br>
><br>
> Nice, thank you. I think this should be sufficient. Just want to note<br>
> that get_url and get_web_url are showing the same url. I will change<br>
> that in the next revision.<br>
><br>
>> +        def get_mbox(self, instance):<br>
>> +            instance = _upgrade_instance(instance)<br>
>> +            request = self.context.get('request')<br>
>> +            return request.build_absolute_uri(instance.get_mbox_url())<br>
>> +<br>
>> +        class Meta:<br>
>> +            model = models.Submission<br>
>> +            fields = ('id', 'url', 'web_url', 'msgid', 'list_archive_url',<br>
>> +                      'date', 'name', 'mbox')<br>
>> +            read_only_fields = fields<br>
>> +<br>
>> +<br>
>>  class CoverLetterSerializer(SerializedRelatedField):<br>
>>  <br>
>>      class _Serializer(MboxMixin, WebURLMixin, BaseHyperlinkedModelSerializer):<br>
>> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py<br>
>> index e7d002b9375a..c02a6fe67e2c 100644<br>
>> --- a/patchwork/api/relation.py<br>
>> +++ b/patchwork/api/relation.py<br>
>> @@ -9,6 +9,7 @@ from rest_framework.generics import ListCreateAPIView<br>
>>  from rest_framework.generics import RetrieveUpdateDestroyAPIView<br>
>>  from rest_framework.serializers import ModelSerializer<br>
>>  <br>
>> +from patchwork.api.embedded import SubmissionSerializer<br>
>>  from patchwork.models import SubmissionRelation<br>
>>  <br>
>>  <br>
>> @@ -34,6 +35,8 @@ class MaintainerPermission(permissions.BasePermission):<br>
>>  <br>
>>  <br>
>>  class SubmissionRelationSerializer(ModelSerializer):<br>
>> +    submissions = SubmissionSerializer(many=True)<br>
>> +<br>
>>      class Meta:<br>
>>          model = SubmissionRelation<br>
>>          fields = ('id', 'url', 'submissions',)<br>
>> <br>
>> <br>
>>><br>
>>>  docs/api/schemas/latest/patchwork.yaml        | 218 +++++++++++++++++<br>
>>>  docs/api/schemas/patchwork.j2                 | 230 ++++++++++++++++++<br>
>>>  docs/api/schemas/v1.2/patchwork.yaml          | 218 +++++++++++++++++<br>
>>>  patchwork/api/index.py                        |   1 +<br>
>>>  patchwork/api/relation.py                     |  73 ++++++<br>
>>>  patchwork/tests/api/test_relation.py          | 194 +++++++++++++++<br>
>>>  patchwork/tests/utils.py                      |  11 +<br>
>>>  patchwork/urls.py                             |  11 +<br>
>>>  ...submission-relations-c96bb6c567b416d8.yaml |  10 +<br>
>>>  9 files changed, 966 insertions(+)<br>
>>> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py<br>
>>> new file mode 100644<br>
>>> index 0000000..e7d002b<br>
>>> --- /dev/null<br>
>>> +++ b/patchwork/api/relation.py<br>
>>> @@ -0,0 +1,73 @@<br>
>>> +# Patchwork - automated patch tracking system<br>
>>> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<br>
>>> +#<br>
>>> +# SPDX-License-Identifier: GPL-2.0-or-later<br>
>>> +<br>
>>> +from django.db.models import Count<br>
>>> +from rest_framework import permissions<br>
>>> +from rest_framework.generics import ListCreateAPIView<br>
>>> +from rest_framework.generics import RetrieveUpdateDestroyAPIView<br>
>>> +from rest_framework.serializers import ModelSerializer<br>
>>> +<br>
>>> +from patchwork.models import SubmissionRelation<br>
>>> +<br>
>>> +<br>
>>> +class MaintainerPermission(permissions.BasePermission):<br>
>>> +<br>
>>> +    def has_object_permission(self, request, view, submissions):<br>
>>> +        if request.method in permissions.SAFE_METHODS:<br>
>>> +            return True<br>
>>> +<br>
>>> +        user = request.user<br>
>>> +        if not user.is_authenticated:<br>
>>> +            return False<br>
>>> +<br>
>>> +        if isinstance(submissions, SubmissionRelation):<br>
>>> +            submissions = list(submissions.submissions.all())<br>
>>> +        maintaining = user.profile.maintainer_projects.all()<br>
>>> +        return all(s.project in maintaining for s in submissions)<br>
>>> If I understand this correctly, you are saying that to have permissions,<br>
>> for all patches, you must be a maintainer of that project.<br>
>> <br>
>> That's correct, I think, but a comment spelling it out would be helpful:<br>
>> perhaps it's because I don't do enough functional programming but it<br>
>> took me a while to understand what you'd written.<br>
>> <br>
>> Partially due to my lack of expertise with DRF, it wasn't clear to me<br>
>> that this prevents the _creation_ (as opposed to modification) of a<br>
>> relation where you're not the maintainer of all the involved projects,<br>
>> but upon testing it would appear that it does, so that's good.<br>
>> <br>
>>> +<br>
>>> +    def has_permission(self, request, view):<br>
>>> +        return request.method in permissions.SAFE_METHODS or \<br>
>>> +               (request.user.is_authenticated and<br>
>>> +                request.user.profile.maintainer_projects.count() > 0)<br>
>>> +<br>
>>> +<br>
>>> +class SubmissionRelationSerializer(ModelSerializer):<br>
>>> +    class Meta:<br>
>>> +        model = SubmissionRelation<br>
>>> +        fields = ('id', 'url', 'submissions',)<br>
>>> +        read_only_fields = ('url',)<br>
>>> +        extra_kwargs = {<br>
>>> +            'url': {'view_name': 'api-relation-detail'},<br>
>>> +        }<br>
>>> +<br>
>>> +<br>
>>> +class SubmissionRelationMixin:<br>
>>> +    serializer_class = SubmissionRelationSerializer<br>
>>> +    permission_classes = (MaintainerPermission,)<br>
>>> +<br>
>>> +    def get_queryset(self):<br>
>>> +        return SubmissionRelation.objects.all() \<br>
>>> +            .prefetch_related('submissions')<br>
>> <br>
>> So prefetch_related always makes me perk up my ears.<br>
>> <br>
>> Here, we end up doing an individual database query for every submission<br>
>> that is in a relation (you can observe this with the Django Debug<br>
>> Toolbar). That's probably not ideal: you could get potentially a large<br>
>> number of relations per page with a large number of patches per<br>
>> relation.<br>
>> > More interestingly, looking into it I think the way you've implemented<br>
>> the model in patch 3 means that a patch can only have at most one<br>
>> relation? Indeed, testing it shows that to be the case.<br>
>> <br>
>> That's deeply counter-intuitive to me - shouldn't a patch be able to be<br>
>> involved in multiple relations? Currently it can only be in one set of<br>
>> relations (belong to one SubmissionRelation), it seems to me that it<br>
>> ought to be able to be in multiple sets of relations.<br>
>> <br>
>> I'm trying to think of a good example of where that makes sense, and I<br>
>> think one is versions vs backports. So I say that v1 of patch A is<br>
>> related to v2 and v3 of patch A, and it makes sense for that to be one<br>
>> SubmissionRelation which v1, v2 and v3 all belong to. Then say that v3<br>
>> is accepted, and then there's a stable backport of that to some old<br>
>> trees. I would then say that v3 was related to the backport, but I'm not<br>
>> sure I'd say that v1 was related to the backport in quite the same way.<br>
>> <br>
>> So I would have thought there would be a many-to-many relationship<br>
>> between submissions and relations. This would also facilitate relations<br>
>> set by multiple tools where we want to be able to maintain some<br>
>> separation.<br>
>> <br>
>> Perhaps there's a good rationale for the current one-to-many<br>
>> relationship, and I'm open to hearing it. If that's the case, then at the<br>
>> very least, you shouldn't be able to silently _remove_ a patch from a<br>
>> relation by adding it to another. You should be forced to first remove<br>
>> the patch from the original relationship explictly, leaving it with<br>
>> related=NULL, and then you can set another relationship.<br>
><br>
> I think you are right that there are different types of relations<br>
> however I am not sure how you exactly want to display them.<br>
><br>
> When viewing a submission, how do we decide which SubmissionRelation to<br>
> display under 'related'? Do we list the submissions of every<br>
> SubmissionRelation a specific submission is part of? Do we list them<br>
> separately or combined? Should users be able to mark a<br>
> SubmissionRelation as something like "backport-relation" which can then<br>
> be displayed in a separate row when viewing a submission?<br>
><br>
> I think a more complex relation model could possibly lead to some<br>
> confusion and unnecessary difficulties.<br>
><br>
> Furthermore  I am not sure whether a more complex model really<br>
> facilitates tools. As an example, PaStA, the analysis tool we are<br>
> currently using for finding relations and commit_refs for a specific<br>
> patch, does not differentiate between different types of relations.<br>
><br>
> Nevertheless I am open to be convinced. I just don't want to complicate<br>
> things if there is really no need.<br>
<br>
Sorry I missed this - December was crazy with the non-patchwork parts of<br>
my job.<br>
<br>
My vision was to list them all, but I agree that figuring out how to do<br>
that is a bit tricky...<br>
<br>
>> Further to that, please could you add a field to SubmissionRelation with<br>
>> some sort of comment/context/tool identification/etc so we can track<br>
>> who's doing what. See e.g. the context field in the Check model.<br>
<br>
I was hoping a context field would provide space for something like<br>
"backports" or "PaStA-bot" so people could identify what was creating<br>
the relation. That would then provide headings for the list.<br>
<br>
But I agree it's complex, so let's get the simple one deployed in 2.2<br>
and see what people do with it. It _may_ limit people to ~1 bot per<br>
mailing list, but let's see what happens.<br>
<br>
Again, apologies for dropping this on the floor.<br>
</blockquote><div dir="auto"><br></div><div dir="auto">We share the same vision. We need a first few implementations of scripts/heuristics that make use of this feature, though. Then, we will see how users and projects really want to use this, and what we can implement to support those use cases.</div><div dir="auto"><br></div><div dir="auto">Lukas</div><div dir="auto"><br></div><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><br>
Regards,<br>
Daniel<br>
<br>
>> <br>
>> <br>
>>> +<br>
>>> +<br>
>>> +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView):<br>
>>> +    ordering = 'id'<br>
>>> +    ordering_fields = ['id', 'submission_count']<br>
>> <br>
>> What is the benefit of being able to order by submission_count?<br>
><br>
> We can remove this. I just used it to see how the UI looks like for<br>
> different relation counts.<br>
><br>
>> It would be really nice to be able to filter by project, depending on<br>
>> how feasible that is - it requires jumping though a JOIN which is<br>
>> unpleasant...<br>
>> <br>
><br>
> I will see how manageable this is.<br>
><br>
>>> +<br>
>>> +    def create(self, request, *args, **kwargs):<br>
>>> +        serializer = self.get_serializer(data=request.data)<br>
>>> +        serializer.is_valid(raise_exception=True)<br>
>>> +        submissions = serializer.validated_data['submissions']<br>
>>> +        self.check_object_permissions(request, submissions)<br>
>>> +        return super().create(request, *args, **kwargs)<br>
>>> +<br>
>>> +    def get_queryset(self):<br>
>>> +        return super().get_queryset() \<br>
>>> +            .annotate(submission_count=Count('submission'))<br>
>>> +<br>
>>> +<br>
>>> +class SubmissionRelationDetail(SubmissionRelationMixin,<br>
>>> +                               RetrieveUpdateDestroyAPIView):<br>
>>> +    pass<br>
>>> diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py<br>
>>> new file mode 100644<br>
>>> index 0000000..296926d<br>
>>> --- /dev/null<br>
>>> +++ b/patchwork/tests/api/test_relation.py<br>
>>> @@ -0,0 +1,194 @@<br>
>>> +# Patchwork - automated patch tracking system<br>
>>> +# Copyright (C) 2019, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<br>
>>> +#<br>
>>> +# SPDX-License-Identifier: GPL-2.0-or-later<br>
>>> +<br>
>>> +import unittest<br>
>>> +from enum import Enum<br>
>>> +from enum import auto<br>
>>> +<br>
>>> +import six<br>
>>> +from django.conf import settings<br>
>>> +from django.urls import reverse<br>
>>> +<br>
>>> +from patchwork.models import SubmissionRelation<br>
>>> +from patchwork.tests.api import utils<br>
>>> +from patchwork.tests.utils import create_maintainer<br>
>>> +from patchwork.tests.utils import create_patches<br>
>>> +from patchwork.tests.utils import create_project<br>
>>> +from patchwork.tests.utils import create_relation<br>
>>> +from patchwork.tests.utils import create_user<br>
>>> +<br>
>>> +if settings.ENABLE_REST_API:<br>
>>> +    from rest_framework import status<br>
>>> +<br>
>>> +<br>
>>> +class UserType(Enum):<br>
>>> +    ANONYMOUS = auto()<br>
>>> +    NON_MAINTAINER = auto()<br>
>>> +    MAINTAINER = auto()<br>
>>> +<br>
>>> +@unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')<br>
>>> +class TestRelationAPI(utils.APITestCase):<br>
>>> +    fixtures = ['default_tags']<br>
>>> +<br>
>>> +    @staticmethod<br>
>>> +    def api_url(item=None):<br>
>>> +        kwargs = {}<br>
>>> +        if item is None:<br>
>>> +            return reverse('api-relation-list', kwargs=kwargs)<br>
>>> +        kwargs['pk'] = item<br>
>>> +        return reverse('api-relation-detail', kwargs=kwargs)<br>
>>> +<br>
>>> +    def request_restricted(self, method, user_type: UserType):<br>
>>> +        # setup<br>
>>> +<br>
>>> +        project = create_project()<br>
>>> +<br>
>>> +        if user_type == UserType.ANONYMOUS:<br>
>>> +            expected_status = status.HTTP_403_FORBIDDEN<br>
>>> +        elif user_type == UserType.NON_MAINTAINER:<br>
>>> +            expected_status = status.HTTP_403_FORBIDDEN<br>
>>> +            self.client.force_authenticate(user=create_user())<br>
>>> +        elif user_type == UserType.MAINTAINER:<br>
>>> +            if method == 'post':<br>
>>> +                expected_status = status.HTTP_201_CREATED<br>
>>> +            elif method == 'delete':<br>
>>> +                expected_status = status.HTTP_204_NO_CONTENT<br>
>>> +            else:<br>
>>> +                expected_status = status.HTTP_200_OK<br>
>>> +            user = create_maintainer(project)<br>
>>> +            self.client.force_authenticate(user=user)<br>
>>> +        else:<br>
>>> +            raise ValueError<br>
>>> +<br>
>>> +        resource_id = None<br>
>>> +        send = None<br>
>>> +<br>
>>> +        if method == 'delete':<br>
>>> +            resource_id = create_relation(project=project).id<br>
>>> +        elif method == 'post':<br>
>>> +            patch_ids = [<a href="http://p.id" rel="noreferrer" target="_blank">p.id</a> for p in create_patches(2, project=project)]<br>
>>> +            send = {'submissions': patch_ids}<br>
>>> +        elif method == 'patch':<br>
>>> +            resource_id = create_relation(project=project).id<br>
>>> +            patch_ids = [<a href="http://p.id" rel="noreferrer" target="_blank">p.id</a> for p in create_patches(2, project=project)]<br>
>>> +            send = {'submissions': patch_ids}<br>
>>> +        else:<br>
>>> +            raise ValueError<br>
>>> +<br>
>>> +        # request<br>
>>> +<br>
>>> +        resp = getattr(self.client, method)(self.api_url(resource_id), send)<br>
>>> +<br>
>>> +        # check<br>
>>> +<br>
>>> +        self.assertEqual(expected_status, resp.status_code)<br>
>>> +<br>
>>> +        if resp.status_code not in range(200, 202):<br>
>>> +            return<br>
>>> +<br>
>>> +        if resource_id:<br>
>>> +            self.assertEqual(resource_id, resp.data['id'])<br>
>>> +<br>
>>> +        send_ids = send['submissions']<br>
>>> +        resp_ids = resp.data['submissions']<br>
>>> +        six.assertCountEqual(self, resp_ids, send_ids)<br>
>>> +<br>
>>> +    def assertSerialized(self, obj: SubmissionRelation, resp: dict):<br>
>>> +        self.assertEqual(<a href="http://obj.id" rel="noreferrer" target="_blank">obj.id</a>, resp['id'])<br>
>>> +        obj = [<a href="http://s.id" rel="noreferrer" target="_blank">s.id</a> for s in obj.submissions.all()]<br>
>>> +        six.assertCountEqual(self, obj, resp['submissions'])<br>
>>> +<br>
>>> +    def test_list_empty(self):<br>
>>> +        """List relation when none are present."""<br>
>>> +        resp = self.client.get(self.api_url())<br>
>>> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)<br>
>>> +        self.assertEqual(0, len(resp.data))<br>
>>> +<br>
>>> +    @utils.store_samples('relation-list')<br>
>>> +    def test_list(self):<br>
>>> +        """List relations."""<br>
>>> +        relation = create_relation()<br>
>>> +<br>
>>> +        resp = self.client.get(self.api_url())<br>
>>> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)<br>
>>> +        self.assertEqual(1, len(resp.data))<br>
>>> +        self.assertSerialized(relation, resp.data[0])<br>
>>> +<br>
>>> +    def test_detail(self):<br>
>>> +        """Show relation."""<br>
>>> +        relation = create_relation()<br>
>>> +<br>
>>> +        resp = self.client.get(self.api_url(<a href="http://relation.id" rel="noreferrer" target="_blank">relation.id</a>))<br>
>>> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)<br>
>>> +        self.assertSerialized(relation, resp.data)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-update-error-forbidden')<br>
>>> +    def test_update_anonymous(self):<br>
>>> +        """Update relation as anonymous user.<br>
>>> +<br>
>>> +        Ensure updates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('patch', UserType.ANONYMOUS)<br>
>>> +<br>
>>> +    def test_update_non_maintainer(self):<br>
>>> +        """Update relation as non-maintainer.<br>
>>> +<br>
>>> +        Ensure updates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('patch', UserType.NON_MAINTAINER)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-update')<br>
>>> +    def test_update_maintainer(self):<br>
>>> +        """Update relation as maintainer.<br>
>>> +<br>
>>> +        Ensure updates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('patch', UserType.MAINTAINER)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-delete-error-forbidden')<br>
>>> +    def test_delete_anonymous(self):<br>
>>> +        """Delete relation as anonymous user.<br>
>>> +<br>
>>> +        Ensure deletes can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('delete', UserType.ANONYMOUS)<br>
>>> +<br>
>>> +    def test_delete_non_maintainer(self):<br>
>>> +        """Delete relation as non-maintainer.<br>
>>> +<br>
>>> +        Ensure deletes can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('delete', UserType.NON_MAINTAINER)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-update')<br>
>>> +    def test_delete_maintainer(self):<br>
>>> +        """Delete relation as maintainer.<br>
>>> +<br>
>>> +        Ensure deletes can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('delete', UserType.MAINTAINER)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-create-error-forbidden')<br>
>>> +    def test_create_anonymous(self):<br>
>>> +        """Create relation as anonymous user.<br>
>>> +<br>
>>> +        Ensure creates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('post', UserType.ANONYMOUS)<br>
>>> +<br>
>>> +    def test_create_non_maintainer(self):<br>
>>> +        """Create relation as non-maintainer.<br>
>>> +<br>
>>> +        Ensure creates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('post', UserType.NON_MAINTAINER)<br>
>>> +<br>
>>> +    @utils.store_samples('relation-create')<br>
>>> +    def test_create_maintainer(self):<br>
>>> +        """Create relation as maintainer.<br>
>>> +<br>
>>> +        Ensure creates can be performed by maintainers.<br>
>>> +        """<br>
>>> +        self.request_restricted('post', UserType.MAINTAINER)<br>
>>> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py<br>
>>> index 577183d..47149de 100644<br>
>>> --- a/patchwork/tests/utils.py<br>
>>> +++ b/patchwork/tests/utils.py<br>
>>> @@ -16,6 +16,7 @@ from patchwork.models import Check<br>
>>>  from patchwork.models import Comment<br>
>>>  from patchwork.models import CoverLetter<br>
>>>  from patchwork.models import Patch<br>
>>> +from patchwork.models import SubmissionRelation<br>
>>>  from patchwork.models import Person<br>
>>>  from patchwork.models import Project<br>
>>>  from patchwork.models import Series<br>
>>> @@ -347,3 +348,13 @@ def create_covers(count=1, **kwargs):<br>
>>>          kwargs (dict): Overrides for various cover letter fields<br>
>>>      """<br>
>>>      return _create_submissions(create_cover, count, **kwargs)<br>
>>> +<br>
>>> +<br>
>>> +def create_relation(count_patches=2, **kwargs):<br>
>>> +    relation = SubmissionRelation.objects.create()<br>
>>> +    values = {<br>
>>> +        'related': relation<br>
>>> +    }<br>
>>> +    values.update(kwargs)<br>
>>> +    create_patches(count_patches, **values)<br>
>>> +    return relation<br>
>> <br>
>> <br>
>> I haven't looked at the tests in great detail, but they look very<br>
>> comprehensive. You can get coverage data out of tox with -e coverage, so<br>
>> just check that you've covered a reasonably amount of the new code<br>
>> you're adding. (It doesn't necessarily have to be 100%.)<br>
>> <br>
>> <br>
>> Lastly, you should probably also add a field on Patch and CoverLetter<br>
>> that links to the related submissions if they exist. Otherwise I think<br>
>> you have to enumerate all the relations in the API to determine if there<br>
>> is one for the patch/cover letter that you're interested in?<br>
><br>
> I am not entirely sure but I will have a closer look at this.<br>
><br>
>> <br>
>> Feel free to respin after you address these comments. Keep up the good<br>
>> work!<br>
>> <br>
>> Regards,<br>
>> Daniel<br>
>><br>
><br>
> Thank you for reviewing!<br>
><br>
> Best regards,<br>
><br>
> Mete<br>
</blockquote></div></div>