diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index af0f023edb..a3550952d6 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -69,7 +69,7 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, custom_profile_fields_for_realm, get_huddle_user_ids, \ CustomProfileFieldValue, validate_attachment_request, get_system_bot, \ get_display_recipient_by_id, query_for_ids, get_huddle_recipient, \ - UserGroup, UserGroupMembership + UserGroup, UserGroupMembership, get_default_stream_groups from zerver.lib.alert_words import alert_words_in_realm from zerver.lib.avatar import avatar_url @@ -1648,6 +1648,17 @@ def check_stream_name(stream_name): if ord(i) == 0: raise JsonableError(_("Stream name '%s' contains NULL (0x00) characters." % (stream_name))) +def check_default_stream_group_name(group_name: Text) -> None: + if group_name.strip() == "": + raise JsonableError(_("Invalid default stream group name '%s'" % (group_name))) + if len(group_name) > DefaultStreamGroup.MAX_NAME_LENGTH: + raise JsonableError(_("Default stream group name too long (limit: %s characters)" + % (DefaultStreamGroup.MAX_NAME_LENGTH))) + for i in group_name: + if ord(i) == 0: + raise JsonableError(_("Default stream group name '%s' contains NULL (0x00) characters." + % (group_name))) + def send_pm_if_empty_stream(sender, stream, stream_name, realm): # type: (UserProfile, Optional[Stream], Text, Realm) -> None """If a bot sends a message to a stream that doesn't exist or has no @@ -2888,6 +2899,13 @@ def notify_default_streams(realm_id): ) send_event(event, active_user_ids(realm_id)) +def notify_default_stream_groups(realm: Realm) -> None: + event = dict( + type="default_stream_groups", + default_stream_groups=default_stream_groups_to_dicts_sorted(get_default_stream_groups(realm)) + ) + send_event(event, active_user_ids(realm.id)) + def do_add_default_stream(stream): # type: (Stream) -> None realm_id = stream.realm_id @@ -2903,6 +2921,61 @@ def do_remove_default_stream(stream): DefaultStream.objects.filter(realm_id=realm_id, stream_id=stream_id).delete() notify_default_streams(realm_id) +def do_create_default_stream_group(realm: Realm, group_name: Text, streams: List[Stream]) -> None: + default_streams = get_default_streams_for_realm(realm.id) + for stream in streams: + if stream in default_streams: + raise JsonableError(_("'%s' is a default stream and cannot be added to '%s'") % (stream.name, group_name)) + + check_default_stream_group_name(group_name) + (group, created) = DefaultStreamGroup.objects.get_or_create(name=group_name, realm=realm) + if not created: + raise JsonableError(_("Default stream group '%s' already exists") % (group_name,)) + + group.streams = streams + group.save() + notify_default_stream_groups(realm) + +def do_add_streams_to_default_stream_group(realm: Realm, group_name: Text, streams: List[Stream]) -> None: + try: + group = DefaultStreamGroup.objects.get(name=group_name, realm=realm) + except DefaultStreamGroup.DoesNotExist: + raise JsonableError(_("Default stream group '%s' does not exist") % (group_name,)) + + default_streams = get_default_streams_for_realm(realm.id) + for stream in streams: + if stream in default_streams: + raise JsonableError(_("'%s' is a default stream and cannot be added to '%s'") % (stream.name, group_name)) + if stream in group.streams.all(): + raise JsonableError(_("Stream '%s' is already present in default stream group '%s'") + % (stream.name, group_name)) + group.streams.add(stream) + + group.save() + notify_default_stream_groups(realm) + +def do_remove_streams_from_default_stream_group(realm: Realm, group_name: Text, streams: List[Stream]) -> None: + try: + group = DefaultStreamGroup.objects.get(name=group_name, realm=realm) + except DefaultStreamGroup.DoesNotExist: + raise JsonableError(_("Default stream group '%s' does not exist") % (group_name,)) + + for stream in streams: + if stream not in group.streams.all(): + raise JsonableError(_("Stream '%s' is not present in default stream group '%s'") + % (stream.name, group_name)) + group.streams.remove(stream) + + group.save() + notify_default_stream_groups(realm) + +def do_remove_default_stream_group(realm: Realm, group_name: Text) -> None: + try: + DefaultStreamGroup.objects.filter(name=group_name, realm=realm).delete() + except DefaultStreamGroup.DoesNotExist: + raise JsonableError(_("Default stream group '%s' does not exist") % (group_name,)) + notify_default_stream_groups(realm) + def get_default_streams_for_realm(realm_id): # type: (int) -> List[Stream] return [default.stream for default in @@ -2919,6 +2992,9 @@ def streams_to_dicts_sorted(streams): # type: (List[Stream]) -> List[Dict[str, Any]] return sorted([stream.to_dict() for stream in streams], key=lambda elt: elt["name"]) +def default_stream_groups_to_dicts_sorted(groups: List[DefaultStreamGroup]) -> List[Dict[str, Any]]: + return sorted([group.to_dict() for group in groups], key=lambda elt: elt["name"]) + def do_update_user_activity_interval(user_profile, log_time): # type: (UserProfile, datetime.datetime) -> None effective_end = log_time + UserActivityInterval.MIN_INTERVAL_LENGTH diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 4f5689ca19..18f631e0d4 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -32,7 +32,8 @@ from zerver.lib.actions import ( validate_user_access_to_subscribers_helper, do_get_streams, get_default_streams_for_realm, gather_subscriptions_helper, get_cross_realm_dicts, - get_status_dict, streams_to_dicts_sorted + get_status_dict, streams_to_dicts_sorted, + default_stream_groups_to_dicts_sorted ) from zerver.lib.upload import get_total_uploads_size_for_user from zerver.lib.user_groups import user_groups_in_realm_serialized @@ -40,7 +41,8 @@ from zerver.tornado.event_queue import request_event_queue, get_user_events from zerver.models import Client, Message, Realm, UserPresence, UserProfile, \ get_user_profile_by_id, \ get_realm_user_dicts, realm_filters_for_realm, get_user,\ - get_owned_bot_dicts, custom_profile_fields_for_realm, get_realm_domains + get_owned_bot_dicts, custom_profile_fields_for_realm, get_realm_domains, \ + get_default_stream_groups from zproject.backends import email_auth_enabled, password_auth_enabled from version import ZULIP_VERSION @@ -231,6 +233,9 @@ def fetch_initial_state_data(user_profile, event_types, queue_id, client_gravata state['streams'] = do_get_streams(user_profile) if want('default_streams'): state['realm_default_streams'] = streams_to_dicts_sorted(get_default_streams_for_realm(user_profile.realm_id)) + if want('default_stream_groups'): + state['realm_default_stream_groups'] = default_stream_groups_to_dicts_sorted( + get_default_stream_groups(user_profile.realm)) if want('update_display_settings'): for prop in UserProfile.property_types: @@ -396,6 +401,8 @@ def apply_event(state, event, user_profile, client_gravatar, include_subscribers state['streams'] = [s for s in state['streams'] if s["stream_id"] not in stream_ids] elif event['type'] == 'default_streams': state['realm_default_streams'] = event['default_streams'] + elif event['type'] == 'default_stream_groups': + state['realm_default_stream_groups'] = event['default_stream_groups'] elif event['type'] == 'realm': if event['op'] == "update": field = 'realm_' + event['property'] diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 0d39edd161..1ec1673b77 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -28,6 +28,7 @@ from zerver.lib.actions import ( do_add_reaction_legacy, do_add_realm_domain, do_add_realm_filter, + do_add_streams_to_default_stream_group, do_change_avatar_fields, do_change_bot_owner, do_change_default_all_public_streams, @@ -41,6 +42,7 @@ from zerver.lib.actions import ( do_change_stream_description, do_change_subscription_property, do_create_user, + do_create_default_stream_group, do_deactivate_stream, do_deactivate_user, do_delete_message, @@ -50,10 +52,12 @@ from zerver.lib.actions import ( do_regenerate_api_key, do_remove_alert_words, do_remove_default_stream, + do_remove_default_stream_group, do_remove_reaction_legacy, do_remove_realm_domain, do_remove_realm_emoji, do_remove_realm_filter, + do_remove_streams_from_default_stream_group, do_rename_stream, do_set_realm_authentication_methods, do_set_realm_message_editing, @@ -979,6 +983,45 @@ class EventsRegisterTest(ZulipTestCase): error = alert_words_checker('events[0]', events[0]) self.assert_on_error(error) + def test_default_stream_groups_events(self): + # type: () -> None + default_stream_groups_checker = self.check_events_dict([ + ('type', equals('default_stream_groups')), + ('default_stream_groups', check_list(check_dict_only([ + ('name', check_string), + ('streams', check_list(check_dict_only([ + ('description', check_string), + ('invite_only', check_bool), + ('name', check_string), + ('stream_id', check_int)]))), + ]))), + ]) + + streams = [] + for stream_name in ["Scotland", "Verona", "Denmark"]: + streams.append(get_stream(stream_name, self.user_profile.realm)) + + events = self.do_test(lambda: do_create_default_stream_group(self.user_profile.realm, + "group1", streams)) + error = default_stream_groups_checker('events[0]', events[0]) + self.assert_on_error(error) + + venice_stream = get_stream("Venice", self.user_profile.realm) + events = self.do_test(lambda: do_add_streams_to_default_stream_group(self.user_profile.realm, + "group1", [venice_stream])) + error = default_stream_groups_checker('events[0]', events[0]) + self.assert_on_error(error) + + events = self.do_test(lambda: do_remove_streams_from_default_stream_group(self.user_profile.realm, + "group1", [venice_stream])) + error = default_stream_groups_checker('events[0]', events[0]) + self.assert_on_error(error) + + events = self.do_test(lambda: do_remove_default_stream_group(self.user_profile.realm, + "group1")) + error = default_stream_groups_checker('events[0]', events[0]) + self.assert_on_error(error) + def test_default_streams_events(self): # type: () -> None default_streams_checker = self.check_events_dict([ @@ -2498,13 +2541,14 @@ class FetchQueriesTest(ZulipTestCase): client_gravatar=False, ) - self.assert_length(queries, 29) + self.assert_length(queries, 30) expected_counts = dict( alert_words=0, attachments=1, custom_profile_fields=1, default_streams=1, + default_stream_groups=1, hotspots=0, message=1, muted_topics=1, diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 262cbfb120..13c666d2c5 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -111,6 +111,7 @@ class HomeTest(ZulipTestCase): "realm_bots", "realm_create_stream_by_admins_only", "realm_default_language", + "realm_default_stream_groups", "realm_default_streams", "realm_description", "realm_domains", @@ -180,7 +181,7 @@ class HomeTest(ZulipTestCase): with patch('zerver.lib.cache.cache_set') as cache_mock: result = self._get_home_page(stream='Denmark') - self.assert_length(queries, 41) + self.assert_length(queries, 42) self.assert_length(cache_mock.call_args_list, 10) html = result.content.decode('utf-8') @@ -245,7 +246,7 @@ class HomeTest(ZulipTestCase): with queries_captured() as queries2: result = self._get_home_page() - self.assert_length(queries2, 35) + self.assert_length(queries2, 36) # Do a sanity check that our new streams were in the payload. html = result.content.decode('utf-8') diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py index 4050f1f280..db5630989e 100644 --- a/zerver/tests/test_subs.py +++ b/zerver/tests/test_subs.py @@ -41,6 +41,7 @@ from zerver.lib.test_runner import ( from zerver.models import ( get_display_recipient, Message, Realm, Recipient, Stream, Subscription, DefaultStream, UserProfile, get_user_profile_by_id, active_user_ids, + get_default_stream_groups ) from zerver.lib.actions import ( @@ -52,6 +53,9 @@ from zerver.lib.actions import ( create_stream_if_needed, create_streams_if_needed, do_deactivate_stream, stream_welcome_message, + do_create_default_stream_group, + do_add_streams_to_default_stream_group, do_remove_streams_from_default_stream_group, + do_remove_default_stream_group, ) from zerver.views.streams import ( @@ -827,6 +831,165 @@ class DefaultStreamTest(ZulipTestCase): self.assert_json_success(result) self.assertFalse(stream_name in self.get_default_stream_names(user_profile.realm)) +class DefaultStreamGroupTest(ZulipTestCase): + def test_create_update_and_remove_default_stream_group(self) -> None: + realm = get_realm("zulip") + + # Test creating new default stream group + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 0) + + streams = [] + for stream_name in ["stream1", "stream2", "stream3"]: + (stream, _) = create_stream_if_needed(realm, stream_name) + streams.append(stream) + + group_name = "group1" + do_create_default_stream_group(realm, group_name, streams) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), streams) + + # Test adding streams to existing default stream group + new_stream_names = ["stream4", "stream5"] + new_streams = [] + for new_stream_name in new_stream_names: + (new_stream, _) = create_stream_if_needed(realm, new_stream_name) + new_streams.append(new_stream) + streams.append(new_stream) + + do_add_streams_to_default_stream_group(realm, group_name, new_streams) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), streams) + + # Test removing streams from existing default stream group + do_remove_streams_from_default_stream_group(realm, group_name, new_streams) + remaining_streams = streams[0:3] + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), remaining_streams) + + # Test removing default stream group + do_remove_default_stream_group(realm, group_name) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 0) + + # Test creating a default stream group which contains a default stream + do_add_default_stream(remaining_streams[0]) + with self.assertRaisesRegex(JsonableError, "'stream1' is a default stream and cannot be added to 'group1'"): + do_create_default_stream_group(realm, group_name, remaining_streams) + + def test_api_calls(self) -> None: + self.login(self.example_email("hamlet")) + user_profile = self.example_user('hamlet') + realm = user_profile.realm + do_change_is_admin(user_profile, True) + + # Test creating new default stream group + stream_names = ["stream1", "stream2", "stream3"] + group_name = "group1" + streams = [] + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 0) + + for stream_name in stream_names: + (stream, _) = create_stream_if_needed(realm, stream_name) + streams.append(stream) + + result = self.client_post('/json/default_stream_groups', + {"group_name": "", "stream_names": ujson.dumps(stream_names)}) + self.assert_json_error(result, "Invalid default stream group name ''") + + result = self.client_post('/json/default_stream_groups', + {"group_name": group_name, "stream_names": ujson.dumps(stream_names)}) + self.assert_json_success(result) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), streams) + + # Test adding streams to existing default stream group + new_stream_names = ["stream4", "stream5"] + new_streams = [] + for new_stream_name in new_stream_names: + (new_stream, _) = create_stream_if_needed(realm, new_stream_name) + new_streams.append(new_stream) + streams.append(new_stream) + + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, + "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Missing 'op' argument") + + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "invalid", + "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, 'Nothing to do. Specify at least one of "add" or "remove".') + + result = self.client_patch("/json/default_stream_groups", + {"group_name": "dumbledore's army", "op": "add", + "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Default stream group 'dumbledore's army' does not exist") + + do_add_default_stream(new_streams[0]) + result = self.client_patch('/json/default_stream_groups', + {"group_name": group_name, "op": "add", "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "'stream4' is a default stream and cannot be added to 'group1'") + + do_remove_default_stream(new_streams[0]) + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "add", + "stream_names": ujson.dumps(new_stream_names)}) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), streams) + + result = self.client_patch('/json/default_stream_groups', + {"group_name": group_name, "op": "add", "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Stream 'stream4' is already present in default stream group 'group1'") + + # Test removing streams from default stream group + result = self.client_patch("/json/default_stream_groups", + {"group_name": "dumbledore's army", "op": "remove", + "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Default stream group 'dumbledore's army' does not exist") + + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "remove", + "stream_names": ujson.dumps(["random stream name"])}) + self.assert_json_error(result, "Invalid stream name 'random stream name'") + + streams.remove(new_streams[0]) + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "remove", + "stream_names": ujson.dumps([new_stream_names[0]])}) + self.assert_json_success(result) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 1) + self.assertEqual(default_stream_groups[0].name, group_name) + self.assertEqual(list(default_stream_groups[0].streams.all()), streams) + + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "remove", + "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Stream 'stream4' is not present in default stream group 'group1'") + + # Test deleting a default stream group + result = self.client_delete('/json/default_stream_groups', {"group_name": group_name}) + self.assert_json_success(result) + default_stream_groups = get_default_stream_groups(realm) + self.assert_length(default_stream_groups, 0) + + do_remove_default_stream(new_stream) + result = self.client_patch("/json/default_stream_groups", + {"group_name": group_name, "op": "add", "stream_names": ujson.dumps(new_stream_names)}) + self.assert_json_error(result, "Default stream group 'group1' does not exist") + class SubscriptionPropertiesTest(ZulipTestCase): def test_set_stream_color(self): # type: () -> None diff --git a/zerver/views/streams.py b/zerver/views/streams.py index a774b7c139..45728fb67e 100644 --- a/zerver/views/streams.py +++ b/zerver/views/streams.py @@ -18,6 +18,8 @@ from zerver.lib.actions import bulk_remove_subscriptions, \ do_deactivate_stream, do_change_stream_invite_only, do_add_default_stream, \ do_change_stream_description, do_get_streams, \ do_remove_default_stream, get_topic_history_for_stream, \ + do_create_default_stream_group, do_add_streams_to_default_stream_group, \ + do_remove_streams_from_default_stream_group, do_remove_default_stream_group, \ prep_stream_welcome_message from zerver.lib.response import json_success, json_error, json_response from zerver.lib.streams import access_stream_by_id, access_stream_by_name, \ @@ -72,6 +74,42 @@ def add_default_stream(request, user_profile, stream_name=REQ()): do_add_default_stream(stream) return json_success() +@require_realm_admin +@has_request_variables +def create_default_stream_group(request: HttpRequest, user_profile: UserProfile, + group_name: Text=REQ(), + stream_names: List[Text]=REQ(validator=check_list(check_string))) -> None: + streams = [] + for stream_name in stream_names: + (stream, recipient, sub) = access_stream_by_name(user_profile, stream_name) + streams.append(stream) + do_create_default_stream_group(user_profile.realm, group_name, streams) + return json_success() + +@require_realm_admin +@has_request_variables +def update_default_stream_group(request: HttpRequest, user_profile: UserProfile, + group_name: Text=REQ(), op: Text=REQ(), + stream_names: List[Text]=REQ(validator=check_list(check_string))) -> None: + streams = [] + for stream_name in stream_names: + (stream, recipient, sub) = access_stream_by_name(user_profile, stream_name) + streams.append(stream) + + if op == 'add': + do_add_streams_to_default_stream_group(user_profile.realm, group_name, streams) + elif op == 'remove': + do_remove_streams_from_default_stream_group(user_profile.realm, group_name, streams) + else: + return json_error(_('Nothing to do. Specify at least one of "add" or "remove".')) + return json_success() + +@require_realm_admin +@has_request_variables +def remove_default_stream_group(request: HttpRequest, user_profile: UserProfile, group_name: Text=REQ()) -> None: + do_remove_default_stream_group(user_profile.realm, group_name) + return json_success() + @require_realm_admin @has_request_variables def remove_default_stream(request, user_profile, stream_name=REQ()): diff --git a/zproject/urls.py b/zproject/urls.py index ecef4eba8b..dbfce5c12e 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -283,6 +283,10 @@ v1_api_and_json_patterns = [ url(r'^default_streams$', rest_dispatch, {'POST': 'zerver.views.streams.add_default_stream', 'DELETE': 'zerver.views.streams.remove_default_stream'}), + url(r'^default_stream_groups', rest_dispatch, + {'POST': 'zerver.views.streams.create_default_stream_group', + 'PATCH': 'zerver.views.streams.update_default_stream_group', + 'DELETE': 'zerver.views.streams.remove_default_stream_group'}), # GET lists your streams, POST bulk adds, PATCH bulk modifies/removes url(r'^users/me/subscriptions$', rest_dispatch, {'GET': 'zerver.views.streams.list_subscriptions_backend',