Skip to content

Commit 935fc41

Browse files
authored
Merge pull request #741 from Nickatak/refactor/serializers-read-write
refactor: Split serializers into Read/Write classes per resource
2 parents 2e36122 + a1bdaef commit 935fc41

4 files changed

Lines changed: 160 additions & 57 deletions

File tree

backend/ctj_api/serializers.py

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
"""DRF serializers for CTJ's domain models, consumed by the views in `ctj_api.views`."""
1+
"""DRF serializers for CTJ's domain models, consumed by the views in `ctj_api.views`.
2+
3+
Serializer shape convention:
4+
- Each resource has a `XxxReadSerializer` for response shape and a
5+
`XxxWriteSerializer` for request shape, when both are needed.
6+
- Read-only resources only have a Read serializer; a Write serializer
7+
is added when a write endpoint is added.
8+
- Auto-managed fields (`id`, `created_at`, `updated_at`) and
9+
request-stamped fields (e.g. `created_by`) are *absent* from
10+
`XxxWriteSerializer.Meta.fields` rather than included with
11+
`read_only=True`. Absence is the contract.
12+
13+
See `docs/developer/backend.md` (`Serializer shape` section) for
14+
the full rule and rationale.
15+
"""
216

317
from rest_framework import serializers
418

@@ -13,7 +27,7 @@
1327
)
1428

1529

16-
class CustomUserSerializer(serializers.ModelSerializer):
30+
class CustomUserReadSerializer(serializers.ModelSerializer):
1731
"""Read serializer for `CustomUser` records.
1832
1933
Note: the `opportunities` field is broken - it declares a writable
@@ -23,11 +37,11 @@ class CustomUserSerializer(serializers.ModelSerializer):
2337
Reads would fail with `AttributeError`; writes would fail
2438
attempting to set `instance.opportunities`. Currently masked
2539
because no exercised code path hits it. The right fix is to drop
26-
the field entirely - deferred out of this docs-only PR. See
40+
the field entirely - deferred out of this shape-only PR. See
2741
`archive/feat-auth-stage1` for a worked example of the removal.
2842
2943
Used by:
30-
- `UserDetail` (`GET /api/users/<uuid>/`).
44+
- `user_detail` FBV (`GET /api/users/<uuid>/`).
3145
"""
3246

3347
opportunities = serializers.PrimaryKeyRelatedField(
@@ -52,17 +66,17 @@ class Meta:
5266
]
5367

5468

55-
class OpportunitySerializer(serializers.ModelSerializer):
56-
"""Read/write serializer for `Opportunity` records.
69+
class OpportunityReadSerializer(serializers.ModelSerializer):
70+
"""Read serializer for `Opportunity` records.
5771
58-
`created_by` is exposed as a read-only string (the creator's email
59-
via `source="created_by.email"`) rather than as the underlying
60-
UUID FK. Clients cannot supply or override the field on create or
61-
update; `OpportunityViewSet.perform_create` stamps it from
62-
`request.user` automatically.
72+
`created_by` is exposed as the creator's email string (via
73+
`source="created_by.email"`) rather than the underlying UUID FK,
74+
so list/retrieve responses surface a human-readable identity
75+
rather than an internal ID.
6376
6477
Used by:
65-
- `OpportunityViewSet` (`/api/opportunities/`).
78+
- `OpportunityViewSet.list` (`GET /api/opportunities/`).
79+
- `OpportunityViewSet.retrieve` (`GET /api/opportunities/<pk>/`).
6680
"""
6781

6882
created_by = serializers.ReadOnlyField(source="created_by.email")
@@ -85,16 +99,46 @@ class Meta:
8599
]
86100

87101

102+
class OpportunityWriteSerializer(serializers.ModelSerializer):
103+
"""Write serializer for `Opportunity` records.
104+
105+
Fields absent from `Meta.fields` are the contract for "client
106+
cannot supply this on write":
107+
- `id`, `created_at`, `updated_at`: auto-managed by Django.
108+
- `created_by`: stamped by `OpportunityViewSet.perform_create`
109+
from `request.user`; clients cannot supply or override it.
110+
111+
Used by:
112+
- `OpportunityViewSet.create` (`POST /api/opportunities/`).
113+
- `OpportunityViewSet.update` / `partial_update`
114+
(`PUT/PATCH /api/opportunities/<pk>/`). Note: PATCH is currently
115+
403'd by `OpportunityPermission` (no PATCH branch); flagged for
116+
fix in `ctj_api.permissions`.
117+
"""
118+
119+
class Meta:
120+
model = Opportunity
121+
fields = [
122+
"project",
123+
"role",
124+
"body",
125+
"min_experience_required",
126+
"min_hours_required",
127+
"work_environment",
128+
"skills_required_matrix",
129+
"status",
130+
]
131+
132+
88133
class SkillMatrixSerializer(serializers.ModelSerializer):
89134
"""Read/write serializer for `SkillMatrix` records.
90135
91-
Note: defined but never imported. `SkillMatrix` instances are
92-
currently surfaced indirectly via `CustomUser.skills_learned_matrix`
93-
and `Opportunity.skills_required_matrix` (each exposes the FK as
94-
a UUID through DRF's default PK-related behavior on the parent
95-
serializer). This serializer is dead code today; either wire it
96-
up to a dedicated endpoint when one is needed, or remove it.
97-
Deferred out of this docs-only PR.
136+
Note: defined but never imported. Not split into Read/Write
137+
because the class is currently dead code.
138+
The cleanup PR drops it. If `SkillMatrix` becomes API-exposed
139+
later, replace this with `SkillMatrixReadSerializer` (and
140+
`SkillMatrixWriteSerializer` if a write endpoint is added).
141+
Deferred out of this shape-only PR.
98142
99143
Used by:
100144
- (none currently).
@@ -110,11 +154,13 @@ class Meta:
110154
]
111155

112156

113-
class CommunityOfPracticeSerializer(serializers.ModelSerializer):
157+
class CommunityOfPracticeReadSerializer(serializers.ModelSerializer):
114158
"""Read serializer for `CommunityOfPractice` records.
115159
116160
Used by:
117-
- `CommunityOfPracticeViewSet` (`/api/communityOfPractice/`).
161+
- `community_of_practice_list` FBV (`GET /api/communityOfPractice/`).
162+
- `community_of_practice_detail` FBV
163+
(`GET /api/communityOfPractice/<uuid:pk>/`).
118164
"""
119165

120166
class Meta:
@@ -128,35 +174,38 @@ class Meta:
128174
]
129175

130176

131-
class RoleSerializer(serializers.ModelSerializer):
177+
class RoleReadSerializer(serializers.ModelSerializer):
132178
"""Read serializer for `Role` records.
133179
134180
Used by:
135-
- `RoleViewSet` (`/api/roles/`).
181+
- `role_list` FBV (`GET /api/roles/`).
182+
- `role_detail` FBV (`GET /api/roles/<uuid:pk>/`).
136183
"""
137184

138185
class Meta:
139186
model = Role
140187
fields = ["id", "title", "community_of_practice", "created_at", "updated_at"]
141188

142189

143-
class SkillSerializer(serializers.ModelSerializer):
190+
class SkillReadSerializer(serializers.ModelSerializer):
144191
"""Read serializer for `Skill` records.
145192
146193
Used by:
147-
- `SkillViewSet` (`/api/skills/`).
194+
- `skill_list` FBV (`GET /api/skills/`).
195+
- `skill_detail` FBV (`GET /api/skills/<uuid:pk>/`).
148196
"""
149197

150198
class Meta:
151199
model = Skill
152200
fields = ["id", "name", "communities_of_practice", "created_at", "updated_at"]
153201

154202

155-
class ProjectSerializer(serializers.ModelSerializer):
203+
class ProjectReadSerializer(serializers.ModelSerializer):
156204
"""Read serializer for `Project` records.
157205
158206
Used by:
159-
- `ProjectViewSet` (`/api/projects/`).
207+
- `project_list` FBV (`GET /api/projects/`).
208+
- `project_detail` FBV (`GET /api/projects/<uuid:pk>/`).
160209
"""
161210

162211
class Meta:

