<div><br></div><div><br><div class="gmail_quote"><div dir="auto">On Mo., 30. Dez. 2019 at 11:41, Mete Polat <<a href="mailto:metepolat2000@gmail.com">metepolat2000@gmail.com</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">Hi Stephen,<br>
<br>
On 27.12.19 18:48, Stephen Finucane wrote:<br>
> On Sat, 2019-12-07 at 17:46 +0100, Mete Polat wrote:<br>
>> View relations and 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>
>> Co-authored-by: Daniel Axtens <<a href="mailto:dja@axtens.net" target="_blank">dja@axtens.net</a>><br>
>> Signed-off-by: Mete Polat <<a href="mailto:metepolat2000@gmail.com" target="_blank">metepolat2000@gmail.com</a>><br>
> <br>
> Why did you choose to expose this as a separate API rather than as a<br>
> field on the '/patches' resource? While a 'Series' objet has enough<br>
> separate metadata to warrant a separate '/series' resource, a<br>
> 'SubmissionRelation' object is basically just a container. Including a<br>
> 'related_patches' field on the detailed patch view would seem like more<br>
> than enough detail for me, anyway, and unless there's a reason not to<br>
> do this, I'd like to see it done that way. Is it possible?<br>
> <br>
<br>
The first version of the series supported bulk creating/updating of<br>
relations which was only possible by moving relations into their own url<br>
[1]. As we deciced against bulk operations, I aggree that exposing a<br>
related_patches field is the better choice now.<br>
</blockquote><div dir="auto"><br></div><div dir="auto"><br></div><div dir="auto">Mete, Stephen's proposal here is a simple quick refactoring of exposing this API, right?</div><div dir="auto">Could we get that change as a quick small v5 patch series for v2.2.0 ready?</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>
Best regards,<br>
<br>
Mete<br>
<br>
[1] Or allow bulk operations on /api/patch/ in general.<br>
<br>
> Stephen<br>
> <br>
> PS: I could have sworn I had asked this before, but I can't find any<br>
> mails about it so maybe I didn't. Please tell me to RTML (read the<br>
> mailing list) if so<br>
> <br>
>> ---<br>
>> Optimize db queries:<br>
>>   I have spent quite a lot of time in optimizing the db queries for the REST API<br>
>>   (thanks for the tip with the Django toolbar). Daniel stated that<br>
>>   prefetch_related is possibly hitting the database for every relation when<br>
>>   prefetching submissions but it turns out that we can tell Django to use a<br>
>>   statement like:<br>
>>       SELECT *<br>
>>       FROM `patchwork_patch`<br>
>>      INNER JOIN `patchwork_submission`<br>
>>         ON (`patchwork_patch`.`submission_ptr_id` = `patchwork_submission`.`id`)<br>
>>      WHERE `patchwork_patch`.`submission_ptr_id` IN (LIST_OF_ALL_SUBMISSION_IDS)<br>
>><br>
>>    We do the same for `patchwork_coverletter`.<br>
>>    This means we only hit the db two times for casting _all_ submissions to a<br>
>>    patch or cover-letter.<br>
>><br>
>>    Prefetching submissions__project eliminates similar and duplicate queries<br>
>>    that are used to determine whether a logged in user is at least maintainer<br>
>>    of one submission's project.<br>
>><br>
>>  docs/api/schemas/latest/patchwork.yaml        | 273 +++++++++++++++++<br>
>>  docs/api/schemas/patchwork.j2                 | 285 ++++++++++++++++++<br>
>>  docs/api/schemas/v1.2/patchwork.yaml          | 273 +++++++++++++++++<br>
>>  patchwork/api/embedded.py                     |  39 +++<br>
>>  patchwork/api/index.py                        |   1 +<br>
>>  patchwork/api/relation.py                     | 121 ++++++++<br>
>>  patchwork/models.py                           |   6 +<br>
>>  patchwork/tests/api/test_relation.py          | 181 +++++++++++<br>
>>  patchwork/tests/utils.py                      |  15 +<br>
>>  patchwork/urls.py                             |  11 +<br>
>>  ...submission-relations-c96bb6c567b416d8.yaml |  10 +<br>
>>  11 files changed, 1215 insertions(+)<br>
>>  create mode 100644 patchwork/api/relation.py<br>
>>  create mode 100644 patchwork/tests/api/test_relation.py<br>
>>  create mode 100644 releasenotes/notes/add-submission-relations-c96bb6c567b416d8.yaml<br>
>><br>
>> diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml<br>
>> index a5e235be936d..7dd24fd700d5 100644<br>
>> --- a/docs/api/schemas/latest/patchwork.yaml<br>
>> +++ b/docs/api/schemas/latest/patchwork.yaml<br>
>> @@ -1039,6 +1039,188 @@ paths:<br>
>>                  $ref: '#/components/schemas/Error'<br>
>>        tags:<br>
>>          - series<br>
>> +  /api/relations/:<br>
>> +    get:<br>
>> +      description: List relations.<br>
>> +      operationId: relations_list<br>
>> +      parameters:<br>
>> +        - $ref: '#/components/parameters/Page'<br>
>> +        - $ref: '#/components/parameters/PageSize'<br>
>> +        - $ref: '#/components/parameters/Order'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          headers:<br>
>> +            Link:<br>
>> +              $ref: '#/components/headers/Link'<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                type: array<br>
>> +                items:<br>
>> +                  $ref: '#/components/schemas/Relation'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    post:<br>
>> +      description: Create a relation.<br>
>> +      operationId: relations_create<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '201':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Invalid Request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - checks<br>
>> +  /api/relations/{id}/:<br>
>> +    get:<br>
>> +      description: Show a relation.<br>
>> +      operationId: relation_read<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    patch:<br>
>> +      description: Update a relation (partial).<br>
>> +      operationId: relations_partial_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    put:<br>
>> +      description: Update a relation.<br>
>> +      operationId: relations_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>>    /api/users/:<br>
>>      get:<br>
>>        description: List users.<br>
>> @@ -1314,6 +1496,18 @@ components:<br>
>>          application/x-www-form-urlencoded:<br>
>>            schema:<br>
>>              $ref: '#/components/schemas/User'<br>
>> +    Relation:<br>
>> +      required: true<br>
>> +      content:<br>
>> +        application/json:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        multipart/form-data:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        application/x-www-form-urlencoded:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>>    schemas:<br>
>>      Index:<br>
>>        type: object<br>
>> @@ -1358,6 +1552,11 @@ components:<br>
>>            type: string<br>
>>            format: uri<br>
>>            readOnly: true<br>
>> +        relations:<br>
>> +          title: Relations URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>>      Bundle:<br>
>>        required:<br>
>>          - name<br>
>> @@ -1943,6 +2142,14 @@ components:<br>
>>            title: Delegate<br>
>>            type: integer<br>
>>            nullable: true<br>
>> +    RelationUpdate:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        submissions:<br>
>> +          title: Submission IDs<br>
>> +          type: array<br>
>> +          items:<br>
>> +            type: integer<br>
>>      Person:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2133,6 +2340,30 @@ components:<br>
>>              $ref: '#/components/schemas/PatchEmbedded'<br>
>>            readOnly: true<br>
>>            uniqueItems: true<br>
>> +    Relation:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        by:<br>
>> +          type: object<br>
>> +          title: By<br>
>> +          readOnly: true<br>
>> +          allOf:<br>
>> +            - $ref: '#/components/schemas/UserEmbedded'<br>
>> +        submissions:<br>
>> +          title: Submissions<br>
>> +          type: array<br>
>> +          items:<br>
>> +            $ref: '#/components/schemas/SubmissionEmbedded'<br>
>> +          readOnly: true<br>
>> +          uniqueItems: true<br>
>>      User:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2211,6 +2442,48 @@ components:<br>
>>            maxLength: 255<br>
>>            minLength: 1<br>
>>            readOnly: true<br>
>> +    SubmissionEmbedded:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +          readOnly: true<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        web_url:<br>
>> +          title: Web URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        msgid:<br>
>> +          title: Message ID<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        list_archive_url:<br>
>> +          title: List archive URL<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          nullable: true<br>
>> +        date:<br>
>> +          title: Date<br>
>> +          type: string<br>
>> +          format: iso8601<br>
>> +          readOnly: true<br>
>> +        name:<br>
>> +          title: Name<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        mbox:<br>
>> +          title: Mbox<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>>      CoverLetterEmbedded:<br>
>>        type: object<br>
>>        properties:<br>
>> diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2<br>
>> index 196d78466b55..a034029accf9 100644<br>
>> --- a/docs/api/schemas/patchwork.j2<br>
>> +++ b/docs/api/schemas/patchwork.j2<br>
>> @@ -1048,6 +1048,190 @@ paths:<br>
>>                  $ref: '#/components/schemas/Error'<br>
>>        tags:<br>
>>          - series<br>
>> +{% if version >= (1, 2) %}<br>
>> +  /api/{{ version_url }}relations/:<br>
>> +    get:<br>
>> +      description: List relations.<br>
>> +      operationId: relations_list<br>
>> +      parameters:<br>
>> +        - $ref: '#/components/parameters/Page'<br>
>> +        - $ref: '#/components/parameters/PageSize'<br>
>> +        - $ref: '#/components/parameters/Order'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          headers:<br>
>> +            Link:<br>
>> +              $ref: '#/components/headers/Link'<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                type: array<br>
>> +                items:<br>
>> +                  $ref: '#/components/schemas/Relation'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    post:<br>
>> +      description: Create a relation.<br>
>> +      operationId: relations_create<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '201':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Invalid Request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - checks<br>
>> +  /api/{{ version_url }}relations/{id}/:<br>
>> +    get:<br>
>> +      description: Show a relation.<br>
>> +      operationId: relation_read<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    patch:<br>
>> +      description: Update a relation (partial).<br>
>> +      operationId: relations_partial_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    put:<br>
>> +      description: Update a relation.<br>
>> +      operationId: relations_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +{% endif %}<br>
>>    /api/{{ version_url }}users/:<br>
>>      get:<br>
>>        description: List users.<br>
>> @@ -1325,6 +1509,20 @@ components:<br>
>>          application/x-www-form-urlencoded:<br>
>>            schema:<br>
>>              $ref: '#/components/schemas/User'<br>
>> +{% if version >= (1, 2) %}<br>
>> +    Relation:<br>
>> +      required: true<br>
>> +      content:<br>
>> +        application/json:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        multipart/form-data:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        application/x-www-form-urlencoded:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +{% endif %}<br>
>>    schemas:<br>
>>      Index:<br>
>>        type: object<br>
>> @@ -1369,6 +1567,13 @@ components:<br>
>>            type: string<br>
>>            format: uri<br>
>>            readOnly: true<br>
>> +{% if version >= (1, 2) %}<br>
>> +        relations:<br>
>> +          title: Relations URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +{% endif %}<br>
>>      Bundle:<br>
>>        required:<br>
>>          - name<br>
>> @@ -1981,6 +2186,16 @@ components:<br>
>>            title: Delegate<br>
>>            type: integer<br>
>>            nullable: true<br>
>> +{% if version >= (1, 2) %}<br>
>> +    RelationUpdate:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        submissions:<br>
>> +          title: Submission IDs<br>
>> +          type: array<br>
>> +          items:<br>
>> +            type: integer<br>
>> +{% endif %}<br>
>>      Person:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2177,6 +2392,32 @@ components:<br>
>>              $ref: '#/components/schemas/PatchEmbedded'<br>
>>            readOnly: true<br>
>>            uniqueItems: true<br>
>> +{% if version >= (1, 2) %}<br>
>> +    Relation:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        by:<br>
>> +          type: object<br>
>> +          title: By<br>
>> +          readOnly: true<br>
>> +          allOf:<br>
>> +            - $ref: '#/components/schemas/UserEmbedded'<br>
>> +        submissions:<br>
>> +          title: Submissions<br>
>> +          type: array<br>
>> +          items:<br>
>> +            $ref: '#/components/schemas/SubmissionEmbedded'<br>
>> +          readOnly: true<br>
>> +          uniqueItems: true<br>
>> +{% endif %}<br>
>>      User:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2255,6 +2496,50 @@ components:<br>
>>            maxLength: 255<br>
>>            minLength: 1<br>
>>            readOnly: true<br>
>> +{% if version >= (1, 2) %}<br>
>> +    SubmissionEmbedded:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +          readOnly: true<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        web_url:<br>
>> +          title: Web URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        msgid:<br>
>> +          title: Message ID<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        list_archive_url:<br>
>> +          title: List archive URL<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          nullable: true<br>
>> +        date:<br>
>> +          title: Date<br>
>> +          type: string<br>
>> +          format: iso8601<br>
>> +          readOnly: true<br>
>> +        name:<br>
>> +          title: Name<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        mbox:<br>
>> +          title: Mbox<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +{% endif %}<br>
>>      CoverLetterEmbedded:<br>
>>        type: object<br>
>>        properties:<br>
>> diff --git a/docs/api/schemas/v1.2/patchwork.yaml b/docs/api/schemas/v1.2/patchwork.yaml<br>
>> index d7b4d2957cff..99425e968881 100644<br>
>> --- a/docs/api/schemas/v1.2/patchwork.yaml<br>
>> +++ b/docs/api/schemas/v1.2/patchwork.yaml<br>
>> @@ -1039,6 +1039,188 @@ paths:<br>
>>                  $ref: '#/components/schemas/Error'<br>
>>        tags:<br>
>>          - series<br>
>> +  /api/1.2/relations/:<br>
>> +    get:<br>
>> +      description: List relations.<br>
>> +      operationId: relations_list<br>
>> +      parameters:<br>
>> +        - $ref: '#/components/parameters/Page'<br>
>> +        - $ref: '#/components/parameters/PageSize'<br>
>> +        - $ref: '#/components/parameters/Order'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          headers:<br>
>> +            Link:<br>
>> +              $ref: '#/components/headers/Link'<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                type: array<br>
>> +                items:<br>
>> +                  $ref: '#/components/schemas/Relation'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    post:<br>
>> +      description: Create a relation.<br>
>> +      operationId: relations_create<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '201':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Invalid Request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - checks<br>
>> +  /api/1.2/relations/{id}/:<br>
>> +    get:<br>
>> +      description: Show a relation.<br>
>> +      operationId: relation_read<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '409':<br>
>> +          description: Conflict<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    patch:<br>
>> +      description: Update a relation (partial).<br>
>> +      operationId: relations_partial_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>> +    put:<br>
>> +      description: Update a relation.<br>
>> +      operationId: relations_update<br>
>> +      security:<br>
>> +        - basicAuth: []<br>
>> +        - apiKeyAuth: []<br>
>> +      parameters:<br>
>> +        - in: path<br>
>> +          name: id<br>
>> +          description: A unique integer value identifying this relation.<br>
>> +          required: true<br>
>> +          schema:<br>
>> +            title: ID<br>
>> +            type: integer<br>
>> +      requestBody:<br>
>> +        $ref: '#/components/requestBodies/Relation'<br>
>> +      responses:<br>
>> +        '200':<br>
>> +          description: ''<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Relation'<br>
>> +        '400':<br>
>> +          description: Bad request<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '403':<br>
>> +          description: Forbidden<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +        '404':<br>
>> +          description: Not found<br>
>> +          content:<br>
>> +            application/json:<br>
>> +              schema:<br>
>> +                $ref: '#/components/schemas/Error'<br>
>> +      tags:<br>
>> +        - relations<br>
>>    /api/1.2/users/:<br>
>>      get:<br>
>>        description: List users.<br>
>> @@ -1314,6 +1496,18 @@ components:<br>
>>          application/x-www-form-urlencoded:<br>
>>            schema:<br>
>>              $ref: '#/components/schemas/User'<br>
>> +    Relation:<br>
>> +      required: true<br>
>> +      content:<br>
>> +        application/json:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        multipart/form-data:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>> +        application/x-www-form-urlencoded:<br>
>> +          schema:<br>
>> +            $ref: '#/components/schemas/RelationUpdate'<br>
>>    schemas:<br>
>>      Index:<br>
>>        type: object<br>
>> @@ -1358,6 +1552,11 @@ components:<br>
>>            type: string<br>
>>            format: uri<br>
>>            readOnly: true<br>
>> +        relations:<br>
>> +          title: Relations URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>>      Bundle:<br>
>>        required:<br>
>>          - name<br>
>> @@ -1943,6 +2142,14 @@ components:<br>
>>            title: Delegate<br>
>>            type: integer<br>
>>            nullable: true<br>
>> +    RelationUpdate:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        submissions:<br>
>> +          title: Submission IDs<br>
>> +          type: array<br>
>> +          items:<br>
>> +            type: integer<br>
>>      Person:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2133,6 +2340,30 @@ components:<br>
>>              $ref: '#/components/schemas/PatchEmbedded'<br>
>>            readOnly: true<br>
>>            uniqueItems: true<br>
>> +    Relation:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        by:<br>
>> +          type: object<br>
>> +          title: By<br>
>> +          readOnly: true<br>
>> +          allOf:<br>
>> +            - $ref: '#/components/schemas/UserEmbedded'<br>
>> +        submissions:<br>
>> +          title: Submissions<br>
>> +          type: array<br>
>> +          items:<br>
>> +            $ref: '#/components/schemas/SubmissionEmbedded'<br>
>> +          readOnly: true<br>
>> +          uniqueItems: true<br>
>>      User:<br>
>>        type: object<br>
>>        properties:<br>
>> @@ -2211,6 +2442,48 @@ components:<br>
>>            maxLength: 255<br>
>>            minLength: 1<br>
>>            readOnly: true<br>
>> +    SubmissionEmbedded:<br>
>> +      type: object<br>
>> +      properties:<br>
>> +        id:<br>
>> +          title: ID<br>
>> +          type: integer<br>
>> +          readOnly: true<br>
>> +        url:<br>
>> +          title: URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        web_url:<br>
>> +          title: Web URL<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>> +        msgid:<br>
>> +          title: Message ID<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        list_archive_url:<br>
>> +          title: List archive URL<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          nullable: true<br>
>> +        date:<br>
>> +          title: Date<br>
>> +          type: string<br>
>> +          format: iso8601<br>
>> +          readOnly: true<br>
>> +        name:<br>
>> +          title: Name<br>
>> +          type: string<br>
>> +          readOnly: true<br>
>> +          minLength: 1<br>
>> +        mbox:<br>
>> +          title: Mbox<br>
>> +          type: string<br>
>> +          format: uri<br>
>> +          readOnly: true<br>
>>      CoverLetterEmbedded:<br>
>>        type: object<br>
>>        properties:<br>
>> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py<br>
>> index de4f31165ee7..0fba291b62b8 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_api_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>
>> +        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/index.py b/patchwork/api/index.py<br>
>> index 45485c9106f6..cf1845393835 100644<br>
>> --- a/patchwork/api/index.py<br>
>> +++ b/patchwork/api/index.py<br>
>> @@ -21,4 +21,5 @@ class IndexView(APIView):<br>
>>              'series': reverse('api-series-list', request=request),<br>
>>              'events': reverse('api-event-list', request=request),<br>
>>              'bundles': reverse('api-bundle-list', request=request),<br>
>> +            'relations': reverse('api-relation-list', request=request),<br>
>>          })<br>
>> diff --git a/patchwork/api/relation.py b/patchwork/api/relation.py<br>
>> new file mode 100644<br>
>> index 000000000000..37640d62e9cc<br>
>> --- /dev/null<br>
>> +++ b/patchwork/api/relation.py<br>
>> @@ -0,0 +1,121 @@<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 rest_framework import permissions<br>
>> +from rest_framework import status<br>
>> +from rest_framework.exceptions import PermissionDenied, APIException<br>
>> +from rest_framework.generics import GenericAPIView<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.api.base import PatchworkPermission<br>
>> +from patchwork.api.embedded import SubmissionSerializer<br>
>> +from patchwork.api.embedded import UserSerializer<br>
>> +from patchwork.models import SubmissionRelation<br>
>> +<br>
>> +<br>
>> +class MaintainerPermission(PatchworkPermission):<br>
>> +<br>
>> +    def has_permission(self, request, view):<br>
>> +        if request.method in permissions.SAFE_METHODS:<br>
>> +            return True<br>
>> +<br>
>> +        # Prevent showing an HTML POST form in the browseable API for logged in<br>
>> +        # users who are not maintainers.<br>
>> +        return len(request.user.maintains) > 0<br>
>> +<br>
>> +    def has_object_permission(self, request, view, relation):<br>
>> +        if request.method in permissions.SAFE_METHODS:<br>
>> +            return True<br>
>> +<br>
>> +        maintains = request.user.maintains<br>
>> +        submissions = relation.submissions.all()<br>
>> +        # user has to be maintainer of every project a submission is part of<br>
>> +        return self.check_user_maintains_all(maintains, submissions)<br>
>> +<br>
>> +    @staticmethod<br>
>> +    def check_user_maintains_all(maintains, submissions):<br>
>> +        if any(s.project not in maintains for s in submissions):<br>
>> +            detail = 'At least one submission is part of a project you are ' \<br>
>> +                     'not maintaining.'<br>
>> +            raise PermissionDenied(detail=detail)<br>
>> +        return True<br>
>> +<br>
>> +<br>
>> +class SubmissionConflict(APIException):<br>
>> +    status_code = status.HTTP_409_CONFLICT<br>
>> +    default_detail = 'At least one submission is already part of another ' \<br>
>> +                     'relation. You have to explicitly remove a submission ' \<br>
>> +                     'from its existing relation before moving it to this one.'<br>
>> +<br>
>> +<br>
>> +class SubmissionRelationSerializer(ModelSerializer):<br>
>> +    by = UserSerializer(read_only=True)<br>
>> +    submissions = SubmissionSerializer(many=True)<br>
>> +<br>
>> +    def create(self, validated_data):<br>
>> +        submissions = validated_data['submissions']<br>
>> +        if any(submission.related_id is not None<br>
>> +               for submission in submissions):<br>
>> +            raise SubmissionConflict()<br>
>> +        return super(SubmissionRelationSerializer, self).create(validated_data)<br>
>> +<br>
>> +    def update(self, instance, validated_data):<br>
>> +        submissions = validated_data['submissions']<br>
>> +        if any(submission.related_id is not None and<br>
>> +               submission.related_id != <a href="http://instance.id" rel="noreferrer" target="_blank">instance.id</a><br>
>> +               for submission in submissions):<br>
>> +            raise SubmissionConflict()<br>
>> +        return super(SubmissionRelationSerializer, self) \<br>
>> +            .update(instance, validated_data)<br>
>> +<br>
>> +    class Meta:<br>
>> +        model = SubmissionRelation<br>
>> +        fields = ('id', 'url', 'by', 'submissions',)<br>
>> +        read_only_fields = ('url', 'by', )<br>
>> +        extra_kwargs = {<br>
>> +            'url': {'view_name': 'api-relation-detail'},<br>
>> +        }<br>
>> +<br>
>> +<br>
>> +class SubmissionRelationMixin(GenericAPIView):<br>
>> +    serializer_class = SubmissionRelationSerializer<br>
>> +    permission_classes = (MaintainerPermission,)<br>
>> +<br>
>> +    def initial(self, request, *args, **kwargs):<br>
>> +        user = request.user<br>
>> +        if not hasattr(user, 'maintains'):<br>
>> +            if user.is_authenticated:<br>
>> +                user.maintains = user.profile.maintainer_projects.all()<br>
>> +            else:<br>
>> +                user.maintains = []<br>
>> +        super(SubmissionRelationMixin, self).initial(request, *args, **kwargs)<br>
>> +<br>
>> +    def get_queryset(self):<br>
>> +        return SubmissionRelation.objects.all() \<br>
>> +            .select_related('by') \<br>
>> +            .prefetch_related('submissions__patch',<br>
>> +                              'submissions__coverletter',<br>
>> +                              'submissions__project')<br>
>> +<br>
>> +<br>
>> +class SubmissionRelationList(SubmissionRelationMixin, ListCreateAPIView):<br>
>> +    ordering = 'id'<br>
>> +    ordering_fields = ['id']<br>
>> +<br>
>> +    def perform_create(self, serializer):<br>
>> +        # has_object_permission() is not called when creating a new relation.<br>
>> +        # Check whether user is maintainer of every project a submission is<br>
>> +        # part of<br>
>> +        maintains = self.request.user.maintains<br>
>> +        submissions = serializer.validated_data['submissions']<br>
>> +        MaintainerPermission.check_user_maintains_all(maintains, submissions)<br>
>> +        serializer.save(by=self.request.user)<br>
>> +<br>
>> +<br>
>> +class SubmissionRelationDetail(SubmissionRelationMixin,<br>
>> +                               RetrieveUpdateDestroyAPIView):<br>
>> +    pass<br>
>> diff --git a/patchwork/models.py b/patchwork/models.py<br>
>> index a92203b24ff2..9ae3370e896b 100644<br>
>> --- a/patchwork/models.py<br>
>> +++ b/patchwork/models.py<br>
>> @@ -415,6 +415,9 @@ class CoverLetter(Submission):<br>
>>                         kwargs={'project_id': self.project.linkname,<br>
>>                                 'msgid': self.url_msgid})<br>
>>  <br>
>> +    def get_absolute_api_url(self):<br>
>> +        return reverse('api-cover-detail', kwargs={'pk': <a href="http://self.id" rel="noreferrer" target="_blank">self.id</a>})<br>
>> +<br>
>>      def get_mbox_url(self):<br>
>>          return reverse('cover-mbox',<br>
>>                         kwargs={'project_id': self.project.linkname,<br>
>> @@ -604,6 +607,9 @@ class Patch(Submission):<br>
>>                         kwargs={'project_id': self.project.linkname,<br>
>>                                 'msgid': self.url_msgid})<br>
>>  <br>
>> +    def get_absolute_api_url(self):<br>
>> +        return reverse('api-patch-detail', kwargs={'pk': <a href="http://self.id" rel="noreferrer" target="_blank">self.id</a>})<br>
>> +<br>
>>      def get_mbox_url(self):<br>
>>          return reverse('patch-mbox',<br>
>>                         kwargs={'project_id': self.project.linkname,<br>
>> diff --git a/patchwork/tests/api/test_relation.py b/patchwork/tests/api/test_relation.py<br>
>> new file mode 100644<br>
>> index 000000000000..5b1a04f13670<br>
>> --- /dev/null<br>
>> +++ b/patchwork/tests/api/test_relation.py<br>
>> @@ -0,0 +1,181 @@<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>
>> +<br>
>> +import six<br>
>> +from django.conf import settings<br>
>> +from django.urls import reverse<br>
>> +<br>
>> +from patchwork.tests.api import utils<br>
>> +from patchwork.tests.utils import create_cover<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:<br>
>> +    ANONYMOUS = 1<br>
>> +    NON_MAINTAINER = 2<br>
>> +    MAINTAINER = 3<br>
>> +<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):<br>
>> +        """Assert post/delete/patch requests on the relation API."""<br>
>> +        assert method in ['post', 'delete', 'patch']<br>
>> +<br>
>> +        # setup<br>
>> +<br>
>> +        project = create_project()<br>
>> +        maintainer = create_maintainer(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>
>> +            self.client.force_authenticate(user=maintainer)<br>
>> +        else:<br>
>> +            raise ValueError<br>
>> +<br>
>> +        resource_id = None<br>
>> +        req = None<br>
>> +<br>
>> +        if method == 'delete':<br>
>> +            resource_id = create_relation(project=project, by=maintainer).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>
>> +            req = {'submissions': patch_ids}<br>
>> +        elif method == 'patch':<br>
>> +            resource_id = create_relation(project=project, by=maintainer).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>
>> +            req = {'submissions': patch_ids}<br>
>> +        else:<br>
>> +            raise ValueError<br>
>> +<br>
>> +        # request<br>
>> +<br>
>> +        resp = getattr(self.client, method)(self.api_url(resource_id), req)<br>
>> +<br>
>> +        # check<br>
>> +<br>
>> +        self.assertEqual(expected_status, resp.status_code)<br>
>> +<br>
>> +        if resp.status_code in range(status.HTTP_200_OK,<br>
>> +                                     status.HTTP_204_NO_CONTENT):<br>
>> +            self.assertRequest(req, resp.data)<br>
>> +<br>
>> +    def assertRequest(self, request, resp):<br>
>> +        if request.get('id'):<br>
>> +            self.assertEqual(request['id'], resp['id'])<br>
>> +        send_ids = request['submissions']<br>
>> +        resp_ids = [s['id'] for s in resp['submissions']]<br>
>> +        six.assertCountEqual(self, resp_ids, send_ids)<br>
>> +<br>
>> +    def assertSerialized(self, obj, resp):<br>
>> +        self.assertEqual(<a href="http://obj.id" rel="noreferrer" target="_blank">obj.id</a>, resp['id'])<br>
>> +        exp_ids = [<a href="http://s.id" rel="noreferrer" target="_blank">s.id</a> for s in obj.submissions.all()]<br>
>> +        act_ids = [s['id'] for s in resp['submissions']]<br>
>> +        six.assertCountEqual(self, exp_ids, act_ids)<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-create-error-forbidden')<br>
>> +    def test_create_anonymous(self):<br>
>> +        self.request_restricted('post', UserType.ANONYMOUS)<br>
>> +<br>
>> +    def test_create_non_maintainer(self):<br>
>> +        self.request_restricted('post', UserType.NON_MAINTAINER)<br>
>> +<br>
>> +    @utils.store_samples('relation-create')<br>
>> +    def test_create_maintainer(self):<br>
>> +        self.request_restricted('post', UserType.MAINTAINER)<br>
>> +<br>
>> +    @utils.store_samples('relation-update-error-forbidden')<br>
>> +    def test_update_anonymous(self):<br>
>> +        self.request_restricted('patch', UserType.ANONYMOUS)<br>
>> +<br>
>> +    def test_update_non_maintainer(self):<br>
>> +        self.request_restricted('patch', UserType.NON_MAINTAINER)<br>
>> +<br>
>> +    @utils.store_samples('relation-update')<br>
>> +    def test_update_maintainer(self):<br>
>> +        self.request_restricted('patch', UserType.MAINTAINER)<br>
>> +<br>
>> +    @utils.store_samples('relation-delete-error-forbidden')<br>
>> +    def test_delete_anonymous(self):<br>
>> +        self.request_restricted('delete', UserType.ANONYMOUS)<br>
>> +<br>
>> +    def test_delete_non_maintainer(self):<br>
>> +        self.request_restricted('delete', UserType.NON_MAINTAINER)<br>
>> +<br>
>> +    @utils.store_samples('relation-update')<br>
>> +    def test_delete_maintainer(self):<br>
>> +        self.request_restricted('delete', UserType.MAINTAINER)<br>
>> +<br>
>> +    def test_submission_conflict(self):<br>
>> +        project = create_project()<br>
>> +        maintainer = create_maintainer(project)<br>
>> +        self.client.force_authenticate(user=maintainer)<br>
>> +        relation = create_relation(by=maintainer, project=project)<br>
>> +        submission_ids = [<a href="http://s.id" rel="noreferrer" target="_blank">s.id</a> for s in relation.submissions.all()]<br>
>> +<br>
>> +        # try to create a new relation with a new submission (cover) and<br>
>> +        # submissions already bound to another relation<br>
>> +        cover = create_cover(project=project)<br>
>> +        submission_ids.append(<a href="http://cover.id" rel="noreferrer" target="_blank">cover.id</a>)<br>
>> +        req = {'submissions': submission_ids}<br>
>> +        resp = self.client.post(self.api_url(), req)<br>
>> +        self.assertEqual(status.HTTP_409_CONFLICT, resp.status_code)<br>
>> +<br>
>> +        # try to patch relation<br>
>> +        resp = self.client.patch(self.api_url(<a href="http://relation.id" rel="noreferrer" target="_blank">relation.id</a>), req)<br>
>> +        self.assertEqual(status.HTTP_200_OK, resp.status_code)<br>
>> diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py<br>
>> index 577183d0986c..ffe90976233e 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,17 @@ 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, by=None, **kwargs):<br>
>> +    if not by:<br>
>> +        project = create_project()<br>
>> +        kwargs['project'] = project<br>
>> +        by = create_maintainer(project)<br>
>> +    relation = SubmissionRelation.objects.create(by=by)<br>
>> +    values = {<br>
>> +        'related': relation<br>
>> +    }<br>
>> +    values.update(kwargs)<br>
>> +    create_patches(count_patches, **values)<br>
>> +    return relation<br>
>> diff --git a/patchwork/urls.py b/patchwork/urls.py<br>
>> index dcdcfb49e67e..92095f62c7b9 100644<br>
>> --- a/patchwork/urls.py<br>
>> +++ b/patchwork/urls.py<br>
>> @@ -187,6 +187,7 @@ if settings.ENABLE_REST_API:<br>
>>      from patchwork.api import patch as api_patch_views  # noqa<br>
>>      from patchwork.api import person as api_person_views  # noqa<br>
>>      from patchwork.api import project as api_project_views  # noqa<br>
>> +    from patchwork.api import relation as api_relation_views  # noqa<br>
>>      from patchwork.api import series as api_series_views  # noqa<br>
>>      from patchwork.api import user as api_user_views  # noqa<br>
>>  <br>
>> @@ -256,9 +257,19 @@ if settings.ENABLE_REST_API:<br>
>>              name='api-cover-comment-list'),<br>
>>      ]<br>
>>  <br>
>> +  </blockquote></div></div>