diff --git a/api_docs/changelog.md b/api_docs/changelog.md index f5b6ef494f..8dc57051c6 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 331** + +* [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events), + `PATCH /realm`: Added `moderation_request_channel_id` realm setting, which is + the ID of the private channel to which moderation requests will be sent. + **Feature level 330** * [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events): diff --git a/version.py b/version.py index b2581ff5a2..0524ec7300 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 330 # Last bumped for updating default streams data. +API_FEATURE_LEVEL = 331 # Last bumped for realm-level setting, `moderation_request_channel`. # 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 diff --git a/zerver/actions/realm_settings.py b/zerver/actions/realm_settings.py index 75f2926ed6..f2e1c55817 100644 --- a/zerver/actions/realm_settings.py +++ b/zerver/actions/realm_settings.py @@ -375,6 +375,7 @@ def do_set_realm_authentication_methods( def do_set_realm_stream( realm: Realm, field: Literal[ + "moderation_request_channel", "new_stream_announcements_stream", "signup_announcements_stream", "zulip_update_announcements_stream", @@ -386,7 +387,11 @@ def do_set_realm_stream( ) -> None: # We could calculate more of these variables from `field`, but # it's probably more readable to not do so. - if field == "new_stream_announcements_stream": + if field == "moderation_request_channel": + old_value = realm.moderation_request_channel_id + realm.moderation_request_channel = stream + property = "moderation_request_channel_id" + elif field == "new_stream_announcements_stream": old_value = realm.new_stream_announcements_stream_id realm.new_stream_announcements_stream = stream property = "new_stream_announcements_stream_id" @@ -426,6 +431,16 @@ def do_set_realm_stream( send_event_on_commit(realm, event, active_user_ids(realm.id)) +def do_set_realm_moderation_request_channel( + realm: Realm, stream: Stream | None, stream_id: int, *, acting_user: UserProfile | None +) -> None: + if stream is not None and stream.is_public(): + raise JsonableError(_("Moderation request channel must be private.")) + do_set_realm_stream( + realm, "moderation_request_channel", stream, stream_id, acting_user=acting_user + ) + + def do_set_realm_new_stream_announcements_stream( realm: Realm, stream: Stream | None, stream_id: int, *, acting_user: UserProfile | None ) -> None: diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 478c3ca70f..8d0322f7d0 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -944,6 +944,7 @@ def check_realm_update( value = event["value"] if prop in [ + "moderation_request_channel_id", "new_stream_announcements_stream_id", "signup_announcements_stream_id", "zulip_update_announcements_stream_id", diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 08f8e97efe..efc957d0fd 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -420,6 +420,12 @@ def fetch_initial_state_data( else server_default_jitsi_server_url ) + moderation_request_channel = realm.get_moderation_request_channel() + if moderation_request_channel: + state["realm_moderation_request_channel_id"] = moderation_request_channel.id + else: + state["realm_moderation_request_channel_id"] = -1 + new_stream_announcements_stream = realm.get_new_stream_announcements_stream() if new_stream_announcements_stream: state["realm_new_stream_announcements_stream_id"] = new_stream_announcements_stream.id diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index dfb53fa474..b7074b0f1c 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -1203,6 +1203,7 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea update_model_ids(PresenceSequence, data, "presencesequence") # Now we prepare to import the Realm table + re_map_foreign_keys(data, "zerver_realm", "moderation_request_channel", related_table="stream") re_map_foreign_keys( data, "zerver_realm", "new_stream_announcements_stream", related_table="stream" ) diff --git a/zerver/migrations/0642_realm_moderation_request_channel.py b/zerver/migrations/0642_realm_moderation_request_channel.py new file mode 100644 index 0000000000..0e0af75406 --- /dev/null +++ b/zerver/migrations/0642_realm_moderation_request_channel.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.9 on 2024-12-16 07:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0641_web_suggest_update_time_zone"), + ] + + operations = [ + migrations.AddField( + model_name="realm", + name="moderation_request_channel", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="zerver.stream", + ), + ), + ] diff --git a/zerver/models/realms.py b/zerver/models/realms.py index 32534bdd39..4e57eb8329 100644 --- a/zerver/models/realms.py +++ b/zerver/models/realms.py @@ -378,6 +378,13 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub ZULIP_SANDBOX_CHANNEL_NAME = gettext_lazy("sandbox") DEFAULT_NOTIFICATION_STREAM_NAME = gettext_lazy("general") STREAM_EVENTS_NOTIFICATION_TOPIC_NAME = gettext_lazy("channel events") + moderation_request_channel = models.ForeignKey( + "Stream", + related_name="+", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) new_stream_announcements_stream = models.ForeignKey( "Stream", related_name="+", @@ -911,6 +918,14 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub def get_bot_domain(self) -> str: return get_fake_email_domain(self.host) + def get_moderation_request_channel(self) -> Optional["Stream"]: + if ( + self.moderation_request_channel is not None + and not self.moderation_request_channel.deactivated + ): + return self.moderation_request_channel + return None + def get_new_stream_announcements_stream(self) -> Optional["Stream"]: if ( self.new_stream_announcements_stream is not None @@ -1148,6 +1163,7 @@ def get_realm_with_settings(realm_id: int) -> Realm: # * All the settings that can be set to anonymous groups. # * Announcements streams. return Realm.objects.select_related( + "moderation_request_channel", "create_multiuse_invite_group", "create_multiuse_invite_group__named_user_group", "can_access_all_users_group", diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index a987102222..889d1e52e3 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -4770,6 +4770,21 @@ paths: **Changes**: Before Zulip 6.0 (feature level 138), no limit was represented using the special value `0`. + moderation_request_channel_id: + type: integer + description: | + The ID of the private channel to which messages flagged by users for + moderation are sent. Moderators can use this channel to review and + act on reported content. + + Will be `-1` if moderation requests are disabled. + + Clients should check whether moderation requests are disabled to + determine whether to present a "report message" feature in their UI + within a given organization. + + **Changes**: New in Zulip 10.0 (feature level 331). Previously, + no "report message" features existed in Zulip. move_messages_within_stream_limit_seconds: type: integer nullable: true @@ -17569,6 +17584,21 @@ paths: **Changes**: New in Zulip 5.0 (feature level 74). Previously, this was hardcoded to 90 seconds, and clients should use that as a fallback value when interacting with servers where this field is not present. + realm_moderation_request_channel_id: + type: integer + description: | + The ID of the private channel to which messages flagged by users for + moderation are sent. Moderators can use this channel to review and + act on reported content. + + Will be `-1` if moderation requests are disabled. + + Clients should check whether moderation requests are disabled to + determine whether to present a "report message" feature in their UI + within a given organization. + + **Changes**: New in Zulip 10.0 (feature level 331). Previously, + no "report message" feature existed in Zulip. realm_new_stream_announcements_stream_id: type: integer description: | diff --git a/zerver/tests/test_audit_log.py b/zerver/tests/test_audit_log.py index ab846e52eb..3d2b12e8b9 100644 --- a/zerver/tests/test_audit_log.py +++ b/zerver/tests/test_audit_log.py @@ -35,6 +35,7 @@ from zerver.actions.realm_settings import ( do_deactivate_realm, do_reactivate_realm, do_set_realm_authentication_methods, + do_set_realm_moderation_request_channel, do_set_realm_new_stream_announcements_stream, do_set_realm_property, do_set_realm_signup_announcements_stream, @@ -649,6 +650,29 @@ class TestRealmAuditLog(ZulipTestCase): 1, ) + def test_set_realm_moderation_request_channel(self) -> None: + now = timezone_now() + realm = get_realm("zulip") + user = self.example_user("hamlet") + old_value = realm.moderation_request_channel + stream = self.make_stream("private_stream", invite_only=True) + + do_set_realm_moderation_request_channel(realm, stream, stream.id, acting_user=user) + self.assertEqual( + RealmAuditLog.objects.filter( + realm=realm, + event_type=AuditLogEventType.REALM_PROPERTY_CHANGED, + event_time__gte=now, + acting_user=user, + extra_data={ + RealmAuditLog.OLD_VALUE: old_value, + RealmAuditLog.NEW_VALUE: stream.id, + "property": "moderation_request_channel", + }, + ).count(), + 1, + ) + def test_change_icon_source(self) -> None: test_start = timezone_now() realm = get_realm("zulip") diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index b490b3f431..adc2422d81 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -79,6 +79,7 @@ from zerver.actions.realm_settings import ( do_deactivate_realm, do_set_push_notifications_enabled_end_timestamp, do_set_realm_authentication_methods, + do_set_realm_moderation_request_channel, do_set_realm_new_stream_announcements_stream, do_set_realm_property, do_set_realm_signup_announcements_stream, @@ -2419,6 +2420,22 @@ class NormalActionsTest(BaseAction): ) check_realm_update("events[0]", events[0], "zulip_update_announcements_stream_id") + def test_change_realm_moderation_request_channel(self) -> None: + channel = self.make_stream("private_stream", invite_only=True) + + for moderation_request_channel, moderation_request_channel_id in ( + (channel, channel.id), + (None, -1), + ): + with self.verify_action() as events: + do_set_realm_moderation_request_channel( + self.user_profile.realm, + moderation_request_channel, + moderation_request_channel_id, + acting_user=None, + ) + check_realm_update("events[0]", events[0], "moderation_request_channel_id") + def test_change_is_admin(self) -> None: reset_email_visibility_to_everyone_in_zulip_realm() diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 1bcc4628e7..d409eab073 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -213,6 +213,7 @@ class HomeTest(ZulipTestCase): "realm_want_advertise_in_communities_directory", "realm_wildcard_mention_policy", "realm_zulip_update_announcements_stream_id", + "realm_moderation_request_channel_id", "recent_private_conversations", "saved_snippets", "scheduled_messages", @@ -827,6 +828,18 @@ class HomeTest(ZulipTestCase): get_stream("Denmark", realm).id, ) + def test_moderation_request_channel(self) -> None: + realm = get_realm("zulip") + realm.moderation_request_channel = self.make_stream("private_stream", invite_only=True) + realm.save() + self.login("hamlet") + result = self._get_home_page() + page_params = self._get_page_params(result) + self.assertEqual( + page_params["state_data"]["realm_moderation_request_channel_id"], + get_stream("private_stream", realm).id, + ) + def test_people(self) -> None: hamlet = self.example_user("hamlet") realm = get_realm("zulip") diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 0dffeb9935..83fdd45304 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -800,6 +800,73 @@ class RealmTest(ZulipTestCase): do_deactivate_stream(zulip_update_announcements_stream, acting_user=None) self.assertIsNone(realm.get_zulip_update_announcements_stream()) + def test_change_moderation_request_channel(self) -> None: + # We need an admin user. + self.login("iago") + + disabled_moderation_request_channel_id = -1 + req = dict( + moderation_request_channel_id=orjson.dumps( + disabled_moderation_request_channel_id + ).decode() + ) + result = self.client_patch("/json/realm", req) + self.assert_json_success(result) + realm = get_realm("zulip") + self.assertEqual(realm.moderation_request_channel, None) + + # Test that admin can set the setting to a private stream. + new_moderation_request_channel_id = self.make_stream("private_stream", invite_only=True).id + req = dict( + moderation_request_channel_id=orjson.dumps(new_moderation_request_channel_id).decode() + ) + result = self.client_patch("/json/realm", req) + self.assert_json_success(result) + realm = get_realm("zulip") + assert realm.moderation_request_channel is not None + self.assertEqual(realm.moderation_request_channel.id, new_moderation_request_channel_id) + + invalid_moderation_request_channel_id = 4321 + req = dict( + moderation_request_channel_id=orjson.dumps( + invalid_moderation_request_channel_id + ).decode() + ) + result = self.client_patch("/json/realm", req) + self.assert_json_error(result, "Invalid channel ID") + realm = get_realm("zulip") + assert realm.moderation_request_channel is not None + self.assertNotEqual( + realm.moderation_request_channel.id, invalid_moderation_request_channel_id + ) + + # Test that setting this to public channel should fail. + public_moderation_request_channel_id = Stream.objects.get(name="Denmark").id + req = dict( + moderation_request_channel_id=orjson.dumps( + public_moderation_request_channel_id + ).decode() + ) + result = self.client_patch("/json/realm", req) + self.assert_json_error(result, "Moderation request channel must be private.") + realm = get_realm("zulip") + assert realm.moderation_request_channel is not None + self.assertNotEqual( + realm.moderation_request_channel.id, public_moderation_request_channel_id + ) + + def test_get_default_moderation_request_channel(self) -> None: + realm = get_realm("zulip") + verona = get_stream("verona", realm) + realm.moderation_request_channel = verona + realm.save(update_fields=["moderation_request_channel"]) + + moderation_request_channel = realm.get_moderation_request_channel() + assert moderation_request_channel is not None + self.assertEqual(moderation_request_channel, verona) + do_deactivate_stream(moderation_request_channel, acting_user=None) + self.assertIsNone(realm.get_moderation_request_channel()) + def test_change_realm_default_language(self) -> None: # we need an admin user. self.login("iago") diff --git a/zerver/views/realm.py b/zerver/views/realm.py index fcb9afbde5..368368ac2c 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -18,6 +18,7 @@ from zerver.actions.realm_settings import ( do_deactivate_realm, do_reactivate_realm, do_set_realm_authentication_methods, + do_set_realm_moderation_request_channel, do_set_realm_new_stream_announcements_stream, do_set_realm_property, do_set_realm_signup_announcements_stream, @@ -123,6 +124,7 @@ def update_realm( authentication_methods: Json[dict[str, Any]] | None = None, # Note: push_notifications_enabled and push_notifications_enabled_end_timestamp # are not offered here as it is maintained by the server, not via the API. + moderation_request_channel_id: Json[int] | None = None, new_stream_announcements_stream_id: Json[int] | None = None, signup_announcements_stream_id: Json[int] | None = None, zulip_update_announcements_stream_id: Json[int] | None = None, @@ -402,9 +404,25 @@ def update_realm( do_set_realm_authentication_methods(realm, authentication_methods, acting_user=user_profile) data["authentication_methods"] = authentication_methods - # Realm.new_stream_announcements_stream, Realm.signup_announcements_stream, - # and Realm.zulip_update_announcements_stream are not boolean, str or integer field, - # and thus doesn't fit into the do_set_realm_property framework. + # Channel-valued settings are not yet fully supported by the + # property_types framework, and thus have explicit blocks here. + if moderation_request_channel_id is not None and ( + realm.moderation_request_channel is None + or realm.moderation_request_channel.id != moderation_request_channel_id + ): + new_moderation_request_channel_id = None + if moderation_request_channel_id >= 0: + (new_moderation_request_channel_id, sub) = access_stream_by_id( + user_profile, moderation_request_channel_id, allow_realm_admin=True + ) + do_set_realm_moderation_request_channel( + realm, + new_moderation_request_channel_id, + moderation_request_channel_id, + acting_user=user_profile, + ) + data["moderation_request_channel_id"] = moderation_request_channel_id + if new_stream_announcements_stream_id is not None and ( realm.new_stream_announcements_stream is None or (realm.new_stream_announcements_stream.id != new_stream_announcements_stream_id)