Skip to content

Commit 4efcb35

Browse files
feat: use shared behavior class for updating email and username
Refactor the current username update flow to use a shared behavior class and base updater view and serializer. This enables supporting email updates separately via a different endpoint.
1 parent c6297e5 commit 4efcb35

File tree

5 files changed

+402
-21
lines changed

5 files changed

+402
-21
lines changed

eox_core/api/support/v1/serializers.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,33 @@ class WrittableEdxappRemoveUserSerializer(serializers.Serializer):
2828
is_support_user = serializers.BooleanField(default=True)
2929

3030

31-
class WrittableEdxappUsernameSerializer(serializers.Serializer):
31+
class WrittableEdxappUserSerializer(serializers.Serializer):
3232
"""
33-
Handles the serialization of the data required to update the username of an edxapp user.
33+
Base serializer for updating username or email of an edxapp user.
34+
35+
When a username/email update is being made the following validations are performed:
36+
- The new username/email is not already taken by another user.
37+
- The user is not staff or superuser.
38+
- The user has just one signup source.
3439
"""
35-
new_username = serializers.CharField(max_length=USERNAME_MAX_LENGTH, write_only=True)
3640

37-
def validate(self, attrs):
41+
def validate_conflicts(self, attrs):
3842
"""
39-
When a username update is being made, then it checks that:
40-
- The new username is not already taken by other user.
41-
- The user is not staff or superuser.
42-
- The user has just one signup source.
43+
Validates that no conflicts exist for the provided username or email.
4344
"""
4445
username = attrs.get("new_username")
45-
conflicts = check_edxapp_account_conflicts(None, username)
46+
email = attrs.get("new_email")
47+
48+
conflicts = check_edxapp_account_conflicts(email, username)
4649
if conflicts:
47-
raise serializers.ValidationError({"detail": "An account already exists with the provided username."})
50+
raise serializers.ValidationError({"detail": "An account already exists with the provided username or email."})
4851

52+
return attrs
53+
54+
def validate_role_restrictions(self, attrs):
55+
"""
56+
Validates that the user is not staff or superuser and has just one signup source.
57+
"""
4958
if self.instance.is_staff or self.instance.is_superuser:
5059
raise serializers.ValidationError({"detail": "You can't update users with roles like staff or superuser."})
5160

@@ -54,15 +63,54 @@ def validate(self, attrs):
5463

5564
return attrs
5665

66+
def validate(self, attrs):
67+
"""
68+
Base validate method to be used by child serializers to validate common restrictions.
69+
"""
70+
self.validate_conflicts(attrs)
71+
self.validate_role_restrictions(attrs)
72+
73+
return attrs
74+
75+
76+
class WrittableEdxappUsernameSerializer(WrittableEdxappUserSerializer):
77+
"""
78+
Handles the serialization of the data required to update the username of an edxapp user.
79+
"""
80+
81+
new_username = serializers.CharField(
82+
max_length=USERNAME_MAX_LENGTH,
83+
required=True,
84+
allow_blank=False,
85+
allow_null=False,
86+
)
87+
5788
def update(self, instance, validated_data):
5889
"""
59-
Method to update username of edxapp User.
90+
Updates the username of the edxapp User.
6091
"""
61-
key = 'username'
62-
if validated_data:
63-
setattr(instance, key, validated_data['new_username'])
64-
instance.save()
92+
instance.username = validated_data["new_username"]
93+
instance.save()
94+
return instance
6595

96+
97+
class WrittableEdxappEmailSerializer(WrittableEdxappUserSerializer):
98+
"""
99+
Handles the serialization of the data required to update the email of an edxapp user.
100+
"""
101+
102+
new_email = serializers.EmailField(
103+
required=True,
104+
allow_blank=False,
105+
allow_null=False,
106+
)
107+
108+
def update(self, instance, validated_data):
109+
"""
110+
Updates the email of the edxapp User.
111+
"""
112+
instance.email = validated_data["new_email"]
113+
instance.save()
66114
return instance
67115

