Skip to content

Commit 88f923f

Browse files
committed
Handle 'lookup_field' containing relationships for path parameters
1 parent 1407059 commit 88f923f

4 files changed

Lines changed: 154 additions & 8 deletions

File tree

drf_spectacular/openapi.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
from drf_spectacular.plumbing import (
2727
ComponentRegistry, ResolvedComponent, UnableToProceedError, append_meta, build_array_type,
2828
build_basic_type, build_choice_field, build_examples_list, build_media_type_object,
29-
build_object_type, build_parameter_type, error, follow_field_source, force_instance, get_doc,
30-
get_type_hints, get_view_model, is_basic_type, is_field, is_list_serializer,
31-
is_patched_serializer, is_serializer, is_trivial_string_variation, resolve_regex_path_parameter,
32-
resolve_type_hint, safe_ref, warn,
29+
build_object_type, build_parameter_type, error, follow_field_source, follow_model_field_lookup,
30+
force_instance, get_doc, get_type_hints, get_view_model, is_basic_type, is_field,
31+
is_list_serializer, is_patched_serializer, is_serializer, is_trivial_string_variation,
32+
resolve_regex_path_parameter, resolve_type_hint, safe_ref, warn,
3333
)
3434
from drf_spectacular.settings import spectacular_settings
3535
from drf_spectacular.types import OpenApiTypes, build_generic_type
@@ -342,22 +342,22 @@ def _resolve_path_parameters(self, variables):
342342
schema = resolved_parameter['schema']
343343
elif get_view_model(self.view) is None:
344344
warn(
345-
f'could not derive type of path parameter "{variable}" because because it '
345+
f'could not derive type of path parameter "{variable}" because it '
346346
f'is untyped and obtaining queryset from the viewset failed. '
347347
f'Consider adding a type to the path (e.g. <int:{variable}>) or annotating '
348348
f'the parameter type with @extend_schema. Defaulting to "string".'
349349
)
350350
else:
351351
try:
352352
model = get_view_model(self.view)
353-
model_field = model._meta.get_field(variable)
353+
model_field = follow_model_field_lookup(model, variable)
354354
schema = self._map_model_field(model_field, direction=None)
355355
if 'description' not in schema and model_field.primary_key:
356356
description = get_pk_description(model, model_field)
357-
except django_exceptions.FieldDoesNotExist:
357+
except django_exceptions.FieldError:
358358
warn(
359359
f'could not derive type of path parameter "{variable}" because '
360-
f'model "{model}" did contain no such field. Consider annotating '
360+
f'model "{model.__module__}.{model.__name__}" contained no such field. Consider annotating '
361361
f'parameter with @extend_schema. Defaulting to "string".'
362362
)
363363

