diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index 5a406903c6..643f0cdec8 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -11,6 +11,12 @@ below features are supported. ## Changes in Zulip 5.0 +**Feature level 104** + +* [`PATCH /realm`]: Added `string_id` parameter for changing an + organization's subdomain. Currently, this is only allowed for + changing changing a demo organization to a normal one. + **Feature level 103** * [`POST /register`](/api/register-queue): Added `create_web_public_stream_policy` diff --git a/version.py b/version.py index ac6b519911..5d2d4b2aef 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3" # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md, as well as # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 103 +API_FEATURE_LEVEL = 104 # 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/lib/actions.py b/zerver/lib/actions.py index a7e8184aa1..244c390402 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -1116,10 +1116,21 @@ def do_reactivate_realm(realm: Realm) -> None: def do_change_realm_subdomain( realm: Realm, new_subdomain: str, *, acting_user: Optional[UserProfile] ) -> None: + """Changing a realm's subdomain is a highly disruptive operation, + because all existing clients will need to be updated to point to + the new URL. Further, requests to fetch data frmo existing event + queues will fail with an authentication error when this change + happens (because the old subdomain is no longer associated with + the realm), making it hard for us to provide a graceful update + experience for clients. + """ old_subdomain = realm.subdomain old_uri = realm.uri + # If the realm had been a demo organization scheduled for + # deleting, clear that state. + realm.demo_organization_scheduled_deletion_date = None realm.string_id = new_subdomain - realm.save(update_fields=["string_id"]) + realm.save(update_fields=["string_id", "demo_organization_scheduled_deletion_date"]) RealmAuditLog.objects.create( realm=realm, event_type=RealmAuditLog.REALM_SUBDOMAIN_CHANGED, diff --git a/zerver/models.py b/zerver/models.py index 9dab6ecae9..e381953440 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -219,7 +219,7 @@ class Realm(models.Model): string_id: str = models.CharField(max_length=MAX_REALM_SUBDOMAIN_LENGTH, unique=True) date_created: datetime.datetime = models.DateTimeField(default=timezone_now) - demo_organization_scheduled_deletion_date: datetime.datetime = models.DateTimeField( + demo_organization_scheduled_deletion_date: Optional[datetime.datetime] = models.DateTimeField( default=None, null=True ) deactivated: bool = models.BooleanField(default=False) diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 4a96bdde78..c959c8bf62 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -171,6 +171,37 @@ class RealmTest(ZulipTestCase): realm = get_realm("zulip") self.assertNotEqual(realm.description, new_description) + def test_realm_convert_demo_realm(self) -> None: + data = dict(string_id="coolrealm") + + self.login("iago") + result = self.client_patch("/json/realm", data) + self.assert_json_error(result, "Must be an organization owner") + + self.login("desdemona") + result = self.client_patch("/json/realm", data) + self.assert_json_error(result, "Must be a demo organization.") + + data = dict(string_id="lear") + self.login("desdemona") + realm = get_realm("zulip") + realm.demo_organization_scheduled_deletion_date = timezone_now() + datetime.timedelta( + days=30 + ) + realm.save() + result = self.client_patch("/json/realm", data) + self.assert_json_error(result, "Subdomain unavailable. Please choose a different one.") + + # Now try to change the string_id to something available. + data = dict(string_id="coolrealm") + result = self.client_patch("/json/realm", data) + self.assert_json_success(result) + json = orjson.loads(result.content) + self.assertEqual(json["realm_uri"], "http://coolrealm.testserver") + realm = get_realm("coolrealm") + self.assertIsNone(realm.demo_organization_scheduled_deletion_date) + self.assertEqual(realm.string_id, data["string_id"]) + def test_realm_name_length(self) -> None: new_name = "A" * (Realm.MAX_REALM_NAME_LENGTH + 1) data = dict(name=new_name) diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 2490cc6462..ca22837090 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -10,6 +10,7 @@ from confirmation.models import Confirmation, ConfirmationKeyException, get_obje from zerver.decorator import require_realm_admin, require_realm_owner from zerver.forms import check_subdomain_available as check_subdomain from zerver.lib.actions import ( + do_change_realm_subdomain, do_deactivate_realm, do_reactivate_realm, do_set_realm_authentication_methods, @@ -133,6 +134,10 @@ def update_realm( digest_weekday: Optional[int] = REQ( json_validator=check_int_in(Realm.DIGEST_WEEKDAY_VALUES), default=None ), + string_id: Optional[str] = REQ( + str_validator=check_capped_string(Realm.MAX_REALM_SUBDOMAIN_LENGTH), + default=None, + ), ) -> HttpResponse: realm = user_profile.realm @@ -273,6 +278,21 @@ def update_realm( else: data["default_code_block_language"] = default_code_block_language + if string_id is not None: + if not user_profile.is_realm_owner: + raise OrganizationOwnerRequired() + + if realm.demo_organization_scheduled_deletion_date is None: + raise JsonableError(_("Must be a demo organization.")) + + try: + check_subdomain(string_id) + except ValidationError as err: + raise JsonableError(str(err.message)) + + do_change_realm_subdomain(realm, string_id, acting_user=user_profile) + data["realm_uri"] = realm.uri + return json_success(data)