diff --git a/analytics/tests/test_views.py b/analytics/tests/test_views.py index 2bafd65f25..5ef05fc834 100644 --- a/analytics/tests/test_views.py +++ b/analytics/tests/test_views.py @@ -10,13 +10,21 @@ from analytics.lib.counts import COUNT_STATS, CountStat from analytics.lib.time_utils import time_range from analytics.models import FillState, RealmCount, UserCount, last_successful_fill from analytics.views import rewrite_client_arrays, sort_by_totals, sort_client_labels -from corporate.lib.stripe import add_months +from corporate.lib.stripe import add_months, update_sponsorship_status from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm from zerver.lib.actions import do_create_multiuse_invite_link, do_send_realm_reactivation_email from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_helpers import reset_emails_in_zulip_realm from zerver.lib.timestamp import ceiling_to_day, ceiling_to_hour, datetime_to_timestamp -from zerver.models import Client, MultiuseInvite, PreregistrationUser, get_realm +from zerver.models import ( + Client, + MultiuseInvite, + PreregistrationUser, + Realm, + UserMessage, + UserProfile, + get_realm, +) class TestStatsEndpoint(ZulipTestCase): @@ -622,6 +630,36 @@ class TestSupportEndpoint(ZulipTestCase): assert(customer is not None) self.assertFalse(customer.sponsorship_pending) + def test_approve_sponsorship(self) -> None: + lear_realm = get_realm("lear") + update_sponsorship_status(lear_realm, True) + king_user = self.lear_user("king") + king_user.role = UserProfile.ROLE_REALM_OWNER + king_user.save() + + cordelia = self.example_user('cordelia') + self.login_user(cordelia) + + result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}", + "approve_sponsorship": "approve_sponsorship"}) + self.assertEqual(result.status_code, 302) + self.assertEqual(result["Location"], "/login/") + + iago = self.example_user("iago") + self.login_user(iago) + + result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}", + "approve_sponsorship": "approve_sponsorship"}) + self.assert_in_success_response(["Sponsorship approved for Lear & Co."], result) + lear_realm.refresh_from_db() + self.assertEqual(lear_realm.plan_type, Realm.STANDARD_FREE) + customer = get_customer_by_realm(lear_realm) + assert(customer is not None) + self.assertFalse(customer.sponsorship_pending) + messages = UserMessage.objects.filter(user_profile=king_user) + self.assertIn("request for sponsored hosting has been approved", messages[0].message.content) + self.assertEqual(len(messages), 1) + def test_activate_or_deactivate_realm(self) -> None: cordelia = self.example_user('cordelia') lear_realm = get_realm('lear') diff --git a/analytics/views.py b/analytics/views.py index c2536dc9cc..93aefd2acb 100644 --- a/analytics/views.py +++ b/analytics/views.py @@ -71,6 +71,7 @@ from zerver.views.invite import get_invitee_emails_set if settings.BILLING_ENABLED: from corporate.lib.stripe import ( + approve_sponsorship, attach_discount_to_realm, get_current_plan_by_realm, get_customer_by_realm, @@ -1157,6 +1158,10 @@ def support(request: HttpRequest) -> HttpResponse: elif sponsorship_pending == "false": update_sponsorship_status(realm, False) context["message"] = f"{realm.name} is no longer pending sponsorship." + elif request.POST.get('approve_sponsorship') is not None: + if request.POST.get('approve_sponsorship') == "approve_sponsorship": + approve_sponsorship(realm) + context["message"] = f"Sponsorship approved for {realm.name}" elif request.POST.get("scrub_realm", None) is not None: if request.POST.get("scrub_realm") == "scrub_realm": do_scrub_realm(realm, acting_user=request.user) diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 21d7dca314..19e2ccac25 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -12,6 +12,7 @@ from django.conf import settings from django.core.signing import Signer from django.db import transaction from django.utils.timezone import now as timezone_now +from django.utils.translation import override as override_language from django.utils.translation import ugettext as _ from corporate.models import ( @@ -25,7 +26,7 @@ from corporate.models import ( from zerver.lib.logging_util import log_to_file from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.utils import generate_random_token -from zerver.models import Realm, RealmAuditLog, UserProfile +from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot from zproject.config import get_secret STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key') @@ -563,6 +564,24 @@ def update_sponsorship_status(realm: Realm, sponsorship_pending: bool) -> None: customer.sponsorship_pending = sponsorship_pending customer.save(update_fields=["sponsorship_pending"]) +def approve_sponsorship(realm: Realm) -> None: + from zerver.lib.actions import do_change_plan_type, internal_send_private_message + do_change_plan_type(realm, Realm.STANDARD_FREE) + customer = get_customer_by_realm(realm) + if customer is not None and customer.sponsorship_pending: + customer.sponsorship_pending = False + customer.save(update_fields=["sponsorship_pending"]) + notification_bot = get_system_bot(settings.NOTIFICATION_BOT) + for billing_admin in realm.get_human_billing_admin_users(): + with override_language(billing_admin.default_language): + # Using variable to make life easier for translators if these details change. + plan_name = "Zulip Cloud Standard" + emoji = ":tada:" + message = _( + f"Your organization's request for sponsored hosting has been approved! {emoji}.\n" + f"You have been upgraded to {plan_name}, free of charge.") + internal_send_private_message(billing_admin.realm, notification_bot, billing_admin, message) + def get_discount_for_realm(realm: Realm) -> Optional[Decimal]: customer = get_customer_by_realm(realm) if customer is not None: diff --git a/static/styles/portico/activity.scss b/static/styles/portico/activity.scss index db7c0e338e..a79c9219d2 100644 --- a/static/styles/portico/activity.scss +++ b/static/styles/portico/activity.scss @@ -89,6 +89,11 @@ tr.admin td:first-child { top: -50px; } +.approve-sponsorship-form { + position: relative; + top: -40px; +} + .scrub-realm-form { position: relative; top: -40px; diff --git a/templates/analytics/realm_details.html b/templates/analytics/realm_details.html index 9672c13589..de40620b7b 100644 --- a/templates/analytics/realm_details.html +++ b/templates/analytics/realm_details.html @@ -40,6 +40,19 @@ + +{% if realm.customer and realm.customer.sponsorship_pending %} +
+ {{ csrf_input }} + + + + (will email organization owners). +
+{% endif %} +
Discount (use 85 for nonprofits):
{{ csrf_input }} diff --git a/zerver/models.py b/zerver/models.py index dce3d5cd6e..08f55c0556 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -501,6 +501,10 @@ class Realm(models.Model): role__in=[UserProfile.ROLE_REALM_ADMINISTRATOR, UserProfile.ROLE_REALM_OWNER]) + def get_human_billing_admin_users(self) -> Sequence['UserProfile']: + return UserProfile.objects.filter(Q(role=UserProfile.ROLE_REALM_OWNER) | Q(is_billing_admin=True), + realm=self, is_bot=False, is_active=True) + def get_active_users(self) -> Sequence['UserProfile']: # TODO: Change return type to QuerySet[UserProfile] return UserProfile.objects.filter(realm=self, is_active=True).select_related()