Skip to content

Commit 8733d54

Browse files
authored
Merge pull request open5e#601 from calumbell/feature-api-v2/filtering-nested-fields-#576
API V2: Filtering nested fields via query parameter (`Documents` &c)
2 parents 303364d + 63ebf7c commit 8733d54

26 files changed

+1292
-203
lines changed

api_v2/serializers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
from .condition import ConditionSerializer
3535

36-
from .spell import SpellSerializer
36+
from .spell import SpellSerializer, SpellSchoolSerializer
3737

3838
from .characterclass import CharacterClassSerializer
3939
from .characterclass import ClassFeatureSerializer

api_v2/serializers/abstracts.py

Lines changed: 138 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,146 @@
33

44
from api_v2 import models
55

6+
class GameContentSerializer(serializers.HyperlinkedModelSerializer):
7+
"""
8+
Much of the logic included in the GameContentSerializer is intended to
9+
support manipulating data returned by the serializer via query parameters.
10+
"""
11+
12+
def remove_unwanted_fields(self, dynamic_params):
13+
"""
14+
Takes the value of the 'fields', a string of comma-separated values,
15+
and removes all fields not in this list from the serializer
16+
"""
17+
if fields_to_keep := dynamic_params.pop("fields", None):
18+
fields_to_keep = set(fields_to_keep.split(","))
19+
all_fields = set(self.fields.keys())
20+
for field in all_fields - fields_to_keep:
21+
self.fields.pop(field, None)
622

7-
class GameContentSerializer(serializers.HyperlinkedModelSerializer):
23+
def get_or_create_dynamic_params(self, child):
24+
"""
25+
Creates dynamic params on the serializer context if it doesn't already
26+
exist, then returns the dynamic parameters
27+
"""
28+
if "dynamic_params" not in self.fields[child]._context:
29+
self.fields[child]._context.update({"dynamic_params": {}})
30+
return self.fields[child]._context["dynamic_params"]
31+
32+
@staticmethod
33+
def split_param(dynamic_param):
34+
"""
35+
Splits a dynamic parameter into its target child serializer and value.
36+
Returns the values as a tuple.
37+
eg.
38+
'document__fields=name' -> ('document', 'fields=name')
39+
'document__gamesystem__fields=name' -> ('document', 'gamesystem__fields=name')
40+
"""
41+
crumbs = dynamic_param.split("__")
42+
return crumbs[0], "__".join(crumbs[1:]) if len(crumbs) > 1 else None
43+
44+
def set_dynamic_params_for_children(self, dynamic_params):
45+
"""
46+
Passes nested dynamic params to child serializer.
47+
eg. the param 'document__fields=name'
48+
"""
49+
for param, fields in dynamic_params.items():
50+
child, child_dynamic_param = self.split_param(param)
51+
if child in self.fields.keys():
52+
# Get dynamic parameters for child serializer and update
53+
child_dynamic_params = self.get_or_create_dynamic_params(child)
54+
child_dynamic_params.update({child_dynamic_param: fields})
55+
56+
# Overwrite existing params to remove 'fields' inherited from parent serializer
57+
self.fields[child]._context['dynamic_params'] = {
58+
**child_dynamic_params,
59+
}
60+
61+
@staticmethod
62+
def is_param_dynamic(p):
63+
"""
64+
Returns true if parameter 'p' is a dynamic parameter. Currently the
65+
only dynamic param supported is 'fields', so we check for that
66+
"""
67+
return p.endswith("fields")
68+
69+
def get_dynamic_params_for_root(self, request):
70+
"""
71+
Returns a dict of dynamic query parameters extracted from the 'request'
72+
object. Only works on the root serializer (child serializers do no
73+
include a 'request' field)
74+
"""
75+
query_params = request.query_params.items()
76+
return {k: v for k, v in query_params if self.is_param_dynamic(k)}
77+
78+
def get_dynamic_params(self):
79+
"""
80+
Returns dynamic parameters stored on the serializer context
81+
"""
82+
# The context for ListSerializers is stored on the parent
83+
if isinstance(self.parent, serializers.ListSerializer):
84+
return self.parent._context.get("dynamic_params", {})
85+
return self._context.get("dynamic_params", {})
86+
87+
def handle_depth_serialization(self, instance, representation):
88+
"""
89+
Handles the serialization of fields based on the current depth
90+
compared to the maximum allowed depth. This function modifies
91+
the representation to include only URLs for nested serializers
92+
when the maximum depth is reached.
93+
"""
94+
max_depth = self._context.get("max_depth", 0)
95+
current_depth = self._context.get("current_depth", 0)
96+
97+
# if we reach the maximum depth, nested serializers return their pk (url)
98+
if current_depth >= max_depth:
99+
for field_name, field in self.fields.items():
100+
if isinstance(field, serializers.HyperlinkedModelSerializer):
101+
nested_representation = representation.get(field_name)
102+
if nested_representation and "url" in nested_representation:
103+
representation[field_name] = nested_representation["url"]
104+
return representation
105+
106+
# otherwise, pass depth to children serializers
107+
for field_name, field in self.fields.items():
108+
# Guard clause: make sure the child is a GameContentSerializer
109+
if not isinstance(field, GameContentSerializer):
110+
continue
111+
112+
nested_instance = getattr(instance, field_name)
113+
nested_serializer = field.__class__(nested_instance, context={
114+
**self._context,
115+
"current_depth": current_depth + 1,
116+
"max_depth": max_depth,
117+
})
118+
119+
# Ensure dynamic params are specific to the child serializer
120+
child_dynamic_params = self.get_or_create_dynamic_params(field_name)
121+
nested_serializer._context['dynamic_params'] = child_dynamic_params
122+
representation[field_name] = nested_serializer.data
123+
return representation
124+
8125

