Repository: zulip/zulip

Issue:
Reactivate a Stream
On the backend, would it be possible to implement a `do_reactivate_stream()` method?

I took a swing at it ... I can get the Stream back, but none of the topics / messages come with it ... I suspect it's because `do_deactivate_stream()` deletes the stream's cache key:

```
old_cache_key = get_stream_cache_key(old_name, stream.realm)
cache_delete(old_cache_key)
```

Is there a way to "re-construct" the stream's messages, etc.?



Discussion:
Would be happy to have a "Reactivate stream" function added to the codebase, at least as a management command.

If you post a PR with the code you're trying I can take a look, but the caching thing is definitely not relevant (if there's a cache miss, it would just go to the DB).

But my guess is the problem is that you can't see history on streams with `invite_only=True` that were sent while you were not were subscribed, and the deactivation code path sets that bit.

Turns out, it wasn't a problem.  Instead, since the list of messages & topics is cached, the Stream can be re-activated, but it won't be apparent on the front-end until the cache is updated.

.. Came to this realize when I started up the dev server again this morning.  I'll make the PR in a bit.

Sorry, probably shouldn't have closed this.

@timabbott If I wanted the change to be immediately noticeable on the front-end, which cache key would I need to delete?

My intention is to give the owner the option in settings.py to cause the cache delete or not.

It's not a cache issue (though you should probably delete the cache key for the DELETED name).  You'd need to do a `send_event` to notify the frontend about the new stream (might have a bit of complexity here where for browsers that had access to old messages it should be treated as a rename).  The new feature tutorial on readthedocs has a bit of info on how send_event works...

It must be an old_messages issue; I have the send_event component piece working.

I suppose the front-end clients need to be told to fetch messages associated with the re-activated stream?

Yeah, so you may need to do some sort of combined rename/create stream logic on the frontend, where if the frontend knows about the DEACTIVATED name, it'll rename that, otherwise it'll add a new stream with the new name.

One area where we need clarity is how much effort we're going to go through to automatically re-subscribe users, or at least notify them that they can re-subscribe.

At deactivation time we set all users subscriptions to active=False, but that doesn't allow us to distinguish users who deactivated themselves earlier vs. users who got deactivated when the stream itself was deactivated.

For public streams, I recommend we just let folks re-subscribe themselves, and we can basically have them see an announcement that the stream has been reactivated.

For private streams, I don't know what we should do.  One thing about private streams is that they tend to have pretty few subscribers, so at deactivation time, we could probably serial the list of currently subscribed users.  And then at reactivation we notify those users.
@zulipbot claim
@zulipbot claim
Hello @edith007, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->
@zulipbot claim


Resolving patch:
diff --git a/zerver/actions/streams.py b/zerver/actions/streams.py
--- a/zerver/actions/streams.py
+++ b/zerver/actions/streams.py
@@ -6,6 +6,7 @@
 import orjson
 from django.conf import settings
 from django.db import transaction
+from django.db.models import Q, QuerySet
 from django.utils.timezone import now as timezone_now
 from django.utils.translation import gettext as _
 from django.utils.translation import override as override_language
@@ -131,6 +132,105 @@ def do_deactivate_stream(stream: Stream, *, acting_user: Optional[UserProfile])
     )
 
 
+def deactivated_streams_by_old_name(realm: Realm, stream_name: str) -> QuerySet[Stream]:
+    fixed_length_prefix = ".......!DEACTIVATED:"
+    truncated_name = stream_name[0 : Stream.MAX_NAME_LENGTH - len(fixed_length_prefix)]
+
+    old_names: List[str] = []
+    for bang_length in range(1, 21):
+        name = "!" * bang_length + "DEACTIVATED:" + stream_name
+        old_names.append(name[0 : Stream.MAX_NAME_LENGTH])
+
+    possible_streams = Stream.objects.filter(realm=realm, deactivated=True).filter(
+        # We go looking for names as they are post-1b6f68bb59dc; 8
+        # characters, followed by `!DEACTIVATED:`, followed by at
+        # most MAX_NAME_LENGTH-(length of the prefix) of the name
+        # they provided:
+        Q(name__regex=rf"^{fixed_length_prefix}{truncated_name}")
+        # Finally, we go looking for the pre-1b6f68bb59dc version,
+        # which is any number of `!` followed by `DEACTIVATED:`
+        # and a prefix of the old stream name
+        | Q(name__in=old_names),
+    )
+
+    return possible_streams
+
+
+@transaction.atomic(savepoint=False)
+def do_reactivate_stream(
+    stream: Stream, new_name: str, *, acting_user: Optional[UserProfile]
+) -> None:
+    realm = stream.realm
+    if not stream.deactivated:
+        raise JsonableError(_("Stream is not currently deactivated"))
+    if Stream.objects.filter(realm=realm, name=new_name).exists():
+        raise JsonableError(
+            _("Stream named {stream_name} already exists").format(stream_name=new_name)
+        )
+    assert stream.recipient_id is not None
+
+    stream.deactivated = False
+    stream.name = new_name
+
+    # We only set invite_only=True during deactivation, which can lead
+    # to the invalid state of to invite-only but also web-public
+    # streams.  Explicitly reset the access; we do not use
+    # do_change_stream_permission because no users need be notified,
+    # and it cannot handle the broken state that may currently exist.
+    stream.is_web_public = False
+    stream.invite_only = True
+    stream.history_public_to_subscribers = True
+    stream.save(
+        update_fields=[
+            "name",
+            "deactivated",
+            "is_web_public",
+            "invite_only",
+            "history_public_to_subscribers",
+        ]
+    )
+
+    # Update caches
+    cache_set(display_recipient_cache_key(stream.recipient_id), new_name)
+    messages = Message.objects.filter(recipient_id=stream.recipient_id).only("id")
+    cache_delete_many(to_dict_cache_key_id(message.id) for message in messages)
+
+    # Unset the is_web_public cache on attachments, since the stream is now private.
+    Attachment.objects.filter(messages__recipient_id=stream.recipient_id).update(is_web_public=None)
+    ArchivedAttachment.objects.filter(messages__recipient_id=stream.recipient_id).update(
+        is_web_public=None
+    )
+
+    RealmAuditLog.objects.create(
+        realm=realm,
+        acting_user=acting_user,
+        modified_stream=stream,
+        event_type=RealmAuditLog.STREAM_REACTIVATED,
+        event_time=timezone_now(),
+    )
+
+    # All admins always get to know about private streams' existence,
+    # but we only subscribe the realm owners.
+    send_stream_creation_event(
+        realm, stream, [user.id for user in realm.get_admin_users_and_bots()]
+    )
+    bulk_add_subscriptions(
+        realm=realm,
+        streams=[stream],
+        users=realm.get_human_owner_users(),
+        acting_user=acting_user,
+    )
+
+    sender = get_system_bot(settings.NOTIFICATION_BOT, stream.realm_id)
+    with override_language(stream.realm.default_language):
+        internal_send_stream_message(
+            sender,
+            stream,
+            str(Realm.STREAM_EVENTS_NOTIFICATION_TOPIC),
+            _("Stream {stream_name} un-archived.").format(stream_name=new_name),
+        )
+
+
 def bulk_delete_cache_keys(message_ids_to_clear: List[int]) -> None:
     while len(message_ids_to_clear) > 0:
         batch = message_ids_to_clear[0:5000]
