diff --git a/corporate/tests/test_support_views.py b/corporate/tests/test_support_views.py index 6a8ef3a38b..906adba5b9 100644 --- a/corporate/tests/test_support_views.py +++ b/corporate/tests/test_support_views.py @@ -4,6 +4,7 @@ from unittest import mock import orjson import time_machine +from django.conf import settings from django.utils.timezone import now as timezone_now from typing_extensions import override @@ -1158,6 +1159,55 @@ class TestSupportEndpoint(ZulipTestCase): ["Organization type of zulip changed from Business to Government"], result ) + def test_change_max_invites(self) -> None: + realm = get_realm("zulip") + iago = self.example_user("iago") + self.login_user(iago) + + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + result = self.client_post( + "/activity/support", {"realm_id": f"{realm.id}", "max_invites": "1"} + ) + self.assert_in_success_response( + [ + "Cannot update maximum number of daily invitations for zulip, because 1 is less than the default for the current plan type." + ], + result, + ) + realm.refresh_from_db() + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + + result = self.client_post( + "/activity/support", {"realm_id": f"{realm.id}", "max_invites": "700"} + ) + self.assert_in_success_response( + ["Maximum number of daily invitations for zulip updated to 700."], result + ) + realm.refresh_from_db() + self.assertEqual(realm.max_invites, 700) + + result = self.client_post( + "/activity/support", {"realm_id": f"{realm.id}", "max_invites": "0"} + ) + self.assert_in_success_response( + [ + "Maximum number of daily invitations for zulip updated to the default for the current plan type." + ], + result, + ) + realm.refresh_from_db() + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + + result = self.client_post( + "/activity/support", {"realm_id": f"{realm.id}", "max_invites": "0"} + ) + self.assert_in_success_response( + [ + "Cannot update maximum number of daily invitations for zulip, because the default for the current plan type is already set." + ], + result, + ) + def test_attach_discount(self) -> None: lear_realm = get_realm("lear") customer = self.create_customer_and_plan(lear_realm, True) diff --git a/corporate/views/support.py b/corporate/views/support.py index 507d0d718f..0754168a78 100644 --- a/corporate/views/support.py +++ b/corporate/views/support.py @@ -46,6 +46,7 @@ from corporate.lib.support import ( from corporate.models import CustomerPlan from zerver.actions.create_realm import do_change_realm_subdomain from zerver.actions.realm_settings import ( + do_change_realm_max_invites, do_change_realm_org_type, do_change_realm_plan_type, do_deactivate_realm, @@ -312,6 +313,22 @@ def get_realm_plan_type_options_for_discount() -> list[SupportSelectOption]: return plan_types +def get_default_max_invites_for_plan_type(realm: Realm) -> int: + if realm.plan_type in [ + Realm.PLAN_TYPE_PLUS, + Realm.PLAN_TYPE_STANDARD, + Realm.PLAN_TYPE_STANDARD_FREE, + ]: + return Realm.INVITES_STANDARD_REALM_DAILY_MAX + return settings.INVITES_DEFAULT_REALM_DAILY_MAX + + +def check_update_max_invites(realm: Realm, new_max: int, default_max: int) -> bool: + if new_max in [0, default_max]: + return realm.max_invites != default_max + return new_max > default_max + + VALID_MODIFY_PLAN_METHODS = Literal[ "downgrade_at_billing_cycle_end", "downgrade_now_without_additional_licenses", @@ -348,6 +365,7 @@ def support( delete_user_by_id: Json[NonNegativeInt] | None = None, query: Annotated[str | None, ApiParamConfig("q")] = None, org_type: Json[NonNegativeInt] | None = None, + max_invites: Json[NonNegativeInt] | None = None, ) -> HttpResponse: context: dict[str, Any] = {} @@ -419,6 +437,22 @@ def support( do_change_realm_org_type(realm, org_type, acting_user=acting_user) msg = f"Organization type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} " context["success_message"] = msg + elif max_invites is not None: + default_max = get_default_max_invites_for_plan_type(realm) + if check_update_max_invites(realm, max_invites, default_max): + do_change_realm_max_invites(realm, max_invites, acting_user=acting_user) + update_text = str(max_invites) + if max_invites == 0: + update_text = "the default for the current plan type" + msg = f"Maximum number of daily invitations for {realm.string_id} updated to {update_text}." + context["success_message"] = msg + else: + update_text = f"{max_invites} is less than the default for the current plan type" + if max_invites in [0, default_max]: + update_text = "the default for the current plan type is already set" + context["error_message"] = ( + f"Cannot update maximum number of daily invitations for {realm.string_id}, because {update_text}." + ) elif new_subdomain is not None: old_subdomain = realm.string_id try: diff --git a/templates/corporate/support/realm_details.html b/templates/corporate/support/realm_details.html index 0e4c678ba1..5f4a848ab4 100644 --- a/templates/corporate/support/realm_details.html +++ b/templates/corporate/support/realm_details.html @@ -73,7 +73,9 @@
- Plan type:
+ Plan type:
{{ csrf_input }}
+
+ Maximum daily invitations:
+ {{ csrf_input }} + + + +
{% if realm.plan_type != SPONSORED_PLAN_TYPE %}
diff --git a/zerver/actions/realm_settings.py b/zerver/actions/realm_settings.py index aa1b458793..1958ba8c9b 100644 --- a/zerver/actions/realm_settings.py +++ b/zerver/actions/realm_settings.py @@ -697,6 +697,40 @@ def do_change_realm_org_type( send_event_on_commit(realm, event, active_user_ids(realm.id)) +@transaction.atomic(durable=True) +def do_change_realm_max_invites(realm: Realm, max_invites: int, acting_user: UserProfile) -> None: + old_value = realm.max_invites + new_max = max_invites + + # Reset to default maximum for plan type + if new_max == 0: + if realm.plan_type == Realm.PLAN_TYPE_PLUS: + new_max = Realm.INVITES_STANDARD_REALM_DAILY_MAX + elif realm.plan_type == Realm.PLAN_TYPE_STANDARD: + new_max = Realm.INVITES_STANDARD_REALM_DAILY_MAX + elif realm.plan_type == Realm.PLAN_TYPE_SELF_HOSTED: + new_max = None # type: ignore[assignment] # https://github.com/python/mypy/issues/3004 + elif realm.plan_type == Realm.PLAN_TYPE_STANDARD_FREE: + new_max = Realm.INVITES_STANDARD_REALM_DAILY_MAX + elif realm.plan_type == Realm.PLAN_TYPE_LIMITED: + new_max = settings.INVITES_DEFAULT_REALM_DAILY_MAX + + realm.max_invites = new_max + realm.save(update_fields=["_max_invites"]) + + RealmAuditLog.objects.create( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED, + realm=realm, + event_time=timezone_now(), + acting_user=acting_user, + extra_data={ + "old_value": old_value, + "new_value": new_max, + "property": "max_invites", + }, + ) + + @transaction.atomic(savepoint=False) def do_change_realm_plan_type( realm: Realm, plan_type: int, *, acting_user: UserProfile | None diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index fea0eba0b8..028a96ba57 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -24,6 +24,7 @@ from zerver.actions.message_send import ( ) from zerver.actions.realm_settings import ( do_add_deactivated_redirect, + do_change_realm_max_invites, do_change_realm_org_type, do_change_realm_permission_group_setting, do_change_realm_plan_type, @@ -1015,6 +1016,132 @@ class RealmTest(ZulipTestCase): self.assertEqual(realm_audit_log.acting_user, iago) self.assertEqual(realm.org_type, Realm.ORG_TYPES["government"]["id"]) + def test_change_realm_max_invites(self) -> None: + realm = get_realm("zulip") + iago = self.example_user("iago") + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED) + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + + do_change_realm_max_invites(realm, 1, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": settings.INVITES_DEFAULT_REALM_DAILY_MAX, + "new_value": 1, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED) + self.assertEqual(realm.max_invites, 1) + + do_change_realm_max_invites(realm, 0, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = {"old_value": 1, "new_value": None, "property": "max_invites"} + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED) + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + + realm.plan_type = Realm.PLAN_TYPE_PLUS + realm.save() + + do_change_realm_max_invites(realm, 0, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": settings.INVITES_DEFAULT_REALM_DAILY_MAX, + "new_value": Realm.INVITES_STANDARD_REALM_DAILY_MAX, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS) + self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) + + realm.plan_type = Realm.PLAN_TYPE_LIMITED + realm.save() + + do_change_realm_max_invites(realm, 0, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": Realm.INVITES_STANDARD_REALM_DAILY_MAX, + "new_value": settings.INVITES_DEFAULT_REALM_DAILY_MAX, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED) + self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) + + realm.plan_type = Realm.PLAN_TYPE_STANDARD + realm.save() + + do_change_realm_max_invites(realm, 0, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": settings.INVITES_DEFAULT_REALM_DAILY_MAX, + "new_value": Realm.INVITES_STANDARD_REALM_DAILY_MAX, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) + self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) + + realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE + realm.save() + + do_change_realm_max_invites(realm, 50000, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": Realm.INVITES_STANDARD_REALM_DAILY_MAX, + "new_value": 50000, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE) + self.assertEqual(realm.max_invites, 50000) + + do_change_realm_max_invites(realm, 0, acting_user=iago) + realm = get_realm("zulip") + realm_audit_log = RealmAuditLog.objects.filter( + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED + ).last() + assert realm_audit_log is not None + expected_extra_data = { + "old_value": 50000, + "new_value": Realm.INVITES_STANDARD_REALM_DAILY_MAX, + "property": "max_invites", + } + self.assertEqual(realm_audit_log.extra_data, expected_extra_data) + self.assertEqual(realm_audit_log.acting_user, iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE) + self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) + @skipUnless(settings.ZILENCER_ENABLED, "requires zilencer") def test_change_realm_plan_type(self) -> None: realm = get_realm("zulip")