9-
# Adding dynamic "fields" qs parameter.
10126
def __init__(self, *args, **kwargs):
11-
# Add default fields variable.
12-
13-
# Instantiate the superclass normally
14-
super(GameContentSerializer, self).__init__(*args, **kwargs)
15-
16-
# The request doesn't exist when generating an OAS file, so we have to check that first
17-
if self.context['request']:
18-
fields = self.context['request'].query_params.get('fields')
19-
if fields:
20-
fields = fields.split(',')
21-
# Drop any fields that are not specified in the `fields` argument.
22-
allowed = set(fields)
23-
existing = set(self.fields.keys())
24-
for field_name in existing - allowed:
25-
self.fields.pop(field_name)
26-
27-
depth = self.context['request'].query_params.get('depth')
28-
if depth:
29-
try:
30-
depth_value = int(depth)
31-
if depth_value > 0 and depth_value < 3:
32-
# This value going above 1 could cause performance issues.
33-
# Limited to 1 and 2 for now.
34-
self.Meta.depth = depth_value
35-
# Depth does not reset by default on subsequent requests with malformed urls.
36-
else:
37-
self.Meta.depth = 0
38-
except ValueError:
39-
pass # it was not castable to an int.
40-
else:
41-
self.Meta.depth = 0 #The default.
127+
request = kwargs.get("context", {}).get("request")
128+
super().__init__(*args, **kwargs)
129+
130+
if request:
131+
self._context["max_depth"] = int(request.query_params.get("depth", 0))
132+
dynamic_params = self.get_dynamic_params_for_root(request)
133+
self._context.update({"dynamic_params": dynamic_params})
134+
135+
def to_representation(self, instance):
136+
if dynamic_params := self.get_dynamic_params().copy():
137+
self.remove_unwanted_fields(dynamic_params)
138+
self.set_dynamic_params_for_children(dynamic_params)
139+
140+
representation = super().to_representation(instance)
141+
142+
representation = self.handle_depth_serialization(instance, representation)
143+
144+
return representation
145+
42146

43147
class Meta:
44-
abstract = True
148+
abstract = True

api_v2/serializers/alignment.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from api_v2 import models
66

77
from .abstracts import GameContentSerializer
8+
from .document import DocumentSerializer
89

910
class AlignmentSerializer(GameContentSerializer):
1011
key = serializers.ReadOnlyField()
1112
morality = serializers.ReadOnlyField()
1213
societal_attitude = serializers.ReadOnlyField()
1314
short_name = serializers.ReadOnlyField()
14-
15+
document = DocumentSerializer()
16+
1517
class Meta:
1618
model = models.Alignment
1719
fields = '__all__'

api_v2/serializers/background.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from api_v2 import models
66

77
from .abstracts import GameContentSerializer
8+
from .document import DocumentSerializer
89

910
class BackgroundBenefitSerializer(serializers.ModelSerializer):
1011
class Meta:
@@ -17,6 +18,7 @@ class BackgroundSerializer(GameContentSerializer):
1718
benefits = BackgroundBenefitSerializer(
1819
many=True
1920
)
21+
document = DocumentSerializer()
2022

2123
class Meta:
2224
model = models.Background

api_v2/serializers/characterclass.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
from api_v2 import models
66

77
from .abstracts import GameContentSerializer
8+
from .document import DocumentSerializer
89

9-
class ClassFeatureItemSerializer(serializers.ModelSerializer):
10+
class ClassFeatureItemSerializer(GameContentSerializer):
1011
class Meta:
1112
model = models.ClassFeatureItem
1213
fields = ['name','desc','type']
1314

14-
class ClassFeatureSerializer(serializers.ModelSerializer):
15+
class ClassFeatureSerializer(GameContentSerializer):
1516
key = serializers.ReadOnlyField()
1617