drf_spectacular/plumbing.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
import inflection
1717
import uritemplate
1818
from django.apps import apps
19+
from django.db.models.constants import LOOKUP_SEP
1920
from django.db.models.fields.related_descriptors import (
2021
ForwardManyToOneDescriptor, ManyToManyDescriptor, ReverseManyToOneDescriptor,
2122
ReverseOneToOneDescriptor,
2223
)
2324
from django.db.models.fields.reverse_related import ForeignObjectRel
25+
from django.db.models.sql.query import Query
2426
from django.urls.resolvers import ( # type: ignore
2527
_PATH_PARAMETER_COMPONENT_RE, RegexPattern, Resolver404, RoutePattern, URLPattern, URLResolver,
2628
get_resolver,
@@ -460,6 +462,17 @@ def dummy_property(obj) -> str:
460462
return dummy_property
461463

462464

465+
def follow_model_field_lookup(model, lookup):
466+
"""
467+
Follow a model lookup `foreignkey__foreignkey__field` in the same
468+
way that Django QuerySet.filter() does, returning the final models.Field.
469+
"""
470+
query = Query(model)
471+
lookup_splitted = lookup.split(LOOKUP_SEP)
472+
_, field, _, _ = query.names_to_path(lookup_splitted, query.get_meta())
473+
return field
474+
475+
463476
def alpha_operation_sorter(endpoint):
464477
""" sort endpoints first alphanumerically by path, then by method order """
465478
path, path_regex, method, callback = endpoint

tests/test_regressions.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,127 @@ class XViewset(viewsets.ModelViewSet):
14591459
assert '/x/{related_field}/{id}/' in schema['paths']
14601460

14611461

1462+
def test_path_parameter_with_relationships(no_warnings):
1463+
class PathParamParent(models.Model):
1464+
pass
1465+
1466+
class PathParamChild(models.Model):
1467+
parent = models.ForeignKey(PathParamParent, on_delete=models.CASCADE)
1468+
1469+
class PathParamGrandChild(models.Model):
1470+
parent = models.ForeignKey(PathParamChild, on_delete=models.CASCADE)
1471+
1472+
class PathParamChildSerializer(serializers.ModelSerializer):
1473+
class Meta:
1474+
fields = '__all__'
1475+
model = PathParamChild
1476+
1477+
class XViewset1(viewsets.ModelViewSet):
1478+
serializer_class = PathParamChildSerializer
1479+
queryset = PathParamChild.objects.none()
1480+
lookup_field = 'id'
1481+
1482+
class XViewset2(viewsets.ModelViewSet):
1483+
serializer_class = PathParamChildSerializer
1484+
queryset = PathParamChild.objects.none()
1485+
lookup_field = 'parent'
1486+
1487+
class XViewset3(viewsets.ModelViewSet):
1488+
serializer_class = PathParamChildSerializer
1489+
queryset = PathParamChild.objects.none()
1490+
lookup_field = 'parent__id' # Functionally the same as above
1491+
1492+
class PathParamGrandChildSerializer(serializers.ModelSerializer):
1493+
class Meta:
1494+
fields = '__all__'
1495+
model = PathParamGrandChild
1496+
1497+
class XViewset4(viewsets.ModelViewSet):
1498+
serializer_class = PathParamGrandChildSerializer
1499+
queryset = PathParamGrandChild.objects.none()
1500+
lookup_field = 'parent__parent'
1501+
1502+
class XViewset5(viewsets.ModelViewSet):
1503+
serializer_class = PathParamGrandChildSerializer
1504+
queryset = PathParamGrandChild.objects.none()
1505+
lookup_field = 'parent__parent__id'
1506+
1507+
router = routers.SimpleRouter()
1508+
router.register('child_by_id', XViewset1)
1509+
router.register('child_by_parent_id', XViewset2)
1510+
router.register('child_by_parent_id_alt', XViewset3)
1511+
router.register('grand_child_by_grand_parent_id', XViewset4)
1512+
router.register('grand_child_by_grand_parent_id_alt', XViewset5)
1513+
1514+
schema = generate_schema(None, patterns=router.urls)
1515+
1516+
# Basic cases:
1517+
assert schema['paths']['/child_by_id/{id}/']['get']['parameters'][0] == {
1518+
'description': 'A unique integer value identifying this path param child.',
1519+
'in': 'path',
1520+
'name': 'id',
1521+
'schema': {'type': 'integer'},
1522+
'required': True
1523+
}
1524+
assert schema['paths']['/child_by_parent_id/{parent}/']['get']['parameters'][0] == {
1525+
'in': 'path',
1526+
'name': 'parent',
1527+
'schema': {'type': 'integer'},
1528+
'required': True
1529+
}
1530+
1531+
# Can we traverse relationships?
1532+
assert schema['paths']['/grand_child_by_grand_parent_id/{parent__parent}/']['get']['parameters'][0] == {
1533+
'in': 'path',
1534+
'name': 'parent__parent',
1535+
'schema': {'type': 'integer'},
1536+
'required': True
1537+
}
1538+
1539+
# Explicit `__id` handling:
1540+
assert schema['paths']['/grand_child_by_grand_parent_id_alt/{parent__parent__id}/']['get']['parameters'][0] == {
1541+
'description': 'A unique integer value identifying this path param grand child.',
1542+
'in': 'path',
1543+
'name': 'parent__parent__id',
1544+
'schema': {'type': 'integer'},
1545+
'required': True
1546+
}
1547+
assert schema['paths']['/child_by_parent_id_alt/{parent__id}/']['get']['parameters'][0] == {
1548+
'description': 'A unique integer value identifying this path param child.',
1549+
'in': 'path',
1550+
'name': 'parent__id',
1551+
'schema': {'type': 'integer'},
1552+
'required': True
1553+
}
1554+
1555+
1556+
def test_path_parameter_with_lookups(no_warnings):
1557+
class JournalEntry(models.Model):
1558+
recorded_at = models.DateTimeField()
1559+
1560+
class JournalEntrySerializer(serializers.ModelSerializer):
1561+
class Meta:
1562+
fields = '__all__'
1563+
model = JournalEntry
1564+
1565+
class JournalEntryViewset(viewsets.ModelViewSet):
1566+
serializer_class = JournalEntrySerializer
1567+
queryset = JournalEntry.objects.none()
1568+
lookup_field = 'recorded_at__date'
1569+
1570+
router = routers.SimpleRouter()
1571+
router.register('journal', JournalEntryViewset)
1572+
1573+
schema = generate_schema(None, patterns=router.urls)
1574+
1575+
assert schema['paths']['/journal/{recorded_at__date}/']['get']['parameters'][0] == {
1576+
'in': 'path',
1577+
'name': 'recorded_at__date',
1578+
'required': True,
1579+
'schema': {'format': 'date-time', 'type': 'string'},
1580+
}
1581+
1582+
14621583
@pytest.mark.contrib('psycopg2')
14631584
def test_multiple_choice_enum(no_warnings):
14641585
from django.contrib.postgres.fields import ArrayField

tests/test_warnings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,15 @@ class XViewSet(viewsets.ModelViewSet):
374374
generate_schema('x', XViewSet)
375375
stderr = capsys.readouterr().err
376376
assert 'Could not derive type for ReadOnlyField "field"' in stderr
377+
378+
379+
def test_warning_missing_lookup_field_on_model_serializer(capsys):
380+
class XViewSet(viewsets.ModelViewSet):
381+
serializer_class = SimpleSerializer
382+
queryset = SimpleModel.objects.all()
383+
lookup_field = 'non_existent_field'
384+
385+
generate_schema('x', XViewSet)
386+
stderr = capsys.readouterr().err
387+
assert ('could not derive type of path parameter "non_existent_field" because model '
388+
'"tests.models.SimpleModel" contained no such field.') in stderr

0 commit comments

Comments
 (0)