Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle when ReadOnlyField has nullable source #1307

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions drf_spectacular/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,11 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False):
return append_meta(build_basic_type(OpenApiTypes.STR), meta)

target = follow_field_source(model, source)

# Whether a ReadOnlyField might be null is only known after following its source
if hasattr(target, 'null') and target.null:
meta['nullable'] = True

if (
hasattr(target, "_partialmethod")
and target._partialmethod.func.__name__ == '_get_FIELD_display'
Expand Down
12 changes: 8 additions & 4 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import collections
import copy
import functools
import hashlib
import inspect
Expand Down Expand Up @@ -566,7 +567,7 @@ def append_meta(schema: _SchemaType, meta: _SchemaType) -> _SchemaType:
return safe_ref({**schema, **meta})


def _follow_field_source(model, path: List[str]):
def _follow_field_source(model, path: List[str], null: bool = False):
"""
navigate through root model via given navigation path. supports forward/reverse relations.
"""
Expand Down Expand Up @@ -598,6 +599,8 @@ def _follow_field_source(model, path: List[str]):
# not via the related_name (default: X_set) but the model name.
return field.target_field
else:
field = copy.copy(field)
field.null = null
return field
else:
if (
Expand All @@ -616,10 +619,11 @@ def _follow_field_source(model, path: List[str]):
f'on model {model}. Please add a type hint on the model\'s property/function '
f'to enable traversal of the source path "{".".join(path)}".'
)
return _follow_field_source(target_model, path[1:])
return _follow_field_source(target_model, path[1:], null)
else:
target_model = model._meta.get_field(path[0]).related_model
return _follow_field_source(target_model, path[1:])
field = model._meta.get_field(path[0])
target_model = field.related_model
return _follow_field_source(target_model, path[1:], null=(null or field.null))


def _follow_return_type(a_callable):
Expand Down
37 changes: 37 additions & 0 deletions tests/test_readonlyfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import uuid

from django.db import models
from rest_framework import serializers, viewsets

from tests import assert_schema, generate_schema


class Cubicle(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)


class Employee(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
cubicle = models.ForeignKey(Cubicle, on_delete=models.CASCADE, null=True)


class EmployeeSerializer(serializers.ModelSerializer):

cubicle_id = serializers.ReadOnlyField(source='cubicle.id')

class Meta:
fields = ['id', 'cubicle_id']
model = Employee


class EmployeeModelViewset(viewsets.ModelViewSet):
serializer_class = EmployeeSerializer
queryset = Employee.objects.none()


def test_readonlyfield(no_warnings, django_transforms):
assert_schema(
generate_schema('songs', EmployeeModelViewset),
'tests/test_readonlyfield.yml',
transforms=django_transforms,
)
197 changes: 197 additions & 0 deletions tests/test_readonlyfield.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
openapi: 3.0.3
info:
title: ''
version: 0.0.0
paths:
/songs/:
get:
operationId: songs_list
tags:
- songs
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Employee'
description: ''
post:
operationId: songs_create
tags:
- songs
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Employee'
multipart/form-data:
schema:
$ref: '#/components/schemas/Employee'
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
description: ''
/songs/{id}/:
get:
operationId: songs_retrieve
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this employee.
required: true
tags:
- songs
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
description: ''
put:
operationId: songs_update
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this employee.
required: true
tags:
- songs
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Employee'
multipart/form-data:
schema:
$ref: '#/components/schemas/Employee'
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
description: ''
patch:
operationId: songs_partial_update
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this employee.
required: true
tags:
- songs
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedEmployee'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PatchedEmployee'
multipart/form-data:
schema:
$ref: '#/components/schemas/PatchedEmployee'
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
description: ''
delete:
operationId: songs_destroy
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this employee.
required: true
tags:
- songs
security:
- cookieAuth: []
- basicAuth: []
- {}
responses:
'204':
description: No response body
components:
schemas:
Employee:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
cubicle_id:
type: string
format: uuid
readOnly: true
nullable: true
required:
- cubicle_id
- id
PatchedEmployee:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
cubicle_id:
type: string
format: uuid
readOnly: true
nullable: true
securitySchemes:
basicAuth:
type: http
scheme: basic
cookieAuth:
type: apiKey
in: cookie
name: sessionid
Loading