diff --git a/zerver/management/commands/reactivate_stream.py b/zerver/management/commands/reactivate_stream.py
new file mode 100644
--- /dev/null
+++ b/zerver/management/commands/reactivate_stream.py
@@ -0,0 +1,91 @@
+from argparse import ArgumentParser
+from typing import Any, Optional
+
+from django.core.management.base import CommandError
+
+from zerver.actions.streams import deactivated_streams_by_old_name, do_reactivate_stream
+from zerver.lib.management import ZulipBaseCommand
+from zerver.models import RealmAuditLog, Stream
+
+
+class Command(ZulipBaseCommand):
+    help = """Reactivate a stream that was deactivated."""
+
+    def add_arguments(self, parser: ArgumentParser) -> None:
+        specify_stream = parser.add_mutually_exclusive_group(required=True)
+        specify_stream.add_argument(
+            "-s",
+            "--stream",
+            help="Name of a deactivated stream in the realm.",
+        )
+        specify_stream.add_argument(
+            "--stream-id",
+            help="ID of a deactivated stream in the realm.",
+        )
+        parser.add_argument(
+            "-n",
+            "--new-name",
+            required=False,
+            help="Name to reactivate as; defaults to the old name.",
+        )
+        self.add_realm_args(parser, required=True)
+
+    def handle(self, *args: Any, **options: Optional[str]) -> None:
+        realm = self.get_realm(options)
+        assert realm is not None  # Should be ensured by parser
+
+        # Looking up the stream is complicated, since they get renamed
+        # when they are deactivated, in a transformation which may be
+        # lossy.
+
+        if options["stream_id"] is not None:
+            stream = Stream.objects.get(id=options["stream_id"])
+            if not stream.deactivated:
+                raise CommandError(
+                    f"Stream id {stream.id}, named '{stream.name}', is not deactivated"
+                )
+            if options["new_name"] is None:
+                raise CommandError("--new-name flag is required with --stream-id")
+            new_name = options["new_name"]
+        else:
+            stream_name = options["stream"]
+            assert stream_name is not None
+
+            possible_streams = deactivated_streams_by_old_name(realm, stream_name)
+            if len(possible_streams) == 0:
+                raise CommandError("No matching deactivated streams found!")
+
+            if len(possible_streams) > 1:
+                # Print ids of all possible streams, support passing by id
+                print("Matching streams:")
+                for stream in possible_streams:
+                    last_deactivation = (
+                        RealmAuditLog.objects.filter(
+                            realm=realm,
+                            modified_stream=stream,
+                            event_type=RealmAuditLog.STREAM_DEACTIVATED,
+                        )
+                        .order_by("-id")
+                        .first()
+                    )
+                    assert last_deactivation is not None
+                    print(
+                        f"  ({stream.id}) {stream.name}, deactivated on {last_deactivation.event_time}"
+                    )
+                raise CommandError(
+                    "More than one matching stream found!  Specify which with --stream-id"
+                )
+
+            stream = possible_streams[0]
+            if options["new_name"] is not None:
+                new_name = options["new_name"]
+            else:
+                new_name = stream_name
+
+        if Stream.objects.filter(realm=realm, name=new_name).exists():
+            raise CommandError(
+                f"Stream with name '{new_name}' already exists; pass a different --new-name"
+            )
+
+        assert stream is not None
+        do_reactivate_stream(stream, new_name, acting_user=None)


Test patch:
diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py
--- a/zerver/tests/test_subs.py
+++ b/zerver/tests/test_subs.py
@@ -28,9 +28,11 @@
 from zerver.actions.streams import (
     bulk_add_subscriptions,
     bulk_remove_subscriptions,
+    deactivated_streams_by_old_name,
     do_change_stream_group_based_setting,
     do_change_stream_post_policy,
     do_deactivate_stream,
+    do_reactivate_stream,
 )
 from zerver.actions.user_groups import add_subgroups_to_user_group, check_add_user_group
 from zerver.actions.users import do_change_user_role, do_deactivate_user
@@ -1334,6 +1336,95 @@ def test_deactivate_stream_marks_messages_as_read(self) -> None:
         self.assertTrue(new_stream_usermessage.flags.read)
         self.assertFalse(denmark_usermessage.flags.read)
 
