Skip to content
This repository was archived by the owner on Jan 29, 2025. It is now read-only.

Commit 091bc5f

Browse files
bors[bot]mythmon
andcommitted
Merge #1550
1550: V3 api refactor r=rehandalal a=mythmon I think this makes the API a lot easier to understand. Previously, there was a confusing mix of hierarchy and recursion. When querying `/v3/api/recipe/{id}/`, some fields were placed at the top level, and other fields were placed within a latest or approved revision key, which then had a recipe key that duplicated those top level keys again. The goal here is not to minimize bytes-on-the-wire, but to make the overall structure clearer and better reflect the underlying data. As a bonus, enabled states are now exposed in the API. Co-authored-by: Mike Cooper <[email protected]>
2 parents 8bbf77b + 62b3e10 commit 091bc5f

File tree

5 files changed

+200
-137
lines changed

5 files changed

+200
-137
lines changed

normandy/recipes/api/v3/serializers.py

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,29 @@
55
from normandy.base.api.serializers import UserSerializer
66
from normandy.recipes import filters
77
from normandy.recipes.api.fields import ActionImplementationHyperlinkField
8-
from normandy.recipes.models import Action, ApprovalRequest, Recipe, RecipeRevision
8+
from normandy.recipes.models import (
9+
Action,
10+
ApprovalRequest,
11+
EnabledState,
12+
Recipe,
13+
RecipeRevision,
14+
Signature,
15+
)
916
from normandy.recipes.validators import JSONSchemaValidator
1017

1118

19+
class CustomizableSerializerMixin:
20+
"""Serializer Mixin that allows callers to exclude fields on instance of this serializer."""
21+
22+
def __init__(self, *args, **kwargs):
23+
exclude_fields = kwargs.pop("exclude_fields", [])
24+
super().__init__(*args, **kwargs)
25+
26+
if exclude_fields:
27+
for field in exclude_fields:
28+
self.fields.pop(field)
29+
30+
1231
class ActionSerializer(serializers.ModelSerializer):
1332
arguments_schema = serializers.JSONField()
1433
implementation_url = ActionImplementationHyperlinkField()
@@ -28,83 +47,106 @@ class Meta:
2847
fields = ["approved", "approver", "comment", "created", "creator", "id"]
2948

3049

50+
class EnabledStateSerializer(CustomizableSerializerMixin, serializers.ModelSerializer):
51+
creator = UserSerializer()
52+
53+
class Meta:
54+
model = EnabledState
55+
fields = ["revision_id", "created", "creator", "enabled", "carryover_from"]
56+
57+
3158
class RecipeRevisionSerializer(serializers.ModelSerializer):
59+
action = serializers.SerializerMethodField(read_only=True)
3260
approval_request = ApprovalRequestSerializer(read_only=True)
3361
comment = serializers.CharField(required=False)
62+
creator = UserSerializer(source="user", read_only=True)
3463
date_created = serializers.DateTimeField(source="created", read_only=True)
64+
enabled_states = EnabledStateSerializer(many=True, exclude_fields=["revision_id"])
3565
recipe = serializers.SerializerMethodField(read_only=True)
36-
user = UserSerializer()
3766

3867
class Meta:
3968
model = RecipeRevision
4069
fields = [
70+
"action",
4171
"approval_request",
72+
"arguments",
73+
"bug_number",
4274
"comment",
4375
"date_created",
76+
"enabled_states",
77+
"enabled",
78+
"extra_filter_expression",
79+
"filter_expression",
80+
"filter_object",
4481
"id",
45-
"recipe",
46-
"user",
4782
"identicon_seed",
83+
"name",
84+
"recipe",
85+
"creator",
86+
"updated",
4887
]
4988

