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.
This commit is contained in:
Lauryn Menard 2024-08-22 18:00:14 +02:00 committed by Tim Abbott
parent 8911347cdb
commit d708c3c039
5 changed files with 257 additions and 1 deletions

View File

@ -4,6 +4,7 @@ from unittest import mock
import orjson import orjson
import time_machine import time_machine
from django.conf import settings
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import override from typing_extensions import override
@ -1158,6 +1159,55 @@ class TestSupportEndpoint(ZulipTestCase):
["Organization type of zulip changed from Business to Government"], result ["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: def test_attach_discount(self) -> None:
lear_realm = get_realm("lear") lear_realm = get_realm("lear")
customer = self.create_customer_and_plan(lear_realm, True) customer = self.create_customer_and_plan(lear_realm, True)

View File

@ -46,6 +46,7 @@ from corporate.lib.support import (
from corporate.models import CustomerPlan from corporate.models import CustomerPlan
from zerver.actions.create_realm import do_change_realm_subdomain from zerver.actions.create_realm import do_change_realm_subdomain
from zerver.actions.realm_settings import ( from zerver.actions.realm_settings import (
do_change_realm_max_invites,
do_change_realm_org_type, do_change_realm_org_type,
do_change_realm_plan_type, do_change_realm_plan_type,
do_deactivate_realm, do_deactivate_realm,
@ -312,6 +313,22 @@ def get_realm_plan_type_options_for_discount() -> list[SupportSelectOption]:
return plan_types 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[ VALID_MODIFY_PLAN_METHODS = Literal[
"downgrade_at_billing_cycle_end", "downgrade_at_billing_cycle_end",
"downgrade_now_without_additional_licenses", "downgrade_now_without_additional_licenses",
@ -348,6 +365,7 @@ def support(
delete_user_by_id: Json[NonNegativeInt] | None = None, delete_user_by_id: Json[NonNegativeInt] | None = None,
query: Annotated[str | None, ApiParamConfig("q")] = None, query: Annotated[str | None, ApiParamConfig("q")] = None,
org_type: Json[NonNegativeInt] | None = None, org_type: Json[NonNegativeInt] | None = None,
max_invites: Json[NonNegativeInt] | None = None,
) -> HttpResponse: ) -> HttpResponse:
context: dict[str, Any] = {} context: dict[str, Any] = {}
@ -419,6 +437,22 @@ def support(
do_change_realm_org_type(realm, org_type, acting_user=acting_user) 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)} " 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 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: elif new_subdomain is not None:
old_subdomain = realm.string_id old_subdomain = realm.string_id
try: try:

View File

@ -73,7 +73,9 @@
<button type="submit" class="support-submit-button">Update</button> <button type="submit" class="support-submit-button">Update</button>
</form> </form>
<form method="POST" class="support-form"> <form method="POST" class="support-form">
<b>Plan type</b>:<br /> <b>Plan type</b>: <i class="fa fa-question-circle-o" data-tippy-content="
Will also update maximum daily invitations to be the default value for the new plan type.
" data-tippy-maxWidth="auto"></i><br />
{{ csrf_input }} {{ csrf_input }}
<input type="hidden" name="realm_id" value="{{ realm.id }}" /> <input type="hidden" name="realm_id" value="{{ realm.id }}" />
<select name="plan_type"> <select name="plan_type">
@ -87,6 +89,15 @@
</select> </select>
<button type="submit" class="support-submit-button">Update</button> <button type="submit" class="support-submit-button">Update</button>
</form> </form>
<form method="POST" class="support-form">
<b>Maximum daily invitations</b>: <i class="fa fa-question-circle-o" data-tippy-content="
Setting to zero will attempt to reset this to the default maximum for the current plan type.
" data-tippy-maxWidth="auto"></i><br />
{{ csrf_input }}
<input type="hidden" name="realm_id" value="{{ realm.id }}" />
<input type="number" name="max_invites" value="{{ realm.max_invites }}" min="0" required />
<button type="submit" class="support-submit-button">Update</button>
</form>
</div> </div>
{% if realm.plan_type != SPONSORED_PLAN_TYPE %} {% if realm.plan_type != SPONSORED_PLAN_TYPE %}
<div class="support-sponsorship-container"> <div class="support-sponsorship-container">

View File

@ -697,6 +697,40 @@ def do_change_realm_org_type(
send_event_on_commit(realm, event, active_user_ids(realm.id)) 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) @transaction.atomic(savepoint=False)
def do_change_realm_plan_type( def do_change_realm_plan_type(
realm: Realm, plan_type: int, *, acting_user: UserProfile | None realm: Realm, plan_type: int, *, acting_user: UserProfile | None

View File

@ -24,6 +24,7 @@ from zerver.actions.message_send import (
) )
from zerver.actions.realm_settings import ( from zerver.actions.realm_settings import (
do_add_deactivated_redirect, do_add_deactivated_redirect,
do_change_realm_max_invites,
do_change_realm_org_type, do_change_realm_org_type,
do_change_realm_permission_group_setting, do_change_realm_permission_group_setting,
do_change_realm_plan_type, do_change_realm_plan_type,
@ -1015,6 +1016,132 @@ class RealmTest(ZulipTestCase):
self.assertEqual(realm_audit_log.acting_user, iago) self.assertEqual(realm_audit_log.acting_user, iago)
self.assertEqual(realm.org_type, Realm.ORG_TYPES["government"]["id"]) 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") @skipUnless(settings.ZILENCER_ENABLED, "requires zilencer")
def test_change_realm_plan_type(self) -> None: def test_change_realm_plan_type(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")