Repository: zulip/zulip

Issue:
PreregistrationUser should link to the UserProfile it created
When a PreregistrationUser is marked as "used," there is no clear link between that object and the UserProfile it spawned, other than sharing an email address -- which can be later changed.

We should add a `created_user_id` column on PreregistrationUser which links to the UserProfile that it was used to create.  We should also add an assert to ensure that a PreregistrationUser is only used to create a single UserProfile.


Discussion:
Hello @zulip/server-authentication, @zulip/server-onboarding members, this issue was labeled with the "area: authentication", "area: invitations" labels, so you may want to check it out!

<!-- areaLabelAddition -->

Hello @RyanJHamby!

Thanks for your interest in Zulip! You have attempted to claim an issue without the labels "help wanted", "good first issue". Since you're a new contributor, you can only claim and submit pull requests for issues with the [help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+label%3A%22help+wanted%22) or [good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+label%3A%22good+first+issue%22) labels.

If this is your first time here, we recommend reading our [guide for new contributors](https://zulip.readthedocs.io/en/latest/overview/contributing.html) before getting started.

@zulipbot claim

Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -487,6 +487,9 @@ def process_new_human_user(
     if prereg_user is not None:
         streams: List[Stream] = list(prereg_user.streams.all())
         acting_user: Optional[UserProfile] = prereg_user.referred_by
+
+        # A PregistrationUser should not be used for another UserProfile
+        assert prereg_user.created_user is None, "PregistrationUser should not be reused"
     else:
         streams = []
         acting_user = None
@@ -529,9 +532,34 @@ def process_new_human_user(
                 ),
             )
 
-    revoke_preregistration_users(user_profile, prereg_user, realm_creation)
-    if not realm_creation and prereg_user is not None and prereg_user.referred_by is not None:
-        notify_invites_changed(user_profile.realm)
+    # Revoke all preregistration users except prereg_user, and link prereg_user to
+    # the created user
+    if prereg_user is None:
+        assert not realm_creation, "realm_creation should only happen with a PreregistrationUser"
+
+    if prereg_user is not None:
+        prereg_user.status = confirmation_settings.STATUS_ACTIVE
+        prereg_user.created_user = user_profile
+        prereg_user.save(update_fields=["status", "created_user"])
+
+    # In the special case of realm creation, there can be no additional PreregistrationUser
+    # for us to want to modify - because other realm_creation PreregistrationUsers should be
+    # left usable for creating different realms.
+    if not realm_creation:
+        # Mark any other PreregistrationUsers in the realm that are STATUS_ACTIVE as
+        # inactive so we can keep track of the PreregistrationUser we
+        # actually used for analytics.
+        if prereg_user is not None:
+            PreregistrationUser.objects.filter(
+                email__iexact=user_profile.delivery_email, realm=user_profile.realm
+            ).exclude(id=prereg_user.id).update(status=confirmation_settings.STATUS_REVOKED)
+        else:
+            PreregistrationUser.objects.filter(
+                email__iexact=user_profile.delivery_email, realm=user_profile.realm
+            ).update(status=confirmation_settings.STATUS_REVOKED)
+
+        if prereg_user is not None and prereg_user.referred_by is not None:
+            notify_invites_changed(user_profile.realm)
 
     notify_new_user(user_profile)
     # Clear any scheduled invitation emails to prevent them
@@ -547,39 +575,6 @@ def process_new_human_user(
     send_initial_pms(user_profile)
 
 
-def revoke_preregistration_users(
-    created_user_profile: UserProfile,
-    used_preregistration_user: Optional[PreregistrationUser],
-    realm_creation: bool,
-) -> None:
-    if used_preregistration_user is None:
-        assert not realm_creation, "realm_creation should only happen with a PreregistrationUser"
-
-    if used_preregistration_user is not None:
-        used_preregistration_user.status = confirmation_settings.STATUS_ACTIVE
-        used_preregistration_user.save(update_fields=["status"])
-
-    # In the special case of realm creation, there can be no additional PreregistrationUser
-    # for us to want to modify - because other realm_creation PreregistrationUsers should be
-    # left usable for creating different realms.
-    if realm_creation:
-        return
-
-    # Mark any other PreregistrationUsers in the realm that are STATUS_ACTIVE as
-    # inactive so we can keep track of the PreregistrationUser we
-    # actually used for analytics.
-    if used_preregistration_user is not None:
-        PreregistrationUser.objects.filter(
-            email__iexact=created_user_profile.delivery_email, realm=created_user_profile.realm
-        ).exclude(id=used_preregistration_user.id).update(
-            status=confirmation_settings.STATUS_REVOKED
-        )
-    else:
-        PreregistrationUser.objects.filter(
-            email__iexact=created_user_profile.delivery_email, realm=created_user_profile.realm
-        ).update(status=confirmation_settings.STATUS_REVOKED)
-
-
 def notify_created_user(user_profile: UserProfile) -> None:
     user_row = user_profile_to_user_row(user_profile)
     person = format_user_row(
diff --git a/zerver/migrations/0388_preregistrationuser_created_user.py b/zerver/migrations/0388_preregistrationuser_created_user.py
new file mode 100644
--- /dev/null
+++ b/zerver/migrations/0388_preregistrationuser_created_user.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.12 on 2022-04-09 00:38
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("zerver", "0387_reupload_realmemoji_again"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="preregistrationuser",
+            name="created_user",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/zerver/models.py b/zerver/models.py
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -2226,6 +2226,12 @@ class PreregistrationUser(models.Model):
     )
     invited_as: int = models.PositiveSmallIntegerField(default=INVITE_AS["MEMBER"])
 
+    # The UserProfile created upon completion of the registration
+    # for this PregistrationUser
+    created_user: Optional[UserProfile] = models.ForeignKey(
+        UserProfile, null=True, related_name="+", on_delete=models.SET_NULL
+    )
+
     class Meta:
         indexes = [
             models.Index(Upper("email"), name="upper_preregistration_email_idx"),


Test patch:
diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py
--- a/zerver/tests/test_signup.py
+++ b/zerver/tests/test_signup.py
@@ -46,6 +46,7 @@
     do_set_realm_property,
     do_set_realm_user_default_setting,
     get_default_streams_for_realm,
+    process_new_human_user,
 )
 from zerver.lib.email_notifications import enqueue_welcome_emails, followup_day2_email_delay
 from zerver.lib.initial_password import initial_password
@@ -2155,7 +2156,7 @@ def test_send_more_than_one_invite_to_same_user(self) -> None:
         invites = PreregistrationUser.objects.filter(email__iexact="foo@zulip.com")
         self.assert_length(invites, 4)
 
-        do_create_user(
+        created_user = do_create_user(
             "foo@zulip.com",
             "password",
             self.user_profile.realm,
@@ -2174,6 +2175,7 @@ def test_send_more_than_one_invite_to_same_user(self) -> None:
         # the others must be canceled.
         self.assert_length(accepted_invite, 1)
         self.assertEqual(accepted_invite[0].id, prereg_user.id)
+        self.assertEqual(accepted_invite[0].created_user, created_user)
 
         expected_revoked_invites = set(invites.exclude(id=prereg_user.id).exclude(realm=lear))
         self.assertEqual(set(revoked_invites), expected_revoked_invites)
@@ -2182,6 +2184,9 @@ def test_send_more_than_one_invite_to_same_user(self) -> None:
             PreregistrationUser.objects.get(email__iexact="foo@zulip.com", realm=lear).status, 0
         )
 
+        with self.assertRaises(AssertionError):
+            process_new_human_user(created_user, prereg_user)
+
     def test_confirmation_obj_not_exist_error(self) -> None:
         """Since the key is a param input by the user to the registration endpoint,
         if it inserts an invalid value, the confirmation object won't be found. This