5089
def get_recipe(self, instance):
51-
serializer = RecipeSerializer(
52-
instance.serializable_recipe,
53-
exclude_fields=["latest_revision", "approved_revision", "approval_request"],
90+
serializer = RecipeLinkSerializer(instance.recipe)
91+
return serializer.data
92+
93+
def get_action(self, instance):
94+
serializer = ActionSerializer(
95+
instance.action, read_only=True, context={"request": self.context.get("request")}
5496
)
5597
return serializer.data
5698

5799

58-
class RecipeSerializer(serializers.ModelSerializer):
59-
action = serializers.SerializerMethodField(read_only=True)
100+
class SignatureSerializer(serializers.ModelSerializer):
101+
timestamp = serializers.DateTimeField(read_only=True)
102+
signature = serializers.ReadOnlyField()
103+
x5u = serializers.ReadOnlyField()
104+
public_key = serializers.ReadOnlyField()
105+
106+
class Meta:
107+
model = Signature
108+
fields = ["timestamp", "signature", "x5u", "public_key"]
109+
110+
111+
class RecipeSerializer(CustomizableSerializerMixin, serializers.ModelSerializer):
112+
# read-only fields
113+
approved_revision = RecipeRevisionSerializer(read_only=True)
114+
latest_revision = RecipeRevisionSerializer(read_only=True)
115+
signature = SignatureSerializer(read_only=True)
116+
117+
# write-only fields
60118
action_id = serializers.PrimaryKeyRelatedField(
61119
source="action", queryset=Action.objects.all(), write_only=True
62120
)
63-
approval_request = ApprovalRequestSerializer(read_only=True)
64-
approved_revision = RecipeRevisionSerializer(read_only=True)
65-
arguments = serializers.JSONField()
66-
enabled = serializers.BooleanField(read_only=True)
67-
extra_filter_expression = serializers.CharField(required=False, allow_blank=True)
68-
filter_expression = serializers.CharField(read_only=True)
69-
filter_object = serializers.JSONField(required=False)
70-
last_updated = serializers.DateTimeField(read_only=True)
71-
latest_revision = RecipeRevisionSerializer(read_only=True)
72-
name = serializers.CharField()
73-
identicon_seed = serializers.CharField(required=False)
74-
comment = serializers.CharField(required=False)
75-
bug_number = serializers.IntegerField(required=False)
121+
arguments = serializers.JSONField(write_only=True)
122+
extra_filter_expression = serializers.CharField(
123+
required=False, allow_blank=True, write_only=True
124+
)
125+
filter_object = serializers.JSONField(required=False, write_only=True)
126+
name = serializers.CharField(write_only=True)
127+
identicon_seed = serializers.CharField(required=False, write_only=True)
128+
comment = serializers.CharField(required=False, write_only=True)
129+
bug_number = serializers.IntegerField(required=False, write_only=True)
76130

77131
class Meta:
78132
model = Recipe
79133
fields = [
80-
"action",
81-
"action_id",
82-
"approval_request",
134+
# read-only
83135
"approved_revision",
136+
"id",
137+
"latest_revision",
138+
"signature",
139+
# write-only
140+
"action_id",
84141
"arguments",
85-
"enabled",
86142
"extra_filter_expression",
87-
"filter_expression",
88143
"filter_object",
89-
"id",
90-
"is_approved",
91-
"last_updated",
92-
"latest_revision",
93144
"name",
94145
"identicon_seed",
95146
"comment",
96147
"bug_number",
97148
]
98149

99-
def __init__(self, *args, **kwargs):
100-
exclude_fields = kwargs.pop("exclude_fields", [])
101-
super().__init__(*args, **kwargs)
102-
103-
if exclude_fields:
104-
for field in exclude_fields:
105-
if field in self.fields:
106-
self.fields.pop(field)
107-
108150
def get_action(self, instance):
109151
serializer = ActionSerializer(
110152
instance.action, read_only=True, context={"request": self.context.get("request")}
@@ -244,3 +286,8 @@ def validate_filter_object(self, value):
244286
raise serializers.ValidationError(errors)
245287

246288
return value
289+
290+
291+
class RecipeLinkSerializer(RecipeSerializer):
292+
class Meta(RecipeSerializer.Meta):
293+
fields = ["approved_revision_id", "id", "latest_revision_id"]

normandy/recipes/api/v3/views.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ class ActionViewSet(CachingViewsetMixin, viewsets.ReadOnlyModelViewSet):
3838

3939
queryset = Action.objects.all()
4040
serializer_class = ActionSerializer
41-
pagination_class = None
4241

4342

4443
class RecipeFilters(django_filters.FilterSet):
@@ -67,14 +66,22 @@ class RecipeViewSet(CachingViewsetMixin, UpdateOrCreateModelViewSet):
6766

6867
queryset = (
6968
Recipe.objects.all()
70-
# Foreign keys
71-
.select_related("latest_revision")
72-
.select_related("latest_revision__action")
73-
.select_related("latest_revision__approval_request")
74-
# Many-to-many
75-
.prefetch_related("latest_revision__channels")
76-
.prefetch_related("latest_revision__countries")
77-
.prefetch_related("latest_revision__locales")
69+
.select_related(
70+
"approved_revision__action",
71+
"approved_revision__approval_request",
72+
"latest_revision__action",
73+
"latest_revision__approval_request",
74+
)
75+
.prefetch_related(
76+
"approved_revision__channels",
77+
"approved_revision__countries",
78+
"approved_revision__enabled_states",
79+
"approved_revision__locales",
80+
"latest_revision__channels",
81+
"latest_revision__countries",
82+
"latest_revision__enabled_states",
83+
"latest_revision__locales",
84+
)
7885
)
7986
serializer_class = RecipeSerializer
8087
filterset_class = RecipeFilters
@@ -138,6 +145,7 @@ def enable(self, request, pk=None):
138145
status=status.HTTP_409_CONFLICT,
139146
)
140147

148+
recipe.latest_revision.refresh_from_db()
141149
return Response(RecipeSerializer(recipe).data)
142150

143151
@action(detail=True, methods=["POST"])
@@ -155,17 +163,11 @@ def disable(self, request, pk=None):
155163
class RecipeRevisionViewSet(viewsets.ReadOnlyModelViewSet):
156164
queryset = (
157165
RecipeRevision.objects.all()
158-
.select_related("action")
159-
.select_related("approval_request")
160-
.select_related("recipe")
161-
# Many-to-many
162-
.prefetch_related("channels")
163-
.prefetch_related("countries")
164-
.prefetch_related("locales")
166+
.select_related("action", "approval_request", "recipe")
167+
.prefetch_related("enabled_states", "channels", "countries", "locales")
165168
)
166169
serializer_class = RecipeRevisionSerializer
167170
permission_classes = [AdminEnabledOrReadOnly, permissions.DjangoModelPermissionsOrAnonReadOnly]
168-
pagination_class = None
169171

170172
@action(detail=True, methods=["POST"])
171173
def request_approval(self, request, pk=None):
@@ -188,7 +190,6 @@ class ApprovalRequestViewSet(viewsets.ReadOnlyModelViewSet):
188190
queryset = ApprovalRequest.objects.all()
189191
serializer_class = ApprovalRequestSerializer
190192
permission_classes = [AdminEnabledOrReadOnly, permissions.DjangoModelPermissionsOrAnonReadOnly]
191-
pagination_class = None
192193

193194
@action(detail=True, methods=["POST"])
194195
def approve(self, request, pk=None):

normandy/recipes/models.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,13 @@ def revise(self, force=False, **data):
276276

277277
@transaction.atomic
278278
def save(self, *args, **kwargs):
279-
if self.is_dirty(check_relationship=True):
280-
dirty_fields = self.get_dirty_fields(check_relationship=True)
279+
dirty_fields = {
280+
k: v
281+
for k, v in self.get_dirty_fields(check_relationship=True, verbose=True).items()
282+
if v["saved"] != v["current"]
283+
}
284+
285+
if dirty_fields:
281286
dirty_field_names = list(dirty_fields.keys())
282287

283288
if (
@@ -492,6 +497,9 @@ class ApprovalRequest(models.Model):
492497
)
493498
comment = models.TextField(null=True)
494499

500+
class Meta:
501+
ordering = ("id",)
502+
495503
class NotActionable(Exception):
496504
pass
497505

@@ -571,6 +579,9 @@ class Action(DirtyFieldsMixin, models.Model):
571579
Signature, related_name="action", null=True, blank=True, on_delete=models.CASCADE
572580
)
573581

582+
class Meta:
583+
ordering = ("id",)
584+
574585
errors = {
575586
"duplicate_branch_slug": "Feature branch slugs must be unique within an experiment",
576587
"duplicate_branch_value": "Feature branch values must be unique within an experiment",
@@ -641,8 +652,12 @@ def update_signature(self):
641652

642653
@transaction.atomic
643654
def save(self, *args, **kwargs):
644-
if self.is_dirty(check_relationship=True):
645-
dirty_fields = self.get_dirty_fields(check_relationship=True)
655+
dirty_fields = {
656+
k: v
657+
for k, v in self.get_dirty_fields(check_relationship=True, verbose=True).items()
658+
if v["saved"] != v["current"]
659+
}
660+
if dirty_fields:
646661
dirty_field_names = list(dirty_fields.keys())
647662

648663
if (

0 commit comments

Comments
 (0)