backend/ctj_api/views.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@
4040
)
4141
from ctj_api.permissions import OpportunityPermission, UserDetailPermission
4242
from ctj_api.serializers import (
43-
CommunityOfPracticeSerializer,
44-
CustomUserSerializer,
45-
OpportunitySerializer,
46-
ProjectSerializer,
47-
RoleSerializer,
48-
SkillSerializer,
43+
CommunityOfPracticeReadSerializer,
44+
CustomUserReadSerializer,
45+
OpportunityReadSerializer,
46+
OpportunityWriteSerializer,
47+
ProjectReadSerializer,
48+
RoleReadSerializer,
49+
SkillReadSerializer,
4950
)
5051

5152
start_time = time.time()
@@ -160,7 +161,7 @@ def user_detail(request, pk):
160161
# object-level (`has_object_permission`) check has to be triggered
161162
# explicitly in FBVs since there's no APIView class to auto-call it.
162163
request.parser_context["view"].check_object_permissions(request, user)
163-
serializer = CustomUserSerializer(user)
164+
serializer = CustomUserReadSerializer(user)
164165
return Response(serializer.data)
165166

166167

@@ -195,8 +196,8 @@ class OpportunityViewSet(viewsets.ModelViewSet):
195196
- IsAuthenticatedOrReadOnly + OpportunityPermission
196197
197198
Errors:
198-
- 400: Validation error on create/update (`OpportunitySerializer`
199-
rejected the payload).
199+
- 400: Validation error on create/update
200+
(`OpportunityWriteSerializer` rejected the payload).
200201
- 401: Unauthenticated mutation.
201202
- 403: `OpportunityPermission` denied (e.g. non-PM trying to
202203
create, non-creator trying to update). Note: PATCH is always
@@ -206,12 +207,22 @@ class OpportunityViewSet(viewsets.ModelViewSet):
206207
"""
207208

208209
queryset = Opportunity.objects.all()
209-
serializer_class = OpportunitySerializer
210+
serializer_class = OpportunityReadSerializer
210211
permission_classes = (
211212
permissions.IsAuthenticatedOrReadOnly,
212213
OpportunityPermission,
213214
)
214215

216+
def get_serializer_class(self):
217+
# Dispatch by action: list/retrieve return the Read shape;
218+
# create/update/destroy accept the Write shape. The class-level
219+
# `serializer_class = OpportunityReadSerializer` above is the
220+
# safe fallback (Read) if `self.action` is None during schema
221+
# introspection.
222+
if self.action in ("list", "retrieve"):
223+
return OpportunityReadSerializer
224+
return OpportunityWriteSerializer
225+
215226
def perform_create(self, serializer):
216227
serializer.save(created_by=self.request.user)
217228

@@ -229,7 +240,7 @@ def community_of_practice_list(request):
229240
230241
Flow:
231242
1. Fetch all `CommunityOfPractice` rows.
232-
2. Serialize via `CommunityOfPracticeSerializer` and return 200.
243+
2. Serialize via `CommunityOfPracticeReadSerializer` and return 200.
233244
234245
URL:
235246
- GET /api/communityOfPractice/
@@ -241,7 +252,7 @@ def community_of_practice_list(request):
241252
- (none)
242253
"""
243254
cops = CommunityOfPractice.objects.all()
244-
serializer = CommunityOfPracticeSerializer(cops, many=True)
255+
serializer = CommunityOfPracticeReadSerializer(cops, many=True)
245256
return Response(serializer.data)
246257

247258

@@ -254,7 +265,7 @@ def community_of_practice_detail(request, pk):
254265
255266
Flow:
256267
1. Look up the row by primary-key UUID.
257-
2. Serialize via `CommunityOfPracticeSerializer` and return 200.
268+
2. Serialize via `CommunityOfPracticeReadSerializer` and return 200.
258269
259270
URL:
260271
- GET /api/communityOfPractice/<uuid:pk>/
@@ -266,7 +277,7 @@ def community_of_practice_detail(request, pk):
266277
- 404: No CoP exists with the given UUID.
267278
"""
268279
cop = get_object_or_404(CommunityOfPractice, pk=pk)
269-
serializer = CommunityOfPracticeSerializer(cop)
280+
serializer = CommunityOfPracticeReadSerializer(cop)
270281
return Response(serializer.data)
271282

272283

@@ -283,7 +294,7 @@ def role_list(request):
283294
284295
Flow:
285296
1. Fetch all `Role` rows.
286-
2. Serialize via `RoleSerializer` and return 200.
297+
2. Serialize via `RoleReadSerializer` and return 200.
287298
288299
URL:
289300
- GET /api/roles/
@@ -295,7 +306,7 @@ def role_list(request):
295306
- (none)
296307
"""
297308
roles = Role.objects.all()
298-
serializer = RoleSerializer(roles, many=True)
309+
serializer = RoleReadSerializer(roles, many=True)
299310
return Response(serializer.data)
300311

