diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 330da348da..3ce096b232 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 12.0 +**Feature level 470** + +* [`POST /remove_client_device`](/api/remove-client-device): + Added a new endpoint to remove a registered device. + **Feature level 469** * `PATCH /realm`, [`POST /register`](/api/register-queue), diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 13831d5caa..1d94b11cda 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -161,6 +161,7 @@ * [Fetch an API key (production)](/api/fetch-api-key) * [Fetch an API key (development only)](/api/dev-fetch-api-key) * [Register a logged-in device](/api/register-client-device) +* [Remove a registered device](/api/remove-client-device) * [Send an E2EE test notification to mobile device(s)](/api/e2ee-test-notify) * [Register E2EE push device](/api/register-push-device) * [Register E2EE push device to bouncer](/api/register-remote-push-device) diff --git a/version.py b/version.py index 316bad2f7e..0875e20c76 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # https://zulip.readthedocs.io/en/latest/documentation/api.html#step-by-step-guide # Also available at docs/documentation/api.md. -API_FEATURE_LEVEL = 469 +API_FEATURE_LEVEL = 470 # 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/actions/devices.py b/zerver/actions/devices.py index a1c5291789..c4cc878154 100644 --- a/zerver/actions/devices.py +++ b/zerver/actions/devices.py @@ -1,3 +1,4 @@ +from zerver.lib.devices import check_device_id from zerver.models.devices import Device from zerver.models.users import UserProfile from zerver.tornado.django_api import send_event_on_commit @@ -12,3 +13,15 @@ def do_register_device(user_profile: UserProfile) -> int: ) send_event_on_commit(user_profile.realm, event, [user_profile.id]) return device.id + + +def do_remove_device(user_profile: UserProfile, device_id: int) -> None: + device = check_device_id(device_id, user_profile.id) + device.delete() + + event = dict( + type="device", + op="remove", + device_id=device_id, + ) + send_event_on_commit(user_profile.realm, event, [user_profile.id]) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index fc6c9d545a..b03bc0fad7 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -31,6 +31,7 @@ from zerver.lib.event_types import ( EventDefaultStreams, EventDeleteMessage, EventDeviceAdd, + EventDeviceRemove, EventDeviceUpdate, EventDirectMessage, EventDraftsAdd, @@ -177,6 +178,7 @@ check_custom_profile_fields = make_checker(EventCustomProfileFields) check_default_stream_groups = make_checker(EventDefaultStreamGroups) check_default_streams = make_checker(EventDefaultStreams) check_device_add = make_checker(EventDeviceAdd) +check_device_remove = make_checker(EventDeviceRemove) check_device_update = make_checker(EventDeviceUpdate) check_direct_message = make_checker(EventDirectMessage) check_draft_add = make_checker(EventDraftsAdd) diff --git a/zerver/lib/event_types.py b/zerver/lib/event_types.py index 5770bf9d1b..b34003c82c 100644 --- a/zerver/lib/event_types.py +++ b/zerver/lib/event_types.py @@ -296,6 +296,12 @@ class EventDeviceAdd(BaseEvent): device_id: int +class EventDeviceRemove(BaseEvent): + type: Literal["device"] + op: Literal["remove"] + device_id: int + + class EventDeviceUpdate(BaseEvent): type: Literal["device"] op: Literal["update"] diff --git a/zerver/lib/events.py b/zerver/lib/events.py index be753e4e81..735006d4b4 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -2016,6 +2016,8 @@ def apply_event( "push_token_last_updated_timestamp": None, "push_registration_error_code": None, } + elif event["op"] == "remove": + del state["devices"][str(event["device_id"])] elif event["op"] == "update": if "push_key_id" in event: state["devices"][str(event["device_id"])]["push_key_id"] = event["push_key_id"] diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index f41440ef5a..88bf684e15 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -1737,6 +1737,21 @@ def register_device(client: Client) -> None: validate_against_openapi_schema(result, "/register_client_device", "post", "200") +@openapi_test_function("/remove_client_device:post") +def remove_device(client: Client) -> None: + # First register a device to get a device_id. + result = client.call_endpoint(url="/register_client_device", method="POST") + device_id = result["device_id"] + + # {code_example|start} + # Remove a registered device. + request = {"device_id": device_id} + result = client.call_endpoint(url="/remove_client_device", method="POST", request=request) + # {code_example|end} + assert_success_response(result) + validate_against_openapi_schema(result, "/remove_client_device", "post", "200") + + @openapi_test_function("/typing:post") def set_typing_status(client: Client) -> None: ensure_users([10, 11], ["hamlet", "iago"]) @@ -2066,6 +2081,7 @@ def test_users(client: Client, owner_client: Client) -> None: remove_fcm_token(client) register_push_device(client) register_device(client) + remove_device(client) def test_streams(client: Client, nonadmin_client: Client) -> None: diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 561df561a7..6e560832cf 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -13184,6 +13184,36 @@ paths: description: | The ID of the newly registered device. example: {"msg": "", "device_id": 2, "result": "success"} + /remove_client_device: + post: + operationId: remove-client-device + summary: Remove a registered device + tags: ["mobile"] + description: | + Mobile devices use this endpoint to remove their device record + registered using [`POST /register_client_device`](/api/register-client-device) + when the user logs out. + + This endpoint is currently not useful for clients other than mobile. + + **Changes**: New in Zulip 12.0 (feature level 470). + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + device_id: + description: | + The ID of the device to remove. + type: integer + example: 2 + required: + - device_id + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" /user_topics: post: operationId: update-user-topic diff --git a/zerver/tests/test_devices.py b/zerver/tests/test_devices.py index a12a3e90c1..4d3450be1c 100644 --- a/zerver/tests/test_devices.py +++ b/zerver/tests/test_devices.py @@ -14,3 +14,17 @@ class TestDeviceRegistration(ZulipTestCase): device = Device.objects.get(id=data["device_id"]) self.assertEqual(device.user_id, user.id) + + def test_remove_device(self) -> None: + user = self.example_user("hamlet") + + self.assertEqual(Device.objects.count(), 0) + + result = self.api_post(user, "/api/v1/register_client_device") + data = self.assert_json_success(result) + device = Device.objects.get(id=data["device_id"]) + + result = self.api_post(user, "/api/v1/remove_client_device", {"device_id": device.id}) + self.assert_json_success(result) + + self.assertEqual(Device.objects.count(), 0) diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 4a39b8eb85..bff2307532 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -53,7 +53,7 @@ from zerver.actions.default_streams import ( do_remove_streams_from_default_stream_group, lookup_default_stream_groups, ) -from zerver.actions.devices import do_register_device +from zerver.actions.devices import do_register_device, do_remove_device from zerver.actions.invites import ( do_create_multiuse_invite_link, do_invite_users, @@ -177,6 +177,7 @@ from zerver.lib.event_schema import ( check_default_streams, check_delete_message, check_device_add, + check_device_remove, check_device_update, check_direct_message, check_draft_add, @@ -4287,6 +4288,12 @@ class NormalActionsTest(BaseAction): do_register_device(self.user_profile) check_device_add("events[0]", events[0]) + def test_remove_device(self) -> None: + device = Device.objects.create(user=self.user_profile) + with self.verify_action() as events: + do_remove_device(self.user_profile, device.id) + check_device_remove("events[0]", events[0]) + def test_register_push_device(self) -> None: self.login_user(self.user_profile) device = Device.objects.create(user=self.user_profile) diff --git a/zerver/views/devices.py b/zerver/views/devices.py index ccafddbc8d..fbdc3dce17 100644 --- a/zerver/views/devices.py +++ b/zerver/views/devices.py @@ -1,8 +1,9 @@ from django.http import HttpRequest, HttpResponse +from pydantic import Json -from zerver.actions.devices import do_register_device +from zerver.actions.devices import do_register_device, do_remove_device from zerver.lib.response import json_success -from zerver.lib.typed_endpoint import typed_endpoint_without_parameters +from zerver.lib.typed_endpoint import typed_endpoint, typed_endpoint_without_parameters from zerver.models.users import UserProfile @@ -10,3 +11,14 @@ from zerver.models.users import UserProfile def register_device(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: device_id = do_register_device(user_profile) return json_success(request, data={"device_id": device_id}) + + +@typed_endpoint +def remove_device( + request: HttpRequest, + user_profile: UserProfile, + *, + device_id: Json[int], +) -> HttpResponse: + do_remove_device(user_profile, device_id) + return json_success(request) diff --git a/zproject/urls.py b/zproject/urls.py index 4d9731d9d3..30f8331bad 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -61,7 +61,7 @@ from zerver.views.custom_profile_fields import ( update_realm_custom_profile_field, update_user_custom_profile_data, ) -from zerver.views.devices import register_device +from zerver.views.devices import register_device, remove_device from zerver.views.digest import digest_page from zerver.views.documentation import MarkdownDirectoryView, integrations_catalog, integrations_doc from zerver.views.drafts import create_drafts, delete_draft, edit_draft, fetch_drafts @@ -612,6 +612,7 @@ v1_api_and_json_patterns = [ rest_path("export/realm/", DELETE=delete_realm_export), rest_path("export/realm/consents", GET=get_users_export_consents), rest_path("register_client_device", POST=register_device), + rest_path("remove_client_device", POST=remove_device), ] # These views serve pages (HTML). As such, their internationalization