68116

eox_core/api/support/v1/tests/integration/test_views.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class SupportAPIRequestMixin:
2222
UPDATE_USERNAME_URL = (
2323
f"{settings['EOX_CORE_API_BASE']}{reverse('eox-support-api:eox-support-api:edxapp-replace-username')}"
2424
)
25+
UPDATE_EMAIL_URL = (
26+
f"{settings['EOX_CORE_API_BASE']}{reverse('eox-support-api:eox-support-api:edxapp-replace-email')}"
27+
)
2528
OAUTH_APP_URL = (
2629
f"{settings['EOX_CORE_API_BASE']}{reverse('eox-support-api:eox-support-api:edxapp-oauth-application')}"
2730
)
@@ -53,6 +56,20 @@ def update_username(self, tenant: dict, params: dict | None = None, data: dict |
5356
"""
5457
return make_request(tenant, "PATCH", url=self.UPDATE_USERNAME_URL, params=params, data=data)
5558

59+
def update_email(self, tenant: dict, params: dict | None = None, data: dict | None = None) -> requests.Response:
60+
"""
61+
Update an edxapp user's email in a tenant.
62+
63+
Args:
64+
tenant (dict): The tenant data.
65+
params (dict, optional): The query parameters for the request.
66+
data (dict, optional): The body data for the request.
67+
68+
Returns:
69+
requests.Response: The response object.
70+
"""
71+
return make_request(tenant, "PATCH", url=self.UPDATE_EMAIL_URL, params=params, data=data)
72+
5673
def create_oauth_application(self, tenant: dict, data: dict | None = None) -> requests.Response:
5774
"""
5875
Create an oauth application in a tenant.
@@ -284,6 +301,153 @@ def test_update_username_in_tenant_missing_body(self) -> None:
284301
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
285302
self.assertEqual(response_data, {"new_username": ["This field is required."]})
286303

304+
@ddt.data("username", "email")
305+
def test_update_email_in_tenant_success(self, query_param: str) -> None:
306+
"""
307+
Test update an edxapp user's email in a tenant.
308+
309+
Open edX definitions tested:
310+
- `get_edxapp_user`
311+
- `check_edxapp_account_conflicts`
312+
- `get_user_read_only_serializer`
313+
314+
Expected result:
315+
- The status code is 200.
316+
- The response indicates the email was updated successfully.
317+
- The user is found in the tenant with the new email.
318+
- The user cannot be found with the old email.
319+
"""
320+
data = next(FAKE_USER_DATA)
321+
self.create_user(self.tenant_x, data)
322+
old_email = data["email"]
323+
new_email = f"new-email-{query_param}@example.com"
324+
325+
response = self.update_email(self.tenant_x, {query_param: data[query_param]}, {"new_email": new_email})
326+
response_data = response.json()
327+
get_response = self.get_user(self.tenant_x, {"email": new_email})
328+
get_response_data = get_response.json()
329+
old_email_response = self.get_user(self.tenant_x, {"email": old_email})
330+
331+
self.assertEqual(response.status_code, status.HTTP_200_OK)
332+
self.assertEqual(response_data["email"], new_email)
333+
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
334+
self.assertEqual(get_response_data["email"], new_email)
335+
self.assertEqual(old_email_response.status_code, status.HTTP_404_NOT_FOUND)
336+
337+
def test_update_email_in_tenant_conflict(self) -> None:
338+
"""
339+
Test update an edxapp user's email to an email that already exists.
340+
341+
Open edX definitions tested:
342+
- `get_edxapp_user`
343+
- `check_edxapp_account_conflicts`
344+
345+
Expected result:
346+
- The status code is 400.
347+
- The response indicates the email already exists.
348+
"""
349+
data1 = next(FAKE_USER_DATA)
350+
data2 = next(FAKE_USER_DATA)
351+
self.create_user(self.tenant_x, data1)
352+
self.create_user(self.tenant_x, data2)
353+
354+
response = self.update_email(
355+
self.tenant_x,
356+
{"username": data1["username"]},
357+
{"new_email": data2["email"]},
358+
)
359+
response_data = response.json()
360+
361+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
362+
self.assertEqual(
363+
response_data["detail"],
364+
["An account already exists with the provided username or email."],
365+
)
366+
367+
def test_update_email_in_tenant_not_found(self) -> None:
368+
"""
369+
Test update an edxapp user's email in a tenant that does not exist.
370+
371+
Open edX definitions tested:
372+
- `get_edxapp_user`
373+
374+
Expected result:
375+
- The status code is 404.
376+
- The response indicates the user was not found in the tenant.
377+
"""
378+
response = self.update_email(
379+
self.tenant_x,
380+
{"username": "user-not-found"},
381+
{"new_email": "[email protected]"},
382+
)
383+
response_data = response.json()
384+
385+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
386+
self.assertEqual(
387+
response_data["detail"],
388+
f"No user found by {{'username': 'user-not-found'}} on site {self.tenant_x['domain']}.",
389+
)
390+
391+
@ddt.data("username", "email")
392+
def test_update_user_email_of_another_tenant(self, query_param: str) -> None:
393+
"""
394+
Test update an edxapp user's email of another tenant.
395+
396+
Open edX definitions tested:
397+
- `get_edxapp_user`
398+
399+
Expected result:
400+
- The status code is 404.
401+
- The response indicates the user was not found in the tenant.
402+
"""
403+
data = next(FAKE_USER_DATA)
404+
self.create_user(self.tenant_x, data)
405+
new_email = f"new-email-{query_param}@example.com"
406+
407+
response = self.update_email(
408+
self.tenant_y,
409+
{query_param: data[query_param]},
410+
{"new_email": new_email},
411+
)
412+
response_data = response.json()
413+
414+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
415+
self.assertEqual(
416+
response_data["detail"],
417+
f"No user found by {{'{query_param}': '{data[query_param]}'}} on site {self.tenant_y['domain']}.",
418+
)
419+
420+
def test_update_email_in_tenant_missing_params(self) -> None:
421+
"""
422+
Test update an edxapp user's email in a tenant without providing the username or email.
423+
424+
Expected result:
425+
- The status code is 400.
426+
- The response indicates the username or email is required.
427+
"""
428+
response = self.update_email(self.tenant_x)
429+
response_data = response.json()
430+
431+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
432+
self.assertEqual(response_data, ["Email or username needed"])
433+
434+
def test_update_email_in_tenant_missing_body(self) -> None:
435+
"""
436+
Test update an edxapp user's email in a tenant without providing the new email.
437+
438+
Expected result:
439+
- The status code is 400.
440+
- The response indicates the new email is required.
441+
"""
442+
data = next(FAKE_USER_DATA)
443+
self.create_user(self.tenant_x, data)
444+
445+
response = self.update_email(self.tenant_x, params={"username": data["username"]})
446+
response_data = response.json()
447+
448+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
449+
self.assertEqual(response_data, {"new_email": ["This field is required."]})
450+
287451

288452
@ddt.ddt
289453
class TestOauthApplicationAPIIntegration(SupportAPIRequestMixin, BaseIntegrationTest, UsersAPIRequestMixin):

eox_core/api/support/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
urlpatterns = [ # pylint: disable=invalid-name
1010
re_path(r'^user/$', views.EdxappUser.as_view(), name='edxapp-user'),
1111
re_path(r'^user/replace-username/$', views.EdxappReplaceUsername.as_view(), name='edxapp-replace-username'),
12+
re_path(r'^user/replace-email/$', views.EdxappReplaceEmail.as_view(), name='edxapp-replace-email'),
1213
re_path(r'^oauth-application/$', views.OauthApplicationAPIView.as_view(), name='edxapp-oauth-application'),
1314
]

0 commit comments

Comments
 (0)