1718
class Meta:
@@ -24,6 +25,7 @@ class CharacterClassSerializer(GameContentSerializer):
2425
many=True, context={'request': {}})
2526
levels = serializers.ReadOnlyField()
2627
hit_points = serializers.ReadOnlyField()
28+
document = DocumentSerializer()
2729

2830
class Meta:
2931
model = models.CharacterClass

api_v2/serializers/condition.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from api_v2 import models
66

77
from .abstracts import GameContentSerializer
8+
from .document import DocumentSerializer
89

910
class ConditionSerializer(GameContentSerializer):
1011
key = serializers.ReadOnlyField()
12+
document = DocumentSerializer()
1113

1214
class Meta:
1315
model = models.Condition

api_v2/serializers/creature.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from api_v2 import models
88

99
from .abstracts import GameContentSerializer
10+
from .document import DocumentSerializer
1011
from .size import SizeSerializer
1112
from drf_spectacular.utils import extend_schema_field, inline_serializer
1213
from drf_spectacular.types import OpenApiTypes
@@ -59,7 +60,9 @@ class CreatureSerializer(GameContentSerializer):
5960
speed_all = serializers.SerializerMethodField()
6061
challenge_rating_text = serializers.SerializerMethodField()
6162
experience_points = serializers.SerializerMethodField()
62-
63+
document = DocumentSerializer()
64+
type = CreatureTypeSerializer()
65+
size = SizeSerializer()
6366

6467
class Meta:
6568
'''Serializer meta options.'''

api_v2/serializers/document.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,23 @@
44

55
from api_v2 import models
66

7-
class GameSystemSerializer(serializers.HyperlinkedModelSerializer):
7+
class GameSystemSerializer(GameContentSerializer):
88
key = serializers.ReadOnlyField()
99

1010
class Meta:
1111
model = models.GameSystem
1212
fields = '__all__'
1313

1414

15-
class LicenseSerializer(serializers.HyperlinkedModelSerializer):
15+
class LicenseSerializer(GameContentSerializer):
1616
key = serializers.ReadOnlyField()
1717

1818
class Meta:
1919
model = models.License
2020
fields = '__all__'
2121

2222

23-
class PublisherSerializer(serializers.HyperlinkedModelSerializer):
23+
class PublisherSerializer(GameContentSerializer):
2424
key = serializers.ReadOnlyField()
2525

2626
class Meta:
@@ -30,6 +30,9 @@ class Meta:
3030

3131
class DocumentSerializer(GameContentSerializer):
3232
key = serializers.ReadOnlyField()
33+
licenses = LicenseSerializer(many=True)
34+
publisher = PublisherSerializer()
35+
gamesystem = GameSystemSerializer()
3336

3437
class Meta:
3538
model = models.Document

api_v2/serializers/feat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from api_v2 import models
66

77
from .abstracts import GameContentSerializer
8+
from .document import DocumentSerializer
89

910
class FeatBenefitSerializer(serializers.ModelSerializer):
1011
class Meta:
@@ -16,6 +17,7 @@ class FeatSerializer(GameContentSerializer):
1617
has_prerequisite = serializers.ReadOnlyField()
1718
benefits = FeatBenefitSerializer(
1819
many=True)
20+
document = DocumentSerializer()
1921

2022
class Meta:
2123
model = models.Feat

api_v2/serializers/item.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .abstracts import GameContentSerializer
88
from .size import SizeSerializer
9+
from .document import DocumentSerializer
910
from drf_spectacular.utils import extend_schema_field
1011
from drf_spectacular.types import OpenApiTypes
1112

@@ -47,11 +48,21 @@ class Meta:
4748
model = models.ItemRarity
4849
fields = '__all__'
4950

51+
class ItemCategorySerializer(GameContentSerializer):
52+
key = serializers.ReadOnlyField()
53+
54+
class Meta:
55+
model = models.ItemCategory
56+
fields = "__all__"
57+
5058
class ItemSerializer(GameContentSerializer):
5159
key = serializers.ReadOnlyField()
5260
is_magic_item = serializers.ReadOnlyField()
5361
weapon = WeaponSerializer(read_only=True, context={'request':{}})
5462
armor = ArmorSerializer(read_only=True, context={'request':{}})
63+
document = DocumentSerializer()
64+
category = ItemCategorySerializer()
65+
rarity = ItemRaritySerializer()
5566

5667
class Meta:
5768
model = models.Item
@@ -65,11 +76,3 @@ class ItemSetSerializer(GameContentSerializer):
6576
class Meta:
6677
model = models.ItemSet
6778
fields = '__all__'
68-
69-
70-
class ItemCategorySerializer(GameContentSerializer):
71-
key = serializers.ReadOnlyField()
72-
73-
class Meta:
74-
model = models.ItemCategory
75-
fields = "__all__"

0 commit comments

Comments
 (0)