diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 6e56a18808..d3680ec616 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -32,6 +32,7 @@ from zerver.lib.send_email import FromAddress, send_email_to_billing_admins_and_ from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.utils import assert_is_not_none from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot +from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog from zproject.config import get_secret stripe.api_key = get_secret("stripe_secret_key") @@ -619,6 +620,19 @@ def ensure_realm_does_not_have_active_plan(realm: Customer) -> None: raise UpgradeWithExistingPlanError() +@transaction.atomic +def do_change_remote_server_plan_type(remote_server: RemoteZulipServer, plan_type: int) -> None: + old_value = remote_server.plan_type + remote_server.plan_type = plan_type + remote_server.save(update_fields=["plan_type"]) + RemoteZulipServerAuditLog.objects.create( + event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED, + server=remote_server, + event_time=timezone_now(), + extra_data={"old_value": old_value, "new_value": plan_type}, + ) + + # Only used for cloud signups @catch_stripe_errors def process_initial_upgrade( diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 4096363be3..82082d4fc2 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -36,6 +36,7 @@ from corporate.lib.stripe import ( catch_stripe_errors, compute_plan_parameters, customer_has_credit_card_as_default_payment_method, + do_change_remote_server_plan_type, do_create_stripe_customer, downgrade_small_realms_behind_on_payments_as_needed, get_discount_for_realm, @@ -97,6 +98,7 @@ from zerver.models import ( get_realm, get_system_bot, ) +from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog CallableT = TypeVar("CallableT", bound=Callable[..., Any]) @@ -4418,6 +4420,30 @@ class BillingHelpersTest(ZulipTestCase): realm.save() self.assertTrue(is_sponsored_realm(realm)) + def test_change_remote_server_plan_type(self) -> None: + server_uuid = "demo-1234" + remote_server = RemoteZulipServer.objects.create( + uuid=server_uuid, + api_key="magic_secret_api_key", + hostname="demo.example.com", + contact_email="email@example.com", + ) + self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_HOSTED) + + do_change_remote_server_plan_type(remote_server, RemoteZulipServer.PLAN_TYPE_STANDARD) + + remote_server = RemoteZulipServer.objects.get(uuid=server_uuid) + remote_realm_audit_log = RemoteZulipServerAuditLog.objects.filter( + event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED + ).last() + assert remote_realm_audit_log is not None + expected_extra_data = { + "old_value": RemoteZulipServer.PLAN_TYPE_SELF_HOSTED, + "new_value": RemoteZulipServer.PLAN_TYPE_STANDARD, + } + self.assertEqual(remote_realm_audit_log.extra_data, str(expected_extra_data)) + self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_STANDARD) + class LicenseLedgerTest(StripeTestCase): def test_add_plan_renewal_if_needed(self) -> None: diff --git a/zerver/models.py b/zerver/models.py index 31d8fb7537..0f615bca3b 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -3883,6 +3883,11 @@ class AbstractRealmAuditLog(models.Model): STREAM_NAME_CHANGED = 603 STREAM_REACTIVATED = 604 + # The following values are only for RemoteZulipServerAuditLog + # Values are chosen to be 10000 greater than the value in RealmAuditLog. + REMOTE_SERVER_CREATED = 10215 + REMOTE_SERVER_PLAN_TYPE_CHANGED = 10204 + event_type: int = models.PositiveSmallIntegerField() # event_types synced from on-prem installations to Zulip Cloud when diff --git a/zilencer/migrations/0019_remotezulipserver_plan_type.py b/zilencer/migrations/0019_remotezulipserver_plan_type.py new file mode 100644 index 0000000000..c844e9227c --- /dev/null +++ b/zilencer/migrations/0019_remotezulipserver_plan_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2021-12-01 16:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("zilencer", "0018_remoterealmauditlog"), + ] + + operations = [ + migrations.AddField( + model_name="remotezulipserver", + name="plan_type", + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/zilencer/migrations/0020_remotezulipserverauditlog.py b/zilencer/migrations/0020_remotezulipserverauditlog.py new file mode 100644 index 0000000000..22358dd96a --- /dev/null +++ b/zilencer/migrations/0020_remotezulipserverauditlog.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.9 on 2021-12-06 18:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("zilencer", "0019_remotezulipserver_plan_type"), + ] + + operations = [ + migrations.CreateModel( + name="RemoteZulipServerAuditLog", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("event_time", models.DateTimeField(db_index=True)), + ("backfilled", models.BooleanField(default=False)), + ("extra_data", models.TextField(null=True)), + ("event_type", models.PositiveSmallIntegerField()), + ( + "server", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver" + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/zilencer/models.py b/zilencer/models.py index 615938312d..0aec8b11e0 100644 --- a/zilencer/models.py +++ b/zilencer/models.py @@ -15,17 +15,34 @@ def get_remote_server_by_uuid(uuid: str) -> "RemoteZulipServer": class RemoteZulipServer(models.Model): + """Each object corresponds to a single remote Zulip server that is + registered for the Mobile Push Notifications Service via + `manage.py register_server`. + """ + UUID_LENGTH = 36 API_KEY_LENGTH = 64 HOSTNAME_MAX_LENGTH = 128 + # The unique UUID (`zulip_org_id`) and API key (`zulip_org_key`) + # for this remote server registration. uuid: str = models.CharField(max_length=UUID_LENGTH, unique=True) api_key: str = models.CharField(max_length=API_KEY_LENGTH) + # The hostname and contact details are not verified/trusted. Thus, + # they primarily exist so that we can communicate with the + # maintainer of a server about abuse problems. hostname: str = models.CharField(max_length=HOSTNAME_MAX_LENGTH) contact_email: str = models.EmailField(blank=True, null=False) last_updated: datetime.datetime = models.DateTimeField("last updated", auto_now=True) + # Plan types for self-hosted customers + PLAN_TYPE_SELF_HOSTED = 1 + PLAN_TYPE_STANDARD = 102 + + # The current billing plan for the remote server, similar to Realm.plan_type. + plan_type: int = models.PositiveSmallIntegerField(default=PLAN_TYPE_SELF_HOSTED) + def __str__(self) -> str: return f"" @@ -33,8 +50,9 @@ class RemoteZulipServer(models.Model): return "zulip-server:" + self.uuid -# Variant of PushDeviceToken for a remote server. class RemotePushDeviceToken(AbstractPushDeviceToken): + """Like PushDeviceToken, but for a device connected to a remote server.""" + server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) # The user id on the remote server for this device device this is user_id: int = models.BigIntegerField(db_index=True) @@ -46,6 +64,22 @@ class RemotePushDeviceToken(AbstractPushDeviceToken): return f"" +class RemoteZulipServerAuditLog(AbstractRealmAuditLog): + """Audit data associated with a remote Zulip server (not specific to a + realm). Used primarily for tracking registration and billing + changes for self-hosted customers. + + In contrast with RemoteRealmAuditLog, which has a copy of data + that is generated on the client Zulip server, this table is the + authoritative storage location for the server's history. + """ + + server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"" + + class RemoteRealmAuditLog(AbstractRealmAuditLog): """Synced audit data from a remote Zulip server, used primarily for billing. See RealmAuditLog and AbstractRealmAuditLog for details.