diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 69618ce4cd..eb9f006102 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -66,6 +66,7 @@ * [Get all users](/api/get-users) * [Create a user](/api/create-user) * [Update a user](/api/update-user) +* [Update a user by email](/api/update-user-by-email) * [Deactivate a user](/api/deactivate-user) * [Deactivate own user](/api/deactivate-own-user) * [Reactivate a user](/api/reactivate-user) diff --git a/version.py b/version.py index 3e74f3ec6a..d0ee0f4da6 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 313 # Last bumped for adding `new_email` to /users/{user_id} +API_FEATURE_LEVEL = 313 # Last bumped for adding `new_email` to /users/{user_id} and the new PATCH /users/{email} endpoint # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/zerver/openapi/curl_param_value_generators.py b/zerver/openapi/curl_param_value_generators.py index 7d0ecc416d..107388e9a8 100644 --- a/zerver/openapi/curl_param_value_generators.py +++ b/zerver/openapi/curl_param_value_generators.py @@ -23,7 +23,7 @@ from zerver.lib.upload import upload_message_attachment from zerver.lib.users import get_api_key from zerver.models import Client, Message, NamedUserGroup, UserPresence from zerver.models.realms import get_realm -from zerver.models.users import get_user +from zerver.models.users import UserProfile, get_user from zerver.openapi.openapi import Parameter GENERATOR_FUNCTIONS: dict[str, Callable[[], dict[str, object]]] = {} @@ -252,6 +252,19 @@ def create_user() -> dict[str, object]: } +@openapi_param_value_generator(["/users/{email]:patch", "/users/{user_id}:patch"]) +def new_email_value() -> dict[str, object]: + count = 0 + exists = True + while exists: + email = f"new{count}@zulip.com" + exists = UserProfile.objects.filter(delivery_email=email).exists() + count += 1 + return { + "new_email": email, + } + + @openapi_param_value_generator(["/user_groups/create:post"]) def create_user_group_data() -> dict[str, object]: return { diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 268d9394c8..8d712299ee 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -12634,6 +12634,52 @@ paths: "delivery_email": null, }, } + patch: + operationId: update-user-by-email + summary: Update a user by email + tags: ["users"] + x-requires-administrator: true + description: | + Administrative endpoint to update the details of another user in the organization by their email address. + Works the same way as [`PATCH /users/{user_id}`](/api/update-user) but fetching the target user by their + real email address. + + The requester needs to have permission to view the target user's real email address, subject to the + user's email address visibility setting. Otherwise, the dummy address of the format + `user{id}@{realm.host}` needs be used. This follows the same rules as `GET /users/{email}`. + + **Changes**: New in Zulip 10.0 (feature level 313). + parameters: + - name: email + in: path + required: true + description: | + The email address of the user, specified following the same rules as + [`GET /users/{email}`](/api/get-user-by-email). + schema: + type: string + example: hamlet@zulip.com + requestBody: + $ref: "#/components/requestBodies/UpdateUser" + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" + + "400": + description: Bad request. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CodedError" + - example: + { + "result": "error", + "msg": "Guests cannot be organization administrators", + "code": "BAD_REQUEST", + } + description: | + A typical unsuccessful JSON response: /users/{user_id}: get: operationId: get-user @@ -12738,62 +12784,7 @@ paths: parameters: - $ref: "#/components/parameters/UserId" requestBody: - required: false - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - full_name: - description: | - The user's full name. - - **Changes**: Removed unnecessary JSON-encoding of this parameter in - Zulip 5.0 (feature level 106). - type: string - example: NewName - role: - description: | - New [role](/api/roles-and-permissions) for the user. Roles are encoded as: - - - Organization owner: 100 - - Organization administrator: 200 - - Organization moderator: 300 - - Member: 400 - - Guest: 600 - - Only organization owners can add or remove the owner role. - - The owner role cannot be removed from the only organization owner. - - **Changes**: New in Zulip 3.0 (feature level 8), replacing the previous - pair of `is_admin` and `is_guest` boolean parameters. Organization moderator - role added in Zulip 4.0 (feature level 60). - type: integer - example: 400 - profile_data: - description: | - A dictionary containing the to be updated custom profile field data for the user. - type: array - items: - type: object - example: - [{"id": 4, "value": "0"}, {"id": 5, "value": "1909-04-05"}] - new_email: - description: | - New email address for the user. Requires the user making - the request to be an organization administrator and - additionally have the `.can_change_user_emails` special - permission. - - **Changes**: New in Zulip 10.0 (feature level 313). - type: string - example: username@example.com - encoding: - role: - contentType: application/json - profile_data: - contentType: application/json + $ref: "#/components/requestBodies/UpdateUser" responses: "200": @@ -24475,6 +24466,67 @@ components: allOf: - $ref: "#/components/schemas/IgnoredParametersSuccess" + ################ + # Shared request bodies + ################ + requestBodies: + UpdateUser: + required: false + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + full_name: + description: | + The user's full name. + + **Changes**: Removed unnecessary JSON-encoding of this parameter in + Zulip 5.0 (feature level 106). + type: string + example: NewName + role: + description: | + New [role](/api/roles-and-permissions) for the user. Roles are encoded as: + + - Organization owner: 100 + - Organization administrator: 200 + - Organization moderator: 300 + - Member: 400 + - Guest: 600 + + Only organization owners can add or remove the owner role. + + The owner role cannot be removed from the only organization owner. + + **Changes**: New in Zulip 3.0 (feature level 8), replacing the previous + pair of `is_admin` and `is_guest` boolean parameters. Organization moderator + role added in Zulip 4.0 (feature level 60). + type: integer + example: 400 + profile_data: + description: | + A dictionary containing the updated custom profile field data for the user. + type: array + items: + type: object + example: + [{"id": 4, "value": "0"}, {"id": 5, "value": "1909-04-05"}] + new_email: + description: | + New email address for the user. Requires the user making the request + to be an organization owner and additionally have the `.can_change_user_emails` + special permission. + + **Changes**: New in Zulip 10.0 (feature level 285). + type: string + example: username@example.com + encoding: + role: + contentType: application/json + profile_data: + contentType: application/json + #################### # Shared parameters #################### diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index bbdf86fa96..d898093105 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -1121,6 +1121,63 @@ class BulkCreateUserTest(ZulipTestCase): ) +class UpdateUserByEmailEndpointTest(ZulipTestCase): + def test_update_user_by_email(self) -> None: + self.login("iago") + hamlet = self.example_user("hamlet") + do_change_user_setting( + hamlet, + "email_address_visibility", + UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE, + acting_user=None, + ) + + result = self.client_patch( + f"/json/users/{hamlet.delivery_email}", + dict(full_name="Newname"), + ) + self.assert_json_success(result) + hamlet.refresh_from_db() + self.assertEqual(hamlet.full_name, "Newname") + + do_change_user_setting( + hamlet, + "email_address_visibility", + UserProfile.EMAIL_ADDRESS_VISIBILITY_MEMBERS, + acting_user=None, + ) + result = self.client_patch( + f"/json/users/{hamlet.delivery_email}", + dict(full_name="Newname2"), + ) + self.assert_json_success(result) + hamlet.refresh_from_db() + self.assertEqual(hamlet.full_name, "Newname2") + + do_change_user_setting( + hamlet, + "email_address_visibility", + UserProfile.EMAIL_ADDRESS_VISIBILITY_NOBODY, + acting_user=None, + ) + result = self.client_patch( + f"/json/users/{hamlet.delivery_email}", + dict(full_name="Newname2"), + ) + self.assert_json_error(result, "No such user") + + # The dummy email can be used, when we don't have access + # to the target's email address. + dummy_email = hamlet.email + result = self.client_patch( + f"/json/users/{dummy_email}", + dict(full_name="Newname3"), + ) + self.assert_json_success(result) + hamlet.refresh_from_db() + self.assertEqual(hamlet.full_name, "Newname3") + + class AdminChangeUserEmailTest(ZulipTestCase): def test_change_user_email_backend(self) -> None: cordelia = self.example_user("cordelia") diff --git a/zerver/views/users.py b/zerver/views/users.py index b16f84a298..6a45fefef4 100644 --- a/zerver/views/users.py +++ b/zerver/views/users.py @@ -202,7 +202,7 @@ class ProfileDataElement(BaseModel): @typed_endpoint @transaction.atomic(durable=True) -def update_user_backend( +def update_user_by_id_api( request: HttpRequest, user_profile: UserProfile, *, @@ -215,7 +215,53 @@ def update_user_backend( target = access_user_by_id( user_profile, user_id, allow_deactivated=True, allow_bots=True, for_admin=True ) + return update_user_backend( + request, + user_profile, + target, + full_name=full_name, + role=role, + profile_data=profile_data, + new_email=new_email, + ) + +@typed_endpoint +@transaction.atomic(durable=True) +def update_user_by_email_api( + request: HttpRequest, + user_profile: UserProfile, + *, + email: PathOnly[str], + full_name: str | None = None, + role: Json[RoleParamType] | None = None, + profile_data: Json[list[ProfileDataElement]] | None = None, + new_email: str | None = None, +) -> HttpResponse: + target = access_user_by_email( + user_profile, email, allow_deactivated=True, allow_bots=True, for_admin=True + ) + return update_user_backend( + request, + user_profile, + target, + full_name=full_name, + role=role, + profile_data=profile_data, + new_email=new_email, + ) + + +def update_user_backend( + request: HttpRequest, + user_profile: UserProfile, + target: UserProfile, + *, + full_name: str | None = None, + role: Json[RoleParamType] | None = None, + profile_data: Json[list[ProfileDataElement]] | None = None, + new_email: str | None = None, +) -> HttpResponse: if new_email is not None and ( not user_profile.can_change_user_emails or not user_profile.is_realm_admin ): diff --git a/zproject/urls.py b/zproject/urls.py index 49f1c9710b..0703e9f104 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -232,7 +232,8 @@ from zerver.views.users import ( patch_bot_backend, reactivate_user_backend, regenerate_bot_api_key, - update_user_backend, + update_user_by_email_api, + update_user_by_id_api, ) from zerver.views.video_calls import ( complete_zoom_user, @@ -316,11 +317,11 @@ v1_api_and_json_patterns = [ rest_path( "users/", GET=get_members_backend, - PATCH=update_user_backend, + PATCH=update_user_by_id_api, DELETE=deactivate_user_backend, ), rest_path("users//subscriptions/", GET=get_subscription_backend), - rest_path("users/", GET=get_user_by_email), + rest_path("users/", GET=get_user_by_email, PATCH=update_user_by_email_api), rest_path("bots", GET=get_bots_backend, POST=add_bot_backend), rest_path("bots//api_key/regenerate", POST=regenerate_bot_api_key), rest_path("bots/", PATCH=patch_bot_backend, DELETE=deactivate_bot_backend),