subscription: Include archived channels in streams list.

`is_archived` field is added to the stream and types.

Include a new `archived_channeels` client capability, to allow clients
to access data on archived channels, without breaking
backwards-compatibility for existing clients that don't know how to
handle these.

Also, included `exclude_archived` parameter to `/get-streams`,
which defaults to `true` as basic clients may not be interested
in archived streams.
This commit is contained in:
sanchi-t
2024-01-18 21:08:48 +05:30
committed by Tim Abbott
parent c6fc25e5df
commit af7ebde9e4
18 changed files with 215 additions and 26 deletions

View File

@@ -20,6 +20,24 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0 ## Changes in Zulip 10.0
**Feature level 315**
* [POST /register](/api/register-queue), [`GET
/streams/{stream_id}`](/api/get-stream-by-id), [`GET
/events`](/api/get-events), [GET
/users/me/subscriptions](/api/get-subscriptions): The `is_archived`
property has been added to channel and subscription objects.
* [`GET /streams`](/api/get-streams): The new parameter
`exclude_archived` controls whether archived channels should be
returned.
* [`POST /register`](/api/register-queue): The new `archived_channels`
[client
capability](/api/register-queue#parameter-client_capabilities)
allows the client to specify whether it supports archived channels
being present in the response.
**Feature level 314** **Feature level 314**
* `PATCH /realm`, [`POST /register`](/api/register-queue), * `PATCH /realm`, [`POST /register`](/api/register-queue),

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 314 # Last bumped for create_multiuse_invite_group api changes. API_FEATURE_LEVEL = 315 # Last bumped for `is_archived`
# Bump the minor PROVISION_VERSION to indicate that folks should provision # 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 # only when going from an old version of the code to a newer version. Bump

View File

@@ -435,6 +435,7 @@ def send_subscription_add_events(
stream_weekly_traffic=stream_dict["stream_weekly_traffic"], stream_weekly_traffic=stream_dict["stream_weekly_traffic"],
subscribers=stream_subscribers, subscribers=stream_subscribers,
# Fields from Stream.API_FIELDS # Fields from Stream.API_FIELDS
is_archived=stream_dict["is_archived"],
can_remove_subscribers_group=stream_dict["can_remove_subscribers_group"], can_remove_subscribers_group=stream_dict["can_remove_subscribers_group"],
creator_id=stream_dict["creator_id"], creator_id=stream_dict["creator_id"],
date_created=stream_dict["date_created"], date_created=stream_dict["date_created"],

View File

@@ -50,6 +50,7 @@ from zerver.models import Realm, RealmUserDefault, Stream, UserProfile
# These fields are used for "stream" events, and are included in the # These fields are used for "stream" events, and are included in the
# larger "subscription" events that also contain personal settings. # larger "subscription" events that also contain personal settings.
default_stream_fields = [ default_stream_fields = [
("is_archived", bool),
("can_remove_subscribers_group", int), ("can_remove_subscribers_group", int),
("creator_id", OptionalType(int)), ("creator_id", OptionalType(int)),
("date_created", int), ("date_created", int),

View File

@@ -144,6 +144,7 @@ def fetch_initial_state_data(
linkifier_url_template: bool = False, linkifier_url_template: bool = False,
user_list_incomplete: bool = False, user_list_incomplete: bool = False,
include_deactivated_groups: bool = False, include_deactivated_groups: bool = False,
archived_channels: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""When `event_types` is None, fetches the core data powering the """When `event_types` is None, fetches the core data powering the
web app's `page_params` and `/api/v1/register` (for mobile/terminal web app's `page_params` and `/api/v1/register` (for mobile/terminal
@@ -654,6 +655,7 @@ def fetch_initial_state_data(
sub_info = gather_subscriptions_helper( sub_info = gather_subscriptions_helper(
user_profile, user_profile,
include_subscribers=include_subscribers, include_subscribers=include_subscribers,
include_archived_channels=archived_channels,
) )
else: else:
sub_info = get_web_public_subs(realm) sub_info = get_web_public_subs(realm)
@@ -787,6 +789,7 @@ def apply_events(
linkifier_url_template: bool, linkifier_url_template: bool,
user_list_incomplete: bool, user_list_incomplete: bool,
include_deactivated_groups: bool, include_deactivated_groups: bool,
archived_channels: bool = False,
) -> None: ) -> None:
for event in events: for event in events:
if fetch_event_types is not None and event["type"] not in fetch_event_types: if fetch_event_types is not None and event["type"] not in fetch_event_types:
@@ -809,6 +812,7 @@ def apply_events(
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
@@ -823,6 +827,7 @@ def apply_event(
linkifier_url_template: bool, linkifier_url_template: bool,
user_list_incomplete: bool, user_list_incomplete: bool,
include_deactivated_groups: bool, include_deactivated_groups: bool,
archived_channels: bool = False,
) -> None: ) -> None:
if event["type"] == "message": if event["type"] == "message":
state["max_message_id"] = max(state["max_message_id"], event["message"]["id"]) state["max_message_id"] = max(state["max_message_id"], event["message"]["id"])
@@ -1210,17 +1215,30 @@ def apply_event(
s for s in state["streams"] if s["stream_id"] not in deleted_stream_ids s for s in state["streams"] if s["stream_id"] not in deleted_stream_ids
] ]
state["subscriptions"] = [ if archived_channels:
stream for stream in state["subscriptions"]:
for stream in state["subscriptions"] if stream["stream_id"] in deleted_stream_ids:
if stream["stream_id"] not in deleted_stream_ids stream["is_archived"] = True
]
state["unsubscribed"] = [ for stream in state["unsubscribed"]:
stream if stream["stream_id"] in deleted_stream_ids:
for stream in state["unsubscribed"] stream["is_archived"] = True
if stream["stream_id"] not in deleted_stream_ids stream["first_message_id"] = Stream.objects.get(
] id=stream["stream_id"]
).first_message_id
else:
state["subscriptions"] = [
stream
for stream in state["subscriptions"]
if stream["stream_id"] not in deleted_stream_ids
]
state["unsubscribed"] = [
stream
for stream in state["unsubscribed"]
if stream["stream_id"] not in deleted_stream_ids
]
state["never_subscribed"] = [ state["never_subscribed"] = [
stream stream
@@ -1720,6 +1738,7 @@ class ClientCapabilities(TypedDict):
linkifier_url_template: NotRequired[bool] linkifier_url_template: NotRequired[bool]
user_list_incomplete: NotRequired[bool] user_list_incomplete: NotRequired[bool]
include_deactivated_groups: NotRequired[bool] include_deactivated_groups: NotRequired[bool]
archived_channels: NotRequired[bool]
def do_events_register( def do_events_register(
@@ -1757,6 +1776,7 @@ def do_events_register(
linkifier_url_template = client_capabilities.get("linkifier_url_template", False) linkifier_url_template = client_capabilities.get("linkifier_url_template", False)
user_list_incomplete = client_capabilities.get("user_list_incomplete", False) user_list_incomplete = client_capabilities.get("user_list_incomplete", False)
include_deactivated_groups = client_capabilities.get("include_deactivated_groups", False) include_deactivated_groups = client_capabilities.get("include_deactivated_groups", False)
archived_channels = client_capabilities.get("archived_channels", False)
if fetch_event_types is not None: if fetch_event_types is not None:
event_types_set: set[str] | None = set(fetch_event_types) event_types_set: set[str] | None = set(fetch_event_types)
@@ -1789,6 +1809,7 @@ def do_events_register(
user_avatar_url_field_optional=user_avatar_url_field_optional, user_avatar_url_field_optional=user_avatar_url_field_optional,
user_settings_object=user_settings_object, user_settings_object=user_settings_object,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
archived_channels=archived_channels,
# These presence params are a noop, because presence is not included. # These presence params are a noop, because presence is not included.
slim_presence=True, slim_presence=True,
presence_last_update_id_fetched_by_client=None, presence_last_update_id_fetched_by_client=None,
@@ -1828,6 +1849,7 @@ def do_events_register(
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
if queue_id is None: if queue_id is None:
@@ -1850,6 +1872,7 @@ def do_events_register(
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
# Apply events that came in while we were fetching initial data # Apply events that came in while we were fetching initial data

View File

@@ -156,6 +156,7 @@ def build_page_params_for_home_page_load(
linkifier_url_template=True, linkifier_url_template=True,
user_list_incomplete=True, user_list_incomplete=True,
include_deactivated_groups=True, include_deactivated_groups=True,
archived_channels=False,
) )
if user_profile is not None: if user_profile is not None:

View File

@@ -872,6 +872,7 @@ def stream_to_dict(stream: Stream, recent_traffic: dict[int, int] | None = None)
stream_weekly_traffic = None stream_weekly_traffic = None
return APIStreamDict( return APIStreamDict(
is_archived=stream.deactivated,
can_remove_subscribers_group=stream.can_remove_subscribers_group_id, can_remove_subscribers_group=stream.can_remove_subscribers_group_id,
creator_id=stream.creator_id, creator_id=stream.creator_id,
date_created=datetime_to_timestamp(stream.date_created), date_created=datetime_to_timestamp(stream.date_created),
@@ -902,6 +903,7 @@ def get_streams_for_user(
include_public: bool = True, include_public: bool = True,
include_web_public: bool = False, include_web_public: bool = False,
include_subscribed: bool = True, include_subscribed: bool = True,
exclude_archived: bool = True,
include_all_active: bool = False, include_all_active: bool = False,
include_owner_subscribed: bool = False, include_owner_subscribed: bool = False,
) -> list[Stream]: ) -> list[Stream]:
@@ -910,8 +912,11 @@ def get_streams_for_user(
include_public = include_public and user_profile.can_access_public_streams() include_public = include_public and user_profile.can_access_public_streams()
# Start out with all active streams in the realm. # Start out with all streams in the realm.
query = Stream.objects.filter(realm=user_profile.realm, deactivated=False) query = Stream.objects.filter(realm=user_profile.realm)
if exclude_archived:
query = query.filter(deactivated=False)
if include_all_active: if include_all_active:
streams = query.only(*Stream.API_FIELDS) streams = query.only(*Stream.API_FIELDS)
@@ -965,6 +970,7 @@ def do_get_streams(
include_public: bool = True, include_public: bool = True,
include_web_public: bool = False, include_web_public: bool = False,
include_subscribed: bool = True, include_subscribed: bool = True,
exclude_archived: bool = True,
include_all_active: bool = False, include_all_active: bool = False,
include_default: bool = False, include_default: bool = False,
include_owner_subscribed: bool = False, include_owner_subscribed: bool = False,
@@ -976,6 +982,7 @@ def do_get_streams(
include_public, include_public,
include_web_public, include_web_public,
include_subscribed, include_subscribed,
exclude_archived,
include_all_active, include_all_active,
include_owner_subscribed, include_owner_subscribed,
) )

View File

@@ -27,7 +27,7 @@ from zerver.lib.types import (
SubscriptionStreamDict, SubscriptionStreamDict,
) )
from zerver.models import Realm, Stream, Subscription, UserProfile from zerver.models import Realm, Stream, Subscription, UserProfile
from zerver.models.streams import get_active_streams from zerver.models.streams import get_all_streams
def get_web_public_subs(realm: Realm) -> SubscriptionInfo: def get_web_public_subs(realm: Realm) -> SubscriptionInfo:
@@ -42,6 +42,7 @@ def get_web_public_subs(realm: Realm) -> SubscriptionInfo:
subscribed = [] subscribed = []
for stream in get_web_public_streams_queryset(realm): for stream in get_web_public_streams_queryset(realm):
# Add Stream fields. # Add Stream fields.
is_archived = stream.deactivated
can_remove_subscribers_group_id = stream.can_remove_subscribers_group_id can_remove_subscribers_group_id = stream.can_remove_subscribers_group_id
creator_id = stream.creator_id creator_id = stream.creator_id
date_created = datetime_to_timestamp(stream.date_created) date_created = datetime_to_timestamp(stream.date_created)
@@ -73,6 +74,7 @@ def get_web_public_subs(realm: Realm) -> SubscriptionInfo:
wildcard_mentions_notify = True wildcard_mentions_notify = True
sub = SubscriptionStreamDict( sub = SubscriptionStreamDict(
is_archived=is_archived,
audible_notifications=audible_notifications, audible_notifications=audible_notifications,
can_remove_subscribers_group=can_remove_subscribers_group_id, can_remove_subscribers_group=can_remove_subscribers_group_id,
color=color, color=color,
@@ -115,6 +117,7 @@ def build_unsubscribed_sub_from_stream_dict(
can_remove_subscribers_group_id=stream_dict["can_remove_subscribers_group"], can_remove_subscribers_group_id=stream_dict["can_remove_subscribers_group"],
creator_id=stream_dict["creator_id"], creator_id=stream_dict["creator_id"],
date_created=timestamp_to_datetime(stream_dict["date_created"]), date_created=timestamp_to_datetime(stream_dict["date_created"]),
deactivated=stream_dict["is_archived"],
description=stream_dict["description"], description=stream_dict["description"],
first_message_id=stream_dict["first_message_id"], first_message_id=stream_dict["first_message_id"],
history_public_to_subscribers=stream_dict["history_public_to_subscribers"], history_public_to_subscribers=stream_dict["history_public_to_subscribers"],
@@ -144,6 +147,7 @@ def build_stream_dict_for_sub(
recent_traffic: dict[int, int] | None, recent_traffic: dict[int, int] | None,
) -> SubscriptionStreamDict: ) -> SubscriptionStreamDict:
# Handle Stream.API_FIELDS # Handle Stream.API_FIELDS
is_archived = raw_stream_dict["deactivated"]
can_remove_subscribers_group_id = raw_stream_dict["can_remove_subscribers_group_id"] can_remove_subscribers_group_id = raw_stream_dict["can_remove_subscribers_group_id"]
creator_id = raw_stream_dict["creator_id"] creator_id = raw_stream_dict["creator_id"]
date_created = datetime_to_timestamp(raw_stream_dict["date_created"]) date_created = datetime_to_timestamp(raw_stream_dict["date_created"])
@@ -187,6 +191,7 @@ def build_stream_dict_for_sub(
# Our caller may add a subscribers field. # Our caller may add a subscribers field.
return SubscriptionStreamDict( return SubscriptionStreamDict(
is_archived=is_archived,
audible_notifications=audible_notifications, audible_notifications=audible_notifications,
can_remove_subscribers_group=can_remove_subscribers_group_id, can_remove_subscribers_group=can_remove_subscribers_group_id,
color=color, color=color,
@@ -218,6 +223,7 @@ def build_stream_dict_for_never_sub(
raw_stream_dict: RawStreamDict, raw_stream_dict: RawStreamDict,
recent_traffic: dict[int, int] | None, recent_traffic: dict[int, int] | None,
) -> NeverSubscribedStreamDict: ) -> NeverSubscribedStreamDict:
is_archived = raw_stream_dict["deactivated"]
can_remove_subscribers_group_id = raw_stream_dict["can_remove_subscribers_group_id"] can_remove_subscribers_group_id = raw_stream_dict["can_remove_subscribers_group_id"]
creator_id = raw_stream_dict["creator_id"] creator_id = raw_stream_dict["creator_id"]
date_created = datetime_to_timestamp(raw_stream_dict["date_created"]) date_created = datetime_to_timestamp(raw_stream_dict["date_created"])
@@ -244,6 +250,7 @@ def build_stream_dict_for_never_sub(
# Our caller may add a subscribers field. # Our caller may add a subscribers field.
return NeverSubscribedStreamDict( return NeverSubscribedStreamDict(
is_archived=is_archived,
can_remove_subscribers_group=can_remove_subscribers_group_id, can_remove_subscribers_group=can_remove_subscribers_group_id,
creator_id=creator_id, creator_id=creator_id,
date_created=date_created, date_created=date_created,
@@ -447,9 +454,12 @@ def has_metadata_access_to_previously_subscribed_stream(
def gather_subscriptions_helper( def gather_subscriptions_helper(
user_profile: UserProfile, user_profile: UserProfile,
include_subscribers: bool = True, include_subscribers: bool = True,
include_archived_channels: bool = False,
) -> SubscriptionInfo: ) -> SubscriptionInfo:
realm = user_profile.realm realm = user_profile.realm
all_streams = get_active_streams(realm).values( all_streams = get_all_streams(
realm, include_archived_channels=include_archived_channels
).values(
*Stream.API_FIELDS, *Stream.API_FIELDS,
# The realm_id and recipient_id are generally not needed in the API. # The realm_id and recipient_id are generally not needed in the API.
"realm_id", "realm_id",
@@ -517,7 +527,9 @@ def gather_subscriptions_helper(
unsubscribed.append(stream_dict) unsubscribed.append(stream_dict)
if user_profile.can_access_public_streams(): if user_profile.can_access_public_streams():
never_subscribed_stream_ids = set(all_streams_map) - sub_unsub_stream_ids never_subscribed_stream_ids = {
stream["id"] for stream in all_streams if not stream["deactivated"]
} - sub_unsub_stream_ids
else: else:
web_public_stream_ids = {stream["id"] for stream in all_streams if stream["is_web_public"]} web_public_stream_ids = {stream["id"] for stream in all_streams if stream["is_web_public"]}
never_subscribed_stream_ids = web_public_stream_ids - sub_unsub_stream_ids never_subscribed_stream_ids = web_public_stream_ids - sub_unsub_stream_ids

View File

@@ -144,6 +144,7 @@ class RawStreamDict(TypedDict):
can_remove_subscribers_group_id: int can_remove_subscribers_group_id: int
creator_id: int | None creator_id: int | None
date_created: datetime date_created: datetime
deactivated: bool
description: str description: str
first_message_id: int | None first_message_id: int | None
history_public_to_subscribers: bool history_public_to_subscribers: bool
@@ -193,6 +194,7 @@ class SubscriptionStreamDict(TypedDict):
in_home_view: bool in_home_view: bool
invite_only: bool invite_only: bool
is_announcement_only: bool is_announcement_only: bool
is_archived: bool
is_muted: bool is_muted: bool
is_web_public: bool is_web_public: bool
message_retention_days: int | None message_retention_days: int | None
@@ -208,6 +210,7 @@ class SubscriptionStreamDict(TypedDict):
class NeverSubscribedStreamDict(TypedDict): class NeverSubscribedStreamDict(TypedDict):
is_archived: bool
can_remove_subscribers_group: int can_remove_subscribers_group: int
creator_id: int | None creator_id: int | None
date_created: int date_created: int
@@ -232,6 +235,7 @@ class DefaultStreamDict(TypedDict):
with few exceptions and possible additional fields. with few exceptions and possible additional fields.
""" """
is_archived: bool
can_remove_subscribers_group: int can_remove_subscribers_group: int
creator_id: int | None creator_id: int | None
date_created: int date_created: int

View File

@@ -182,6 +182,7 @@ class Stream(models.Model):
API_FIELDS = [ API_FIELDS = [
"creator_id", "creator_id",
"date_created", "date_created",
"deactivated",
"description", "description",
"first_message_id", "first_message_id",
"history_public_to_subscribers", "history_public_to_subscribers",
@@ -197,6 +198,7 @@ class Stream(models.Model):
def to_dict(self) -> DefaultStreamDict: def to_dict(self) -> DefaultStreamDict:
return DefaultStreamDict( return DefaultStreamDict(
is_archived=self.deactivated,
can_remove_subscribers_group=self.can_remove_subscribers_group_id, can_remove_subscribers_group=self.can_remove_subscribers_group_id,
creator_id=self.creator_id, creator_id=self.creator_id,
date_created=datetime_to_timestamp(self.date_created), date_created=datetime_to_timestamp(self.date_created),
@@ -229,6 +231,16 @@ def get_active_streams(realm: Realm) -> QuerySet[Stream]:
return Stream.objects.filter(realm=realm, deactivated=False) return Stream.objects.filter(realm=realm, deactivated=False)
def get_all_streams(realm: Realm, include_archived_channels: bool = True) -> QuerySet[Stream]:
"""
Return all streams for `include_archived_channels`= true (including invite-only and deactivated streams).
"""
if not include_archived_channels:
return get_active_streams(realm)
return Stream.objects.filter(realm=realm)
def get_linkable_streams(realm_id: int) -> QuerySet[Stream]: def get_linkable_streams(realm_id: int) -> QuerySet[Stream]:
""" """
This returns the streams that we are allowed to linkify using This returns the streams that we are allowed to linkify using

View File

@@ -694,6 +694,7 @@ paths:
{ {
"name": "test", "name": "test",
"stream_id": 9, "stream_id": 9,
"is_archived": false,
"creator_id": null, "creator_id": null,
"description": "", "description": "",
"rendered_description": "", "rendered_description": "",
@@ -1337,6 +1338,7 @@ paths:
{ {
"name": "private", "name": "private",
"stream_id": 12, "stream_id": 12,
"is_archived": false,
"description": "", "description": "",
"rendered_description": "", "rendered_description": "",
"date_created": 1691057093, "date_created": 1691057093,
@@ -1395,6 +1397,7 @@ paths:
{ {
"name": "private", "name": "private",
"stream_id": 12, "stream_id": 12,
"is_archived": true,
"description": "", "description": "",
"rendered_description": "", "rendered_description": "",
"date_created": 1691057093, "date_created": 1691057093,
@@ -1994,6 +1997,7 @@ paths:
{ {
"name": "Scotland", "name": "Scotland",
"stream_id": 3, "stream_id": 3,
"is_archived": false,
"description": "Located in the United Kingdom", "description": "Located in the United Kingdom",
"rendered_description": "<p>Located in the United Kingdom</p>", "rendered_description": "<p>Located in the United Kingdom</p>",
"date_created": 1691057093, "date_created": 1691057093,
@@ -2010,6 +2014,7 @@ paths:
{ {
"name": "Denmark", "name": "Denmark",
"stream_id": 1, "stream_id": 1,
"is_archived": false,
"description": "A Scandinavian country", "description": "A Scandinavian country",
"rendered_description": "<p>A Scandinavian country</p>", "rendered_description": "<p>A Scandinavian country</p>",
"date_created": 1691057093, "date_created": 1691057093,
@@ -2026,6 +2031,7 @@ paths:
{ {
"name": "Verona", "name": "Verona",
"stream_id": 5, "stream_id": 5,
"is_archived": false,
"description": "A city in Italy", "description": "A city in Italy",
"rendered_description": "<p>A city in Italy</p>", "rendered_description": "<p>A city in Italy</p>",
"date_created": 1691057093, "date_created": 1691057093,
@@ -2073,6 +2079,7 @@ paths:
{ {
"name": "Scotland", "name": "Scotland",
"stream_id": 3, "stream_id": 3,
"is_archived": false,
"description": "Located in the United Kingdom", "description": "Located in the United Kingdom",
"rendered_description": "<p>Located in the United Kingdom</p>", "rendered_description": "<p>Located in the United Kingdom</p>",
"date_created": 1691057093, "date_created": 1691057093,
@@ -10178,6 +10185,7 @@ paths:
"creator_id": null, "creator_id": null,
"description": "A Scandinavian country", "description": "A Scandinavian country",
"desktop_notifications": true, "desktop_notifications": true,
"is_archived": false,
"is_muted": false, "is_muted": false,
"invite_only": false, "invite_only": false,
"name": "Denmark", "name": "Denmark",
@@ -10192,6 +10200,7 @@ paths:
"creator_id": 8, "creator_id": 8,
"description": "Located in the United Kingdom", "description": "Located in the United Kingdom",
"desktop_notifications": true, "desktop_notifications": true,
"is_archived": false,
"is_muted": false, "is_muted": false,
"invite_only": false, "invite_only": false,
"name": "Scotland", "name": "Scotland",
@@ -14079,6 +14088,15 @@ paths:
**Changes**: New in Zulip 10.0 (feature level 294). This **Changes**: New in Zulip 10.0 (feature level 294). This
capability is for backwards-compatibility. capability is for backwards-compatibility.
- `archived_channels`: Boolean for whether the client supports processing
[archived channels](/help/archive-a-channel) in the `stream` and
`subscription` event types. If `false`, the server will not include data
related to archived channels in the `register` response or in events.
<br />
**Changes**: New in Zulip 10.0 (feature level 315). This allows clients to
access archived channels, without breaking backwards-compatibility for
existing clients.
[help-linkifiers]: /help/add-a-custom-linkifier [help-linkifiers]: /help/add-a-custom-linkifier
[rfc6570]: https://www.rfc-editor.org/rfc/rfc6570.html [rfc6570]: https://www.rfc-editor.org/rfc/rfc6570.html
[events-linkifiers]: /api/get-events#realm_linkifiers [events-linkifiers]: /api/get-events#realm_linkifiers
@@ -14784,6 +14802,7 @@ paths:
properties: properties:
stream_id: {} stream_id: {}
name: {} name: {}
is_archived: {}
description: {} description: {}
date_created: {} date_created: {}
creator_id: creator_id:
@@ -19562,6 +19581,16 @@ paths:
type: boolean type: boolean
default: true default: true
example: false example: false
- name: exclude_archived
in: query
description: |
Whether to exclude archived streams from the results.
**Changes**: New in Zulip 10.0 (feature level 315).
schema:
type: boolean
default: true
example: true
- name: include_all_active - name: include_all_active
in: query in: query
description: | description: |
@@ -19612,6 +19641,7 @@ paths:
properties: properties:
stream_id: {} stream_id: {}
name: {} name: {}
is_archived: {}
description: {} description: {}
date_created: {} date_created: {}
creator_id: creator_id:
@@ -19655,6 +19685,7 @@ paths:
required: required:
- stream_id - stream_id
- name - name
- is_archived
- description - description
- date_created - date_created
- creator_id - creator_id
@@ -19683,6 +19714,7 @@ paths:
"history_public_to_subscribers": false, "history_public_to_subscribers": false,
"invite_only": true, "invite_only": true,
"is_announcement_only": false, "is_announcement_only": false,
"is_archived": false,
"is_default": false, "is_default": false,
"is_web_public": false, "is_web_public": false,
"message_retention_days": null, "message_retention_days": null,
@@ -19701,6 +19733,7 @@ paths:
"history_public_to_subscribers": true, "history_public_to_subscribers": true,
"invite_only": false, "invite_only": false,
"is_announcement_only": false, "is_announcement_only": false,
"is_archived": false,
"is_default": true, "is_default": true,
"is_web_public": false, "is_web_public": false,
"message_retention_days": null, "message_retention_days": null,
@@ -19768,6 +19801,7 @@ paths:
"creator_id": null, "creator_id": null,
"invite_only": false, "invite_only": false,
"is_announcement_only": false, "is_announcement_only": false,
"is_archived": false,
"is_web_public": false, "is_web_public": false,
"message_retention_days": null, "message_retention_days": null,
"name": "Denmark", "name": "Denmark",
@@ -21555,6 +21589,7 @@ components:
properties: properties:
stream_id: {} stream_id: {}
name: {} name: {}
is_archived: {}
description: {} description: {}
date_created: {} date_created: {}
creator_id: creator_id:
@@ -21588,6 +21623,7 @@ components:
required: required:
- stream_id - stream_id
- name - name
- is_archived
- description - description
- date_created - date_created
- creator_id - creator_id
@@ -21608,6 +21644,7 @@ components:
properties: properties:
stream_id: {} stream_id: {}
name: {} name: {}
is_archived: {}
description: {} description: {}
date_created: {} date_created: {}
creator_id: creator_id:
@@ -21626,6 +21663,7 @@ components:
required: required:
- stream_id - stream_id
- name - name
- is_archived
- description - description
- date_created - date_created
- creator_id - creator_id
@@ -21651,6 +21689,13 @@ components:
type: string type: string
description: | description: |
The name of the channel. The name of the channel.
is_archived:
type: boolean
description: |
A boolean indicating whether the channel is [archived](/help/archive-a-channel).
**Changes**: New in Zulip 10.0 (feature level 315).
Previously, this endpoint never returned archived channels.
description: description:
type: string type: string
description: | description: |
@@ -22753,6 +22798,16 @@ components:
was named `can_remove_subscribers_group_id`. was named `can_remove_subscribers_group_id`.
New in Zulip 6.0 (feature level 142). New in Zulip 6.0 (feature level 142).
is_archived:
type: boolean
description: |
A boolean indicating whether the channel is [archived](/help/archive-a-channel).
**Changes**: New in Zulip 10.0 (feature level 315).
Previously, subscriptions only included active
channels. Note that some endpoints will never return archived
channels unless the client declares explicit support for
them via the `archived_channels` client capability.
DefaultChannelGroup: DefaultChannelGroup:
type: object type: object
description: | description: |

View File

@@ -305,6 +305,7 @@ class BaseAction(ZulipTestCase):
user_list_incomplete: bool = False, user_list_incomplete: bool = False,
client_is_old: bool = False, client_is_old: bool = False,
include_deactivated_groups: bool = False, include_deactivated_groups: bool = False,
archived_channels: bool = False,
) -> Iterator[list[dict[str, Any]]]: ) -> Iterator[list[dict[str, Any]]]:
""" """
Make sure we have a clean slate of client descriptors for these tests. Make sure we have a clean slate of client descriptors for these tests.
@@ -354,6 +355,7 @@ class BaseAction(ZulipTestCase):
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
if client_is_old: if client_is_old:
@@ -395,6 +397,7 @@ class BaseAction(ZulipTestCase):
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
post_process_state(self.user_profile, hybrid_state, notification_settings_null) post_process_state(self.user_profile, hybrid_state, notification_settings_null)
after = orjson.dumps(hybrid_state) after = orjson.dumps(hybrid_state)
@@ -425,6 +428,7 @@ class BaseAction(ZulipTestCase):
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
post_process_state(self.user_profile, normal_state, notification_settings_null) post_process_state(self.user_profile, normal_state, notification_settings_null)
self.match_states(hybrid_state, normal_state, events) self.match_states(hybrid_state, normal_state, events)
@@ -3355,6 +3359,23 @@ class NormalActionsTest(BaseAction):
check_stream_delete("events[0]", events[0]) check_stream_delete("events[0]", events[0])
self.assertIsNone(events[0]["streams"][0]["stream_weekly_traffic"]) self.assertIsNone(events[0]["streams"][0]["stream_weekly_traffic"])
def test_admin_deactivate_unsubscribed_stream(self) -> None:
self.set_up_db_for_testing_user_access()
stream = self.make_stream("test_stream")
iago = self.example_user("iago")
realm = iago.realm
self.user_profile = self.example_user("iago")
self.subscribe(iago, stream.name)
self.assertCountEqual(self.users_subscribed_to_stream(stream.name, realm), [iago])
self.unsubscribe(iago, stream.name)
self.assertCountEqual(self.users_subscribed_to_stream(stream.name, realm), [])
with self.verify_action(num_events=1, archived_channels=True) as events:
do_deactivate_stream(stream, acting_user=iago)
check_stream_delete("events[0]", events[0])
def test_user_losing_access_on_deactivating_stream(self) -> None: def test_user_losing_access_on_deactivating_stream(self) -> None:
self.set_up_db_for_testing_user_access() self.set_up_db_for_testing_user_access()
polonius = self.example_user("polonius") polonius = self.example_user("polonius")
@@ -3367,7 +3388,7 @@ class NormalActionsTest(BaseAction):
self.users_subscribed_to_stream(stream.name, realm), [hamlet, polonius] self.users_subscribed_to_stream(stream.name, realm), [hamlet, polonius]
) )
with self.verify_action(num_events=2) as events: with self.verify_action(num_events=2, archived_channels=True) as events:
do_deactivate_stream(stream, acting_user=None) do_deactivate_stream(stream, acting_user=None)
check_stream_delete("events[0]", events[0]) check_stream_delete("events[0]", events[0])
check_realm_user_remove("events[1]", events[1]) check_realm_user_remove("events[1]", events[1])
@@ -3383,7 +3404,7 @@ class NormalActionsTest(BaseAction):
self.users_subscribed_to_stream(stream.name, realm), [iago, polonius, shiva] self.users_subscribed_to_stream(stream.name, realm), [iago, polonius, shiva]
) )
with self.verify_action(num_events=2) as events: with self.verify_action(num_events=2, archived_channels=True) as events:
do_deactivate_stream(stream, acting_user=None) do_deactivate_stream(stream, acting_user=None)
check_stream_delete("events[0]", events[0]) check_stream_delete("events[0]", events[0])
check_realm_user_remove("events[1]", events[1]) check_realm_user_remove("events[1]", events[1])

View File

@@ -648,7 +648,7 @@ class TestOutgoingWebhookMessaging(ZulipTestCase):
prev_message = self.get_second_to_last_message() prev_message = self.get_second_to_last_message()
self.assertIn( self.assertIn(
"tried to send a message to channel #**Denmark**, but that channel does not exist", "Failure! Bot is unavailable",
prev_message.content, prev_message.content,
) )

View File

@@ -238,8 +238,8 @@ class TestMiscStuff(ZulipTestCase):
"""Verify that all the fields from `Stream.API_FIELDS` and `Subscription.API_FIELDS` present """Verify that all the fields from `Stream.API_FIELDS` and `Subscription.API_FIELDS` present
in `APIStreamDict` and `APISubscriptionDict`, respectively. in `APIStreamDict` and `APISubscriptionDict`, respectively.
""" """
expected_fields = set(Stream.API_FIELDS) | {"stream_id"} expected_fields = set(Stream.API_FIELDS) | {"stream_id", "is_archived"}
expected_fields -= {"id", "can_remove_subscribers_group_id"} expected_fields -= {"id", "can_remove_subscribers_group_id", "deactivated"}
expected_fields |= {"can_remove_subscribers_group"} expected_fields |= {"can_remove_subscribers_group"}
stream_dict_fields = set(APIStreamDict.__annotations__.keys()) stream_dict_fields = set(APIStreamDict.__annotations__.keys())
@@ -2508,6 +2508,13 @@ class StreamAdminTest(ZulipTestCase):
public_streams = [s["name"] for s in self.assert_json_success(result)["streams"]] public_streams = [s["name"] for s in self.assert_json_success(result)["streams"]]
self.assertNotIn(deactivated_stream_name, public_streams) self.assertNotIn(deactivated_stream_name, public_streams)
# It shows up with `exclude_archived` parameter set to false.
result = self.client_get(
"/json/streams", {"exclude_archived": "false", "include_all_active": "true"}
)
streams = [s["name"] for s in self.assert_json_success(result)["streams"]]
self.assertIn(deactivated_stream_name, streams)
# You can't subscribe to archived stream. # You can't subscribe to archived stream.
result = self.common_subscribe_to_streams( result = self.common_subscribe_to_streams(
self.example_user("hamlet"), [deactivated_stream_name], allow_fail=True self.example_user("hamlet"), [deactivated_stream_name], allow_fail=True
@@ -5464,10 +5471,10 @@ class SubscriptionAPITest(ZulipTestCase):
self.assert_length(result, 1) self.assert_length(result, 1)
self.assertEqual(result[0]["stream_id"], stream1.id) self.assertEqual(result[0]["stream_id"], stream1.id)
def test_gather_subscriptions_excludes_deactivated_streams(self) -> None: def test_gather_subscriptions_deactivated_streams(self) -> None:
""" """
Check that gather_subscriptions_helper does not include deactivated streams in its Check that gather_subscriptions_helper does/doesn't include deactivated streams in its
results. results with `exclude_archived` parameter.
""" """
realm = get_realm("zulip") realm = get_realm("zulip")
admin_user = self.example_user("iago") admin_user = self.example_user("iago")
@@ -5497,6 +5504,10 @@ class SubscriptionAPITest(ZulipTestCase):
admin_after_delete = gather_subscriptions_helper(admin_user) admin_after_delete = gather_subscriptions_helper(admin_user)
non_admin_after_delete = gather_subscriptions_helper(non_admin_user) non_admin_after_delete = gather_subscriptions_helper(non_admin_user)
admin_after_delete_include_archived = gather_subscriptions_helper(
admin_user, include_archived_channels=True
)
# Compare results - should be 1 stream less # Compare results - should be 1 stream less
self.assertTrue( self.assertTrue(
len(admin_before_delete.subscriptions) == len(admin_after_delete.subscriptions) + 1, len(admin_before_delete.subscriptions) == len(admin_after_delete.subscriptions) + 1,
@@ -5508,6 +5519,14 @@ class SubscriptionAPITest(ZulipTestCase):
"Expected exactly 1 less stream from gather_subscriptions_helper", "Expected exactly 1 less stream from gather_subscriptions_helper",
) )
# Compare results - should be the same number of streams
self.assertTrue(
len(admin_before_delete.subscriptions) + len(admin_before_delete.unsubscribed)
== len(admin_after_delete_include_archived.subscriptions)
+ len(admin_after_delete_include_archived.unsubscribed),
"Expected exact number of streams from gather_subscriptions_helper",
)
def test_validate_user_access_to_subscribers_helper(self) -> None: def test_validate_user_access_to_subscribers_helper(self) -> None:
""" """
Ensure the validate_user_access_to_subscribers_helper is properly raising Ensure the validate_user_access_to_subscribers_helper is properly raising
@@ -5944,6 +5963,7 @@ class GetSubscribersTest(ZulipTestCase):
def verify_sub_fields(self, sub_data: SubscriptionInfo) -> None: def verify_sub_fields(self, sub_data: SubscriptionInfo) -> None:
other_fields = { other_fields = {
"is_archived",
"is_announcement_only", "is_announcement_only",
"in_home_view", "in_home_view",
"stream_id", "stream_id",
@@ -5952,7 +5972,7 @@ class GetSubscribersTest(ZulipTestCase):
} }
expected_fields = set(Stream.API_FIELDS) | set(Subscription.API_FIELDS) | other_fields expected_fields = set(Stream.API_FIELDS) | set(Subscription.API_FIELDS) | other_fields
expected_fields -= {"id", "can_remove_subscribers_group_id"} expected_fields -= {"id", "can_remove_subscribers_group_id", "deactivated"}
expected_fields |= {"can_remove_subscribers_group"} expected_fields |= {"can_remove_subscribers_group"}
for lst in [sub_data.subscriptions, sub_data.unsubscribed]: for lst in [sub_data.subscriptions, sub_data.unsubscribed]:
@@ -5960,6 +5980,7 @@ class GetSubscribersTest(ZulipTestCase):
self.assertEqual(set(sub), expected_fields) self.assertEqual(set(sub), expected_fields)
other_fields = { other_fields = {
"is_archived",
"is_announcement_only", "is_announcement_only",
"stream_id", "stream_id",
"stream_weekly_traffic", "stream_weekly_traffic",
@@ -5967,7 +5988,7 @@ class GetSubscribersTest(ZulipTestCase):
} }
expected_fields = set(Stream.API_FIELDS) | other_fields expected_fields = set(Stream.API_FIELDS) | other_fields
expected_fields -= {"id", "can_remove_subscribers_group_id"} expected_fields -= {"id", "can_remove_subscribers_group_id", "deactivated"}
expected_fields |= {"can_remove_subscribers_group"} expected_fields |= {"can_remove_subscribers_group"}
for never_sub in sub_data.never_subscribed: for never_sub in sub_data.never_subscribed:

View File

@@ -91,6 +91,7 @@ def request_event_queue(
linkifier_url_template: bool = False, linkifier_url_template: bool = False,
user_list_incomplete: bool = False, user_list_incomplete: bool = False,
include_deactivated_groups: bool = False, include_deactivated_groups: bool = False,
archived_channels: bool = False,
) -> str | None: ) -> str | None:
if not settings.USING_TORNADO: if not settings.USING_TORNADO:
return None return None
@@ -115,6 +116,7 @@ def request_event_queue(
"linkifier_url_template": orjson.dumps(linkifier_url_template), "linkifier_url_template": orjson.dumps(linkifier_url_template),
"user_list_incomplete": orjson.dumps(user_list_incomplete), "user_list_incomplete": orjson.dumps(user_list_incomplete),
"include_deactivated_groups": orjson.dumps(include_deactivated_groups), "include_deactivated_groups": orjson.dumps(include_deactivated_groups),
"archived_channels": orjson.dumps(archived_channels),
} }
if event_types is not None: if event_types is not None:

View File

@@ -79,6 +79,7 @@ class ClientDescriptor:
linkifier_url_template: bool = False, linkifier_url_template: bool = False,
user_list_incomplete: bool = False, user_list_incomplete: bool = False,
include_deactivated_groups: bool = False, include_deactivated_groups: bool = False,
archived_channels: bool = False,
) -> None: ) -> None:
# TODO: We eventually want to upstream this code to the caller, but # TODO: We eventually want to upstream this code to the caller, but
# serialization concerns make it a bit difficult. # serialization concerns make it a bit difficult.
@@ -110,6 +111,7 @@ class ClientDescriptor:
self.linkifier_url_template = linkifier_url_template self.linkifier_url_template = linkifier_url_template
self.user_list_incomplete = user_list_incomplete self.user_list_incomplete = user_list_incomplete
self.include_deactivated_groups = include_deactivated_groups self.include_deactivated_groups = include_deactivated_groups
self.archived_channels = archived_channels
# Default for lifespan_secs is DEFAULT_EVENT_QUEUE_TIMEOUT_SECS; # Default for lifespan_secs is DEFAULT_EVENT_QUEUE_TIMEOUT_SECS;
# but users can set it as high as MAX_QUEUE_TIMEOUT_SECS. # but users can set it as high as MAX_QUEUE_TIMEOUT_SECS.
@@ -141,6 +143,7 @@ class ClientDescriptor:
linkifier_url_template=self.linkifier_url_template, linkifier_url_template=self.linkifier_url_template,
user_list_incomplete=self.user_list_incomplete, user_list_incomplete=self.user_list_incomplete,
include_deactivated_groups=self.include_deactivated_groups, include_deactivated_groups=self.include_deactivated_groups,
archived_channels=self.archived_channels,
) )
@override @override
@@ -178,6 +181,7 @@ class ClientDescriptor:
d.get("linkifier_url_template", False), d.get("linkifier_url_template", False),
d.get("user_list_incomplete", False), d.get("user_list_incomplete", False),
d.get("include_deactivated_groups", False), d.get("include_deactivated_groups", False),
d.get("archived_channels", False),
) )
ret.last_connection_time = d["last_connection_time"] ret.last_connection_time = d["last_connection_time"]
return ret return ret

View File

@@ -210,6 +210,10 @@ def get_events_backend(
Json[bool], Json[bool],
ApiParamConfig(documentation_status=DocumentationStatus.INTENTIONALLY_UNDOCUMENTED), ApiParamConfig(documentation_status=DocumentationStatus.INTENTIONALLY_UNDOCUMENTED),
] = False, ] = False,
archived_channels: Annotated[
Json[bool],
ApiParamConfig(documentation_status=DocumentationStatus.INTENTIONALLY_UNDOCUMENTED),
] = False,
) -> HttpResponse: ) -> HttpResponse:
if narrow is None: if narrow is None:
narrow = [] narrow = []
@@ -248,6 +252,7 @@ def get_events_backend(
linkifier_url_template=linkifier_url_template, linkifier_url_template=linkifier_url_template,
user_list_incomplete=user_list_incomplete, user_list_incomplete=user_list_incomplete,
include_deactivated_groups=include_deactivated_groups, include_deactivated_groups=include_deactivated_groups,
archived_channels=archived_channels,
) )
result = in_tornado_thread(fetch_events)( result = in_tornado_thread(fetch_events)(

View File

@@ -847,6 +847,7 @@ def get_streams_backend(
include_public: Json[bool] = True, include_public: Json[bool] = True,
include_web_public: Json[bool] = False, include_web_public: Json[bool] = False,
include_subscribed: Json[bool] = True, include_subscribed: Json[bool] = True,
exclude_archived: Json[bool] = True,
include_all_active: Json[bool] = False, include_all_active: Json[bool] = False,
include_default: Json[bool] = False, include_default: Json[bool] = False,
include_owner_subscribed: Json[bool] = False, include_owner_subscribed: Json[bool] = False,
@@ -856,6 +857,7 @@ def get_streams_backend(
include_public=include_public, include_public=include_public,
include_web_public=include_web_public, include_web_public=include_web_public,
include_subscribed=include_subscribed, include_subscribed=include_subscribed,
exclude_archived=exclude_archived,
include_all_active=include_all_active, include_all_active=include_all_active,
include_default=include_default, include_default=include_default,
include_owner_subscribed=include_owner_subscribed, include_owner_subscribed=include_owner_subscribed,