301312

@@ -308,7 +319,7 @@ def role_detail(request, pk):
308319
309320
Flow:
310321
1. Look up the row by primary-key UUID.
311-
2. Serialize via `RoleSerializer` and return 200.
322+
2. Serialize via `RoleReadSerializer` and return 200.
312323
313324
URL:
314325
- GET /api/roles/<uuid:pk>/
@@ -320,7 +331,7 @@ def role_detail(request, pk):
320331
- 404: No role exists with the given UUID.
321332
"""
322333
role = get_object_or_404(Role, pk=pk)
323-
serializer = RoleSerializer(role)
334+
serializer = RoleReadSerializer(role)
324335
return Response(serializer.data)
325336

326337

@@ -337,7 +348,7 @@ def skill_list(request):
337348
338349
Flow:
339350
1. Fetch all `Skill` rows.
340-
2. Serialize via `SkillSerializer` and return 200.
351+
2. Serialize via `SkillReadSerializer` and return 200.
341352
342353
URL:
343354
- GET /api/skills/
@@ -349,7 +360,7 @@ def skill_list(request):
349360
- (none)
350361
"""
351362
skills = Skill.objects.all()
352-
serializer = SkillSerializer(skills, many=True)
363+
serializer = SkillReadSerializer(skills, many=True)
353364
return Response(serializer.data)
354365

355366

@@ -362,7 +373,7 @@ def skill_detail(request, pk):
362373
363374
Flow:
364375
1. Look up the row by primary-key UUID.
365-
2. Serialize via `SkillSerializer` and return 200.
376+
2. Serialize via `SkillReadSerializer` and return 200.
366377
367378
URL:
368379
- GET /api/skills/<uuid:pk>/
@@ -374,7 +385,7 @@ def skill_detail(request, pk):
374385
- 404: No skill exists with the given UUID.
375386
"""
376387
skill = get_object_or_404(Skill, pk=pk)
377-
serializer = SkillSerializer(skill)
388+
serializer = SkillReadSerializer(skill)
378389
return Response(serializer.data)
379390

380391

@@ -390,7 +401,7 @@ def project_list(request):
390401
391402
Flow:
392403
1. Fetch all `Project` rows.
393-
2. Serialize via `ProjectSerializer` and return 200.
404+
2. Serialize via `ProjectReadSerializer` and return 200.
394405
395406
URL:
396407
- GET /api/projects/
@@ -402,7 +413,7 @@ def project_list(request):
402413
- (none)
403414
"""
404415
projects = Project.objects.all()
405-
serializer = ProjectSerializer(projects, many=True)
416+
serializer = ProjectReadSerializer(projects, many=True)
406417
return Response(serializer.data)
407418

408419

@@ -415,7 +426,7 @@ def project_detail(request, pk):
415426
416427
Flow:
417428
1. Look up the row by primary-key UUID.
418-
2. Serialize via `ProjectSerializer` and return 200.
429+
2. Serialize via `ProjectReadSerializer` and return 200.
419430
420431
URL:
421432
- GET /api/projects/<uuid:pk>/
@@ -427,5 +438,5 @@ def project_detail(request, pk):
427438
- 404: No project exists with the given UUID.
428439
"""
429440
project = get_object_or_404(Project, pk=pk)
430-
serializer = ProjectSerializer(project)
441+
serializer = ProjectReadSerializer(project)
431442
return Response(serializer.data)

0 commit comments

Comments
 (0)