diff --git a/zerver/migrations/0679_zerver_message_edit_history_id.py b/zerver/migrations/0679_zerver_message_edit_history_id.py new file mode 100644 index 0000000000..83e6d31fd2 --- /dev/null +++ b/zerver/migrations/0679_zerver_message_edit_history_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-02-26 17:25 + +from django.contrib.postgres.operations import AddIndexConcurrently +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("zerver", "0678_remove_realm_allow_edit_history"), + ] + + operations = [ + AddIndexConcurrently( + model_name="message", + index=models.Index( + condition=models.Q(("edit_history__isnull", False)), + fields=["id"], + name="zerver_message_edit_history_id", + ), + ), + ] diff --git a/zerver/migrations/0680_rename_general_chat_to_empty_string_topic.py b/zerver/migrations/0680_rename_general_chat_to_empty_string_topic.py new file mode 100644 index 0000000000..6da58292b7 --- /dev/null +++ b/zerver/migrations/0680_rename_general_chat_to_empty_string_topic.py @@ -0,0 +1,147 @@ +from typing import Any + +from django.db import connection, migrations, models, transaction +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps +from psycopg2.sql import SQL, Identifier + + +def update_edit_history(message_model: type[Any]) -> None: + BATCH_SIZE = 10000 + lower_id_bound = 0 + + max_id = message_model.objects.aggregate(models.Max("id"))["id__max"] + if max_id is None: + return + + while lower_id_bound < max_id: + upper_id_bound = min(lower_id_bound + BATCH_SIZE, max_id) + with connection.cursor() as cursor: + query = SQL( + """ + UPDATE {table_name} + SET edit_history = ( + SELECT JSONB_AGG( + elem + || + (CASE + WHEN elem ? 'prev_topic' AND elem->>'prev_topic' = 'general chat' + THEN '{{"prev_topic": ""}}'::jsonb + ELSE '{{}}'::jsonb + END) + || + (CASE + WHEN elem ? 'topic' AND elem->>'topic' = 'general chat' + THEN '{{"topic": ""}}'::jsonb + ELSE '{{}}'::jsonb + END) + )::text + FROM JSONB_ARRAY_ELEMENTS(edit_history::jsonb) AS elem + ) + WHERE edit_history IS NOT NULL + AND id > %(lower_id_bound)s AND id <= %(upper_id_bound)s + AND ( + edit_history::jsonb @> '[{{"prev_topic": "general chat"}}]' OR + edit_history::jsonb @> '[{{"topic": "general chat"}}]' + ); + """ + ).format(table_name=Identifier(message_model._meta.db_table)) + cursor.execute( + query, + { + "lower_id_bound": lower_id_bound, + "upper_id_bound": upper_id_bound, + }, + ) + + print(f"Processed {upper_id_bound} / {max_id}") + lower_id_bound += BATCH_SIZE + + +def rename_general_chat_to_empty_string_topic( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + """Because legacy clients will be unable to distinguish the topic "general chat" + from the "" topic (displayed as italicized "general chat"), it's helpful + to not have both types of topics exist in an organization. + + Further, the "general chat" topic is likely to in almost every + case be the result of an organization that followed our advice to + just make a "general chat" topic for topic-free chat; the new + "general chat" feature naturally replaces that learned + behavior. + + So it makes sense to just consider those older "general chat" + topics to be the same as the modern general chat topic. + + The technical way to do that is to rewrite those topics in the + database to be represented as `""` rather than "general chat", + since we've endeavored to make the distinction between those two + storage approaches invisible to legacy clients at the API layer. + + Thus, we don't generate edit history entries for this, since we're + thinking of it as redefining how "general chat" is stored in the + database. + """ + Realm = apps.get_model("zerver", "Realm") + Message = apps.get_model("zerver", "Message") + UserTopic = apps.get_model("zerver", "UserTopic") + ArchivedMessage = apps.get_model("zerver", "ArchivedMessage") + ScheduledMessage = apps.get_model("zerver", "ScheduledMessage") + + for realm in Realm.objects.all(): + with transaction.atomic(durable=True): + # Uses index "zerver_message_realm_upper_subject" + message_queryset = Message.objects.filter(realm=realm, subject__iexact="general chat") + channel_ids = list( + message_queryset.distinct("recipient__type_id").values_list( + "recipient__type_id", flat=True + ) + ) + + message_queryset.update(subject="") + + # Limiting the UserTopic query to only those channels that + # contain an actual general chat topic does not guaranteed + # updating all UserTopic rows, since it's possible to + # follow/mute an empty topic. But it does guarantee that + # we update all rows that have any current effect. + # + # Uses index "zerver_mutedtopic_stream_topic" + UserTopic.objects.filter( + stream_id__in=channel_ids, topic_name__iexact="general chat" + ).update(topic_name="") + + ArchivedMessage.objects.filter(realm=realm, subject__iexact="general chat").update( + subject="" + ) + ScheduledMessage.objects.filter(realm=realm, subject__iexact="general chat").update( + subject="" + ) + + for message_model in [Message, ArchivedMessage]: + update_edit_history(message_model) + + +class Migration(migrations.Migration): + """ + Zulip now supports empty string as a valid topic name. + For clients predating this feature, such messages appear + in "general chat" topic. Messages sent to "general chat" are + stored in the database as having a "" topic. This migration + renames the existing "general chat" topic in the database to "". + """ + + atomic = False + + dependencies = [ + ("zerver", "0679_zerver_message_edit_history_id"), + ] + + operations = [ + migrations.RunPython( + rename_general_chat_to_empty_string_topic, + reverse_code=migrations.RunPython.noop, + elidable=True, + ), + ] diff --git a/zerver/models/messages.py b/zerver/models/messages.py index fc8f71ad1e..a19e0ad9cc 100644 --- a/zerver/models/messages.py +++ b/zerver/models/messages.py @@ -163,7 +163,7 @@ class Message(AbstractMessage): # Name to be used for the empty topic with clients that have not # yet migrated to have the `empty_topic_name` client capability. - EMPTY_TOPIC_FALLBACK_NAME = "test general chat" + EMPTY_TOPIC_FALLBACK_NAME = "general chat" class Meta: indexes = [ @@ -238,6 +238,12 @@ class Message(AbstractMessage): F("id").desc(nulls_last=True), name="zerver_message_realm_id", ), + models.Index( + # Used by 0680_rename_general_chat_to_empty_string_topic + fields=["id"], + condition=Q(edit_history__isnull=False), + name="zerver_message_edit_history_id", + ), ] def topic_name(self) -> str: