From 431a6d43b1113987362fb464bafcb83bb8c17815 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Wed, 15 Jul 2026 16:40:48 -0300 Subject: [PATCH 01/11] feat: API to create users --- .../djangoapps/user_api/tests/test_views.py | 67 ++++++++++++++++ openedx/core/djangoapps/user_api/urls.py | 3 + openedx/core/djangoapps/user_api/views.py | 80 ++++++++++++++++++- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 143002b30df6..07e7e2a831b7 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -12,6 +12,7 @@ from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.api.test_utils import TEST_API_KEY, ApiTestCase from openedx.core.lib.time_zone_utils import get_display_time_zone +from rest_framework import status from xmodule.modulestore.tests.django_utils import ( SharedModuleStoreTestCase, # pylint: disable=wrong-import-order ) @@ -649,3 +650,69 @@ def test_get_all_common_timezones(self): assert len(results) == len(common_timezones) for time_zone_info in results: self._assert_time_zone_is_valid(time_zone_info) + + +@ddt.ddt +class TestUserModifyAPI(ApiTestCase): + """ Test cases covering the user modification API """ + + PATH = '/api/user/v1/modify' + + DATA = { + 'name': 'Test User', + 'username': 'testuser', + 'password': 'Password1234', + 'email': 'e@mail.com', + 'terms_of_service': 'true', + 'honor_code': 'true', + } + + def setUp(self): + """ Create a test user and log in with that user """ + super().setUp() + + self.test_user = UserFactory.create( + username="user", + email="user@example.com", + password="pass", + is_staff=True, + is_superuser=True + ) + self.client.login(username="user", password="pass") + + @override_settings(ENFORCE_SAFE_SESSIONS=False) + def test_create_new_user_success(self): + """ Test creating a user with valid information """ + response = self.client.post(self.PATH, self.DATA) + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED + ) + + @ddt.data('username', 'password', 'email', 'terms_of_service', 'honor_code') + def test_create_new_user_error_missing_info(self, missing_field): + """ Test creating a user with missing required information """ + data = self.DATA.copy() + data.pop(missing_field) + response = self.client.post(self.PATH, data) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + + @ddt.data( + ('email', 'invalid-email'), + ('email', 'user@example.com'), + ('username', 'user'), + ) + @ddt.unpack + def test_create_new_user_error_invalid_attribute(self, field, value): + """ Test creating a user with an invalid attribute """ + data = self.DATA.copy() + data[field] = value + response = self.client.post(self.PATH, data) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 5928a1d4f422..60b6777b76fb 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -178,6 +178,9 @@ path('v1/accounts/replace_usernames/', UsernameReplacementView.as_view(), name='username_replacement' ), + path('v1/modify', user_api_views.UserModifyView.as_view(), + name='user_account_modify' + ), re_path( fr'^v1/preferences/{settings.USERNAME_PATTERN}$', PreferencesView.as_view(), diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 2504c38db9bd..77ea49eb1484 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -1,19 +1,23 @@ """HTTP end-points for the User API. """ -from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie from django_filters.rest_framework import DjangoFilterBackend +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError from opaque_keys.edx import locator from opaque_keys.edx.keys import CourseKey from rest_framework import generics, status, viewsets -from rest_framework.exceptions import ParseError -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView +from rest_framework.exceptions import ParseError, ValidationError +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.views import APIView, Response +from common.djangoapps.student.helpers import AccountValidationError from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones, update_email_opt_in @@ -22,6 +26,8 @@ UserPreferenceSerializer, UserSerializer, ) +from openedx.core.djangoapps.user_authn.views.register import create_account_with_params +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import ApiKeyHeaderPermission from openedx.core.lib.api.view_utils import require_post_params @@ -161,3 +167,69 @@ class CountryTimeZoneListView(generics.ListAPIView): def get_queryset(self): country_code = self.request.GET.get("country_code", None) return get_country_time_zones(country_code) + + +class UserModifyView(APIView): + """ + **Use Cases** + + Creates a user with email and username, or updates user information by + email or username. + + **Example Requests** + + POST /api/user/v1/modify/ + + PATCH /api/user/v1/modify/ + + **Example GET Response** + + If the request is successful, an HTTP 200 "OK" response is returned + along with the user id and username, e.g.: + + { + "user_id": 5, + "username": "newuser" + } + + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAdminUser,) + + @method_decorator(transaction.non_atomic_requests) + def dispatch(self, request, *args, **kwargs): + """ + Decorate with `non_atomic_requests` to work on newer versions of platform. + """ + return super().dispatch(request, *args, **kwargs) + + def post(self, request): + """ + Create a user with email and username. + """ + try: + user = create_account_with_params(request, request.data) + except ( + AccountValidationError, + ValueError, + ValidationError, + DjangoValidationError, + ) as e: + if isinstance(e, ValidationError): + message = e.detail + else: + message = str(e) + return Response( + data={"error_message": message}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + data={"user_id": user.id, "username": user.username}, + status=status.HTTP_201_CREATED, + ) From 58161c3ba182be9c28642eef344bb4fa6ecd7a07 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Wed, 15 Jul 2026 16:41:18 -0300 Subject: [PATCH 02/11] feat: API to modify users --- openedx/core/djangoapps/user_api/views.py | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 77ea49eb1484..a0483cabfbef 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -233,3 +233,51 @@ def post(self, request): data={"user_id": user.id, "username": user.username}, status=status.HTTP_201_CREATED, ) + + def patch(self, request): + """ + Update user information by email or username. + """ + try: + user = self._get_user_by_email_or_username(request) + if not user: + return Response( + data={"error_message": "User not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + user = UserSerializer().update(user, request.data) + except ( + AccountValidationError, + ValueError, + ValidationError, + DjangoValidationError, + ) as e: + if isinstance(e, ValidationError): + message = e.detail + else: + message = str(e) + return Response( + data={"error_message": message}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + data={"user_id": user.id, "username": user.username}, + status=status.HTTP_200_OK, + ) + + @staticmethod + def _get_user_by_email_or_username(request): + """ + Get a user by email and/or username. + + Returns None if one doesn't exist. + """ + + email = request.data.get("email") + username = request.data.get("username") + query = {} + if email: + query["email"] = email + if username: + query["username"] = username + return User.objects.filter(**query).first() From c01b3f27976da85c126e766db707c86e46237789 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Wed, 15 Jul 2026 17:21:22 -0300 Subject: [PATCH 03/11] fix: improve unit tests --- .../djangoapps/user_api/tests/test_views.py | 106 ++++++++++++++++-- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 07e7e2a831b7..8f90cc06b1a5 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1,5 +1,9 @@ """Tests for the user API at the HTTP request level. """ +import pytest +import json +from unittest.mock import patch + import ddt import pytest from django.test.utils import override_settings @@ -656,15 +660,15 @@ def test_get_all_common_timezones(self): class TestUserModifyAPI(ApiTestCase): """ Test cases covering the user modification API """ - PATH = '/api/user/v1/modify' + PATH = "/api/user/v1/modify" DATA = { - 'name': 'Test User', - 'username': 'testuser', - 'password': 'Password1234', - 'email': 'e@mail.com', - 'terms_of_service': 'true', - 'honor_code': 'true', + "name": "Test User", + "username": "testuser", + "password": "Password1234", + "email": "e@mail.com", + "terms_of_service": "true", + "honor_code": "true", } def setUp(self): @@ -688,8 +692,13 @@ def test_create_new_user_success(self): response.status_code, status.HTTP_201_CREATED ) + created_user = User.objects.get(username=self.DATA["username"]) + self.assertEqual( + response.json(), + {"user_id": created_user.id, "username": created_user.username}, + ) - @ddt.data('username', 'password', 'email', 'terms_of_service', 'honor_code') + @ddt.data("username", "password", "email", "terms_of_service", "honor_code") def test_create_new_user_error_missing_info(self, missing_field): """ Test creating a user with missing required information """ data = self.DATA.copy() @@ -699,14 +708,27 @@ def test_create_new_user_error_missing_info(self, missing_field): response.status_code, status.HTTP_400_BAD_REQUEST ) + self.assertIn("error_message", response.json()) + self.assertIn(missing_field, str(response.json()["error_message"])) + + def test_create_new_user_error_invalid_attribute(self): + """ Test creating a user with an invalid attribute """ + data = self.DATA.copy() + data["email"] = "invalid-email" + response = self.client.post(self.PATH, data) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST + ) + self.assertIn("error_message", response.json()) + self.assertIn("email", str(response.json()["error_message"])) @ddt.data( - ('email', 'invalid-email'), - ('email', 'user@example.com'), - ('username', 'user'), + ("email", "user@example.com", "existing account"), + ("username", "user", "already exists"), ) @ddt.unpack - def test_create_new_user_error_invalid_attribute(self, field, value): + def test_create_new_user_error_already_exists(self, field, value, error_message): """ Test creating a user with an invalid attribute """ data = self.DATA.copy() data[field] = value @@ -715,4 +737,64 @@ def test_create_new_user_error_invalid_attribute(self, field, value): response.status_code, status.HTTP_400_BAD_REQUEST ) + self.assertIn("error_message", response.json()) + self.assertIn(error_message, str(response.json()["error_message"])) + + @patch("openedx.core.djangoapps.user_api.views.UserSerializer.update") + def test_patch_user_success(self, serializer_update): + """Test updating a user with a valid lookup field""" + user = UserFactory.create( + username="patch-user", + email="patch@example.com", + ) + serializer_update.return_value = user + + response = self.client.patch( + self.PATH, + data=json.dumps({"email": user.email, "name": "Updated Name"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + {"user_id": user.id, "username": user.username}, + ) + self.assertEqual(serializer_update.call_count, 1) + + def test_patch_user_not_found(self): + """Test patch returns 404 when no user matches the lookup fields""" + response = self.client.patch( + self.PATH, + data=json.dumps({"email": "missing@example.com", "name": "Updated Name"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual( + response.json(), + {"error_message": "User not found."}, + ) + + @patch("openedx.core.djangoapps.user_api.views.UserSerializer.update") + def test_patch_user_validation_error(self, serializer_update): + """Test serializer validation errors are returned with status 400""" + user = UserFactory.create( + username="test-user", + email="patch-error@example.com", + ) + serializer_update.side_effect = ValidationError( + {"email": ["Invalid email address."]} + ) + + response = self.client.patch( + self.PATH, + data=json.dumps({"email": user.email, "name": "Updated Name"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"error_message": {"email": ["Invalid email address."]}}, + ) From 6d230bd951f8edf0986e5f6c84731083ee6be91b Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Wed, 15 Jul 2026 18:27:01 -0300 Subject: [PATCH 04/11] fix: improve unit tests --- .../core/djangoapps/user_api/tests/test_views.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 8f90cc06b1a5..d65b4b867fa7 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -740,18 +740,16 @@ def test_create_new_user_error_already_exists(self, field, value, error_message) self.assertIn("error_message", response.json()) self.assertIn(error_message, str(response.json()["error_message"])) - @patch("openedx.core.djangoapps.user_api.views.UserSerializer.update") - def test_patch_user_success(self, serializer_update): + def test_patch_user_success(self): """Test updating a user with a valid lookup field""" user = UserFactory.create( username="patch-user", email="patch@example.com", ) - serializer_update.return_value = user response = self.client.patch( self.PATH, - data=json.dumps({"email": user.email, "name": "Updated Name"}), + data=json.dumps({"email": user.email, "first_name": "Updated Name"}), content_type="application/json", ) @@ -760,13 +758,14 @@ def test_patch_user_success(self, serializer_update): response.json(), {"user_id": user.id, "username": user.username}, ) - self.assertEqual(serializer_update.call_count, 1) + user = User.objects.get(id=user.id) + self.assertEqual(user.first_name, "Updated Name") def test_patch_user_not_found(self): """Test patch returns 404 when no user matches the lookup fields""" response = self.client.patch( self.PATH, - data=json.dumps({"email": "missing@example.com", "name": "Updated Name"}), + data=json.dumps({"email": "missing@example.com", "first_name": "Updated Name"}), content_type="application/json", ) @@ -789,7 +788,7 @@ def test_patch_user_validation_error(self, serializer_update): response = self.client.patch( self.PATH, - data=json.dumps({"email": user.email, "name": "Updated Name"}), + data=json.dumps({"email": user.email, "first_name": "Updated Name"}), content_type="application/json", ) From 8606d4a0cf123e46902b0da43b289032249e3e28 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Thu, 16 Jul 2026 11:28:43 -0300 Subject: [PATCH 05/11] fix: fix tests --- openedx/core/djangoapps/user_api/tests/test_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index d65b4b867fa7..4f5de7406c18 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1,9 +1,8 @@ """Tests for the user API at the HTTP request level. """ -import pytest import json from unittest.mock import patch - +from django.contrib.auth import get_user_model import ddt import pytest from django.test.utils import override_settings @@ -17,6 +16,7 @@ from openedx.core.lib.api.test_utils import TEST_API_KEY, ApiTestCase from openedx.core.lib.time_zone_utils import get_display_time_zone from rest_framework import status +from rest_framework.exceptions import ValidationError from xmodule.modulestore.tests.django_utils import ( SharedModuleStoreTestCase, # pylint: disable=wrong-import-order ) @@ -34,6 +34,7 @@ USER_PREFERENCE_LIST_URI = "/api/user/v1/user_prefs/" ROLE_LIST_URI = "/api/user/v1/forum_roles/Moderator/users/" +User = get_user_model() class UserAPITestCase(ApiTestCase): """ From e80d96a771b918474ab70c9cb9e84643029a6c08 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Thu, 16 Jul 2026 11:37:48 -0300 Subject: [PATCH 06/11] fix: fix quality --- .../djangoapps/user_api/tests/test_views.py | 110 ++++++++---------- 1 file changed, 48 insertions(+), 62 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 4f5de7406c18..2234b28198a1 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -2,31 +2,34 @@ import json from unittest.mock import patch -from django.contrib.auth import get_user_model + import ddt import pytest +from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse from opaque_keys.edx.keys import CourseKey from pytz import common_timezones, common_timezones_set, country_timezones +from rest_framework import status +from rest_framework.exceptions import ValidationError from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.django_comment_common import models from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.api.test_utils import TEST_API_KEY, ApiTestCase from openedx.core.lib.time_zone_utils import get_display_time_zone -from rest_framework import status -from rest_framework.exceptions import ValidationError from xmodule.modulestore.tests.django_utils import ( - SharedModuleStoreTestCase, # pylint: disable=wrong-import-order -) -from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order - -from ..accounts.tests.retirement_helpers import ( # pylint: disable=unused-import - RetirementTestCase, # noqa: F401 - fake_requested_retirement, # noqa: F401 - setup_retirement_states, # noqa: F401 -) + SharedModuleStoreTestCase, +) # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( + CourseFactory, +) # pylint: disable=wrong-import-order + +from ..accounts.tests.retirement_helpers import RetirementTestCase # noqa: F401 +from ..accounts.tests.retirement_helpers import fake_requested_retirement # noqa: F401 +from ..accounts.tests.retirement_helpers import ( + setup_retirement_states, +) # pylint: disable=unused-import; noqa: F401 from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory @@ -659,7 +662,7 @@ def test_get_all_common_timezones(self): @ddt.ddt class TestUserModifyAPI(ApiTestCase): - """ Test cases covering the user modification API """ + """Test cases covering the user modification API""" PATH = "/api/user/v1/modify" @@ -673,7 +676,7 @@ class TestUserModifyAPI(ApiTestCase): } def setUp(self): - """ Create a test user and log in with that user """ + """Create a test user and log in with that user""" super().setUp() self.test_user = UserFactory.create( @@ -681,48 +684,39 @@ def setUp(self): email="user@example.com", password="pass", is_staff=True, - is_superuser=True + is_superuser=True, ) self.client.login(username="user", password="pass") @override_settings(ENFORCE_SAFE_SESSIONS=False) def test_create_new_user_success(self): - """ Test creating a user with valid information """ + """Test creating a user with valid information""" response = self.client.post(self.PATH, self.DATA) - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED - ) + assert response.status_code == status.HTTP_201_CREATED created_user = User.objects.get(username=self.DATA["username"]) - self.assertEqual( - response.json(), - {"user_id": created_user.id, "username": created_user.username}, - ) + assert response.json() == { + "user_id": created_user.id, + "username": created_user.username, + } @ddt.data("username", "password", "email", "terms_of_service", "honor_code") def test_create_new_user_error_missing_info(self, missing_field): - """ Test creating a user with missing required information """ + """Test creating a user with missing required information""" data = self.DATA.copy() data.pop(missing_field) response = self.client.post(self.PATH, data) - self.assertEqual( - response.status_code, - status.HTTP_400_BAD_REQUEST - ) - self.assertIn("error_message", response.json()) - self.assertIn(missing_field, str(response.json()["error_message"])) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_message" in response.json() + assert missing_field in str(response.json()["error_message"]) def test_create_new_user_error_invalid_attribute(self): - """ Test creating a user with an invalid attribute """ + """Test creating a user with an invalid attribute""" data = self.DATA.copy() data["email"] = "invalid-email" response = self.client.post(self.PATH, data) - self.assertEqual( - response.status_code, - status.HTTP_400_BAD_REQUEST - ) - self.assertIn("error_message", response.json()) - self.assertIn("email", str(response.json()["error_message"])) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_message" in response.json() + assert "email" in str(response.json()["error_message"]) @ddt.data( ("email", "user@example.com", "existing account"), @@ -730,16 +724,13 @@ def test_create_new_user_error_invalid_attribute(self): ) @ddt.unpack def test_create_new_user_error_already_exists(self, field, value, error_message): - """ Test creating a user with an invalid attribute """ + """Test creating a user with an invalid attribute""" data = self.DATA.copy() data[field] = value response = self.client.post(self.PATH, data) - self.assertEqual( - response.status_code, - status.HTTP_400_BAD_REQUEST - ) - self.assertIn("error_message", response.json()) - self.assertIn(error_message, str(response.json()["error_message"])) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_message" in response.json() + assert error_message in str(response.json()["error_message"]) def test_patch_user_success(self): """Test updating a user with a valid lookup field""" @@ -754,27 +745,23 @@ def test_patch_user_success(self): content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.json(), - {"user_id": user.id, "username": user.username}, - ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"user_id": user.id, "username": user.username} user = User.objects.get(id=user.id) - self.assertEqual(user.first_name, "Updated Name") + assert user.first_name == "Updated Name" def test_patch_user_not_found(self): """Test patch returns 404 when no user matches the lookup fields""" response = self.client.patch( self.PATH, - data=json.dumps({"email": "missing@example.com", "first_name": "Updated Name"}), + data=json.dumps( + {"email": "missing@example.com", "first_name": "Updated Name"} + ), content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual( - response.json(), - {"error_message": "User not found."}, - ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"error_message": "User not found."} @patch("openedx.core.djangoapps.user_api.views.UserSerializer.update") def test_patch_user_validation_error(self, serializer_update): @@ -793,8 +780,7 @@ def test_patch_user_validation_error(self, serializer_update): content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - {"error_message": {"email": ["Invalid email address."]}}, - ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "error_message": {"email": ["Invalid email address."]} + } From 7e155028396be1a94edf6647c86531761aec710b Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Thu, 16 Jul 2026 14:33:10 -0300 Subject: [PATCH 07/11] fix: fix quality --- .../core/djangoapps/user_api/tests/test_views.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 2234b28198a1..bcee1f31a51f 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -18,18 +18,12 @@ from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.api.test_utils import TEST_API_KEY, ApiTestCase from openedx.core.lib.time_zone_utils import get_display_time_zone -from xmodule.modulestore.tests.django_utils import ( - SharedModuleStoreTestCase, -) # pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import ( - CourseFactory, -) # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order from ..accounts.tests.retirement_helpers import RetirementTestCase # noqa: F401 from ..accounts.tests.retirement_helpers import fake_requested_retirement # noqa: F401 -from ..accounts.tests.retirement_helpers import ( - setup_retirement_states, -) # pylint: disable=unused-import; noqa: F401 +from ..accounts.tests.retirement_helpers import setup_retirement_states # pylint: disable=unused-import; noqa: F401 from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory @@ -660,6 +654,7 @@ def test_get_all_common_timezones(self): self._assert_time_zone_is_valid(time_zone_info) +@override_settings(ENABLE_AUTHN_REGISTER_HIBP_POLICY=False) @ddt.ddt class TestUserModifyAPI(ApiTestCase): """Test cases covering the user modification API""" From 56877299bbab31776b3c1a77c5cf729aa4f4ed43 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Thu, 16 Jul 2026 15:20:54 -0300 Subject: [PATCH 08/11] fix: fix quality --- openedx/core/djangoapps/user_api/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index bcee1f31a51f..3d6438979b4f 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -23,7 +23,7 @@ from ..accounts.tests.retirement_helpers import RetirementTestCase # noqa: F401 from ..accounts.tests.retirement_helpers import fake_requested_retirement # noqa: F401 -from ..accounts.tests.retirement_helpers import setup_retirement_states # pylint: disable=unused-import; noqa: F401 +from ..accounts.tests.retirement_helpers import setup_retirement_states # noqa: F401 from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory From a3dc11af1a63ca2889ac4da95013bc8193b68afd Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Fri, 17 Jul 2026 08:12:17 -0300 Subject: [PATCH 09/11] fix: requested changes --- openedx/core/djangoapps/user_api/views.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index a0483cabfbef..57b991fa1506 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -15,7 +15,8 @@ from rest_framework import generics, status, viewsets from rest_framework.exceptions import ParseError, ValidationError from rest_framework.permissions import IsAdminUser, IsAuthenticated -from rest_framework.views import APIView, Response +from rest_framework.response import Response +from rest_framework.views import APIView from common.djangoapps.student.helpers import AccountValidationError from openedx.core.djangoapps.django_comment_common.models import Role @@ -182,10 +183,10 @@ class UserModifyView(APIView): PATCH /api/user/v1/modify/ - **Example GET Response** + **Example POST Response** - If the request is successful, an HTTP 200 "OK" response is returned - along with the user id and username, e.g.: + If the request is successful, an HTTP 201 "Created" response is + returned along with the user id and username, e.g.: { "user_id": 5, @@ -245,7 +246,16 @@ def patch(self, request): data={"error_message": "User not found."}, status=status.HTTP_404_NOT_FOUND, ) - user = UserSerializer().update(user, request.data) + + data = request.data.copy() + # Remove email and username from the data to prevent changing them + data.pop("email", None) + data.pop("username", None) + # Remove password from the data and handle it separately + password = data.pop("password", None) + if password: + user.set_password(password) + user = UserSerializer().update(user, data) except ( AccountValidationError, ValueError, @@ -275,6 +285,8 @@ def _get_user_by_email_or_username(request): email = request.data.get("email") username = request.data.get("username") + if not email and not username: + raise ValidationError("email or username must be specified") query = {} if email: query["email"] = email From 7c14842f8f0e80788e058b4f061138bbf8a850f7 Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Fri, 17 Jul 2026 08:18:47 -0300 Subject: [PATCH 10/11] fix: requested changes --- .../djangoapps/user_api/tests/test_views.py | 18 +++++++++--------- openedx/core/djangoapps/user_api/views.py | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 3d6438979b4f..006643b49bb1 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -701,8 +701,8 @@ def test_create_new_user_error_missing_info(self, missing_field): data.pop(missing_field) response = self.client.post(self.PATH, data) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert "error_message" in response.json() - assert missing_field in str(response.json()["error_message"]) + assert "error" in response.json() + assert missing_field in str(response.json()["error"]) def test_create_new_user_error_invalid_attribute(self): """Test creating a user with an invalid attribute""" @@ -710,22 +710,22 @@ def test_create_new_user_error_invalid_attribute(self): data["email"] = "invalid-email" response = self.client.post(self.PATH, data) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert "error_message" in response.json() - assert "email" in str(response.json()["error_message"]) + assert "error" in response.json() + assert "email" in str(response.json()["error"]) @ddt.data( ("email", "user@example.com", "existing account"), ("username", "user", "already exists"), ) @ddt.unpack - def test_create_new_user_error_already_exists(self, field, value, error_message): + def test_create_new_user_error_already_exists(self, field, value, error): """Test creating a user with an invalid attribute""" data = self.DATA.copy() data[field] = value response = self.client.post(self.PATH, data) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert "error_message" in response.json() - assert error_message in str(response.json()["error_message"]) + assert "error" in response.json() + assert error in str(response.json()["error"]) def test_patch_user_success(self): """Test updating a user with a valid lookup field""" @@ -756,7 +756,7 @@ def test_patch_user_not_found(self): ) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"error_message": "User not found."} + assert response.json() == {"error": "User not found."} @patch("openedx.core.djangoapps.user_api.views.UserSerializer.update") def test_patch_user_validation_error(self, serializer_update): @@ -777,5 +777,5 @@ def test_patch_user_validation_error(self, serializer_update): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == { - "error_message": {"email": ["Invalid email address."]} + "error": {"email": ["Invalid email address."]} } diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 57b991fa1506..d0058c331879 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -226,7 +226,7 @@ def post(self, request): else: message = str(e) return Response( - data={"error_message": message}, + data={"error": message}, status=status.HTTP_400_BAD_REQUEST, ) @@ -243,7 +243,7 @@ def patch(self, request): user = self._get_user_by_email_or_username(request) if not user: return Response( - data={"error_message": "User not found."}, + data={"error": "User not found."}, status=status.HTTP_404_NOT_FOUND, ) @@ -267,7 +267,7 @@ def patch(self, request): else: message = str(e) return Response( - data={"error_message": message}, + data={"error": message}, status=status.HTTP_400_BAD_REQUEST, ) return Response( From adbefc97510927709fa7da393eb70def63e063eb Mon Sep 17 00:00:00 2001 From: Paulo Viadanna Date: Fri, 17 Jul 2026 08:28:53 -0300 Subject: [PATCH 11/11] fix: fix quality --- openedx/core/djangoapps/user_api/tests/test_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 006643b49bb1..5d90dc9e2dff 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -21,9 +21,9 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order -from ..accounts.tests.retirement_helpers import RetirementTestCase # noqa: F401 -from ..accounts.tests.retirement_helpers import fake_requested_retirement # noqa: F401 -from ..accounts.tests.retirement_helpers import setup_retirement_states # noqa: F401 +from ..accounts.tests.retirement_helpers import RetirementTestCase # pylint: disable=unused-import; noqa: F401 +from ..accounts.tests.retirement_helpers import fake_requested_retirement # pylint: disable=unused-import; noqa: F401 +from ..accounts.tests.retirement_helpers import setup_retirement_states # pylint: disable=unused-import; noqa: F401 from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory