devices: Add an endpoint to remove Device records.

This commit adds a `POST /remove_client_device`
endpoint to remove a Device record.

Helps mobile clients to cleanup their Device records
when user logs out.

Signed-off-by: Prakhar Pratyush <prakhar@zulip.com>
This commit is contained in:
Prakhar Pratyush 2026-02-25 18:25:11 +05:30 committed by Tim Abbott
parent 1b340c6b74
commit b1da50bd7f
13 changed files with 114 additions and 5 deletions

View File

@ -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),

View File

@ -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)

View File

@ -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

View File

@ -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])

View File

@ -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)

View File

@ -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"]

View File

@ -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"]

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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/<int:export_id>", 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