From d708c3c039af4d207324d4d02e5d04d21db8538e Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Thu, 22 Aug 2024 18:00:14 +0200 Subject: [PATCH] support: Add ability to update max daily invitations for realm. Adds some validation for changing the realm's max invites via the support view so that it is not set below the default max for the realm's plan type, and so that if it's currently set to the default max it's not reset to that same value. --- corporate/tests/test_support_views.py | 50 +++++++ corporate/views/support.py | 34 +++++ .../corporate/support/realm_details.html | 13 +- zerver/actions/realm_settings.py | 34 +++++ zerver/tests/test_realm.py | 127 ++++++++++++++++++ 5 files changed, 257 insertions(+), 1 deletion(-) 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")