+    def test_deactivated_streams_by_old_name(self) -> None:
+        realm = get_realm("zulip")
+        stream = self.make_stream("new_stream")
+        do_deactivate_stream(stream, acting_user=None)
+        self.assertEqual(set(deactivated_streams_by_old_name(realm, "new_stream")), {stream})
+
+        second_stream = self.make_stream("new_stream")
+        do_deactivate_stream(second_stream, acting_user=None)
+        self.assertEqual(
+            set(deactivated_streams_by_old_name(realm, "new_stream")), {stream, second_stream}
+        )
+
+        self.make_stream("!DEACTIVATED:old_style")  # This is left active
+        old_style = self.make_stream("old_style")
+        do_deactivate_stream(old_style, acting_user=None)
+        old_style.name = "!!DEACTIVATED:old_style"
+        old_style.save()
+        self.assertEqual(set(deactivated_streams_by_old_name(realm, "old_style")), {old_style})
+
+    def test_reactivate_stream_active_stream(self) -> None:
+        stream = self.make_stream("new_stream")
+        with self.assertRaisesRegex(JsonableError, "Stream is not currently deactivated"):
+            do_reactivate_stream(stream, new_name="new_stream", acting_user=None)
+
+    def test_reactivate_stream_existing_name(self) -> None:
+        stream = self.make_stream("new_stream")
+        self.make_stream("existing")
+        do_deactivate_stream(stream, acting_user=None)
+        with self.assertRaisesRegex(JsonableError, "Stream named existing already exists"):
+            do_reactivate_stream(stream, new_name="existing", acting_user=None)
+
+    def test_reactivate_stream(self) -> None:
+        desdemona = self.example_user("desdemona")
+        iago = self.example_user("iago")
+        hamlet = self.example_user("hamlet")
+        cordelia = self.example_user("cordelia")
+
+        stream = self.make_stream("new_stream", is_web_public=True)
+        self.subscribe(hamlet, stream.name)
+        self.subscribe(cordelia, stream.name)
+        do_deactivate_stream(stream, acting_user=None)
+        with self.capture_send_event_calls(expected_num_events=4) as events:
+            do_reactivate_stream(stream, new_name="new_stream", acting_user=None)
+
+        # Tell all admins and owners that the stream exists
+        self.assertEqual(events[0]["event"]["op"], "create")
+        self.assertEqual(events[0]["event"]["streams"][0]["name"], "new_stream")
+        self.assertEqual(events[0]["event"]["streams"][0]["stream_id"], stream.id)
+        self.assertEqual(set(events[0]["users"]), {iago.id, desdemona.id})
+
+        # Tell the owners that they're subscribed to it
+        self.assertEqual(events[1]["event"]["op"], "add")
+        self.assertEqual(events[1]["event"]["subscriptions"][0]["name"], "new_stream")
+        self.assertEqual(events[1]["event"]["subscriptions"][0]["stream_id"], stream.id)
+        self.assertEqual(events[1]["users"], [desdemona.id])
+
+        # Send a message there logging the reactivation
+        self.assertEqual(events[2]["event"]["type"], "message")
+
+        # iago (as an admin) gets to know that desdemona (the owner) is now subscribed.
+        self.assertEqual(
+            events[3],
+            {
+                "event": {
+                    "op": "peer_add",
+                    "stream_ids": [stream.id],
+                    "type": "subscription",
+                    "user_ids": [desdemona.id],
+                },
+                "users": [iago.id],
+            },
+        )
+
+        stream = Stream.objects.get(id=stream.id)
+        self.assertFalse(stream.deactivated)
+        self.assertTrue(stream.invite_only)
+        self.assertFalse(stream.is_web_public)
+        self.assertTrue(stream.history_public_to_subscribers)
+
+        self.assertEqual(
+            [desdemona.id],
+            [
+                sub.user_profile_id
+                for sub in get_active_subscriptions_for_stream_id(
+                    stream.id, include_deactivated_users=True
+                )
+            ],
+        )
+
     def test_vacate_private_stream_removes_default_stream(self) -> None:
         stream = self.make_stream("new_stream", invite_only=True)
         self.subscribe(self.example_user("hamlet"), stream.name)