Skip to content

Commit c1471f1

Browse files
authored
chore(member merge): create temporary verification code model (#94382)
Create a model for storing the verification codes used during the manual merge process (for users who have multiple accounts with the same email address). We will delete this model after the work to enforce unique primary email addresses is completed.
1 parent 81bb8a6 commit c1471f1

File tree

5 files changed

+163
-1
lines changed

5 files changed

+163
-1
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ preprod: 0010_actual_drop_preprod_artifact_analysis_file_id_col
2525

2626
replays: 0006_add_bulk_delete_job
2727

28-
sentry: 0940_auditlog_json_field
28+
sentry: 0941_create_temporary_verification_code_table
2929

3030
social_auth: 0003_social_auth_json_field
3131

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Generated by Django 5.2.1 on 2025-07-01 20:14
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
import sentry.db.models.fields.bounded
8+
import sentry.db.models.fields.foreignkey
9+
import sentry.users.models.user_merge_verification_code
10+
from sentry.new_migrations.migrations import CheckedMigration
11+
12+
13+
class Migration(CheckedMigration):
14+
# This flag is used to mark that a migration shouldn't be automatically run in production.
15+
# This should only be used for operations where it's safe to run the migration after your
16+
# code has deployed. So this should not be used for most operations that alter the schema
17+
# of a table.
18+
# Here are some things that make sense to mark as post deployment:
19+
# - Large data migrations. Typically we want these to be run manually so that they can be
20+
# monitored and not block the deploy for a long period of time while they run.
21+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
22+
# run this outside deployments so that we don't block them. Note that while adding an index
23+
# is a schema change, it's completely safe to run the operation after the code has deployed.
24+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
25+
26+
is_post_deployment = False
27+
28+
dependencies = [
29+
("sentry", "0940_auditlog_json_field"),
30+
]
31+
32+
operations = [
33+
migrations.CreateModel(
34+
name="UserMergeVerificationCode",
35+
fields=[
36+
(
37+
"id",
38+
sentry.db.models.fields.bounded.BoundedBigAutoField(
39+
primary_key=True, serialize=False
40+
),
41+
),
42+
("date_updated", models.DateTimeField(auto_now=True)),
43+
("date_added", models.DateTimeField(auto_now_add=True)),
44+
(
45+
"token",
46+
models.CharField(
47+
default=sentry.users.models.user_merge_verification_code.generate_token,
48+
max_length=64,
49+
),
50+
),
51+
(
52+
"expires_at",
53+
models.DateTimeField(
54+
default=sentry.users.models.user_merge_verification_code.generate_expires_at
55+
),
56+
),
57+
(
58+
"user",
59+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
60+
on_delete=django.db.models.deletion.CASCADE,
61+
to=settings.AUTH_USER_MODEL,
62+
unique=True,
63+
),
64+
),
65+
],
66+
options={
67+
"db_table": "sentry_user_verification_codes_temp",
68+
},
69+
),
70+
]

src/sentry/users/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from sentry.users.models.identity import Identity
44
from sentry.users.models.lostpasswordhash import LostPasswordHash
55
from sentry.users.models.user import User
6+
from sentry.users.models.user_merge_verification_code import UserMergeVerificationCode
67
from sentry.users.models.useremail import UserEmail
78
from sentry.users.models.userip import UserIP
89
from sentry.users.models.userpermission import UserPermission
@@ -16,6 +17,7 @@
1617
"User",
1718
"UserEmail",
1819
"UserIP",
20+
"UserMergeVerificationCode",
1921
"UserPermission",
2022
"UserRole",
2123
)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import secrets
2+
from datetime import datetime, timedelta
3+
4+
from django.conf import settings
5+
from django.db import models
6+
from django.utils import timezone
7+
8+
from sentry.backup.scopes import RelocationScope
9+
from sentry.db.models import FlexibleForeignKey, control_silo_model, sane_repr
10+
from sentry.db.models.base import DefaultFieldsModel
11+
from sentry.users.models.user import User
12+
from sentry.users.services.user.model import RpcUser
13+
14+
TOKEN_MINUTES_VALID = 30
15+
16+
17+
def generate_token() -> str:
18+
return secrets.token_hex(nbytes=32)
19+
20+
21+
def generate_expires_at() -> datetime:
22+
return timezone.now() + timedelta(minutes=TOKEN_MINUTES_VALID)
23+
24+
25+
@control_silo_model
26+
class UserMergeVerificationCode(DefaultFieldsModel):
27+
"""
28+
A temporary model used to store verification codes for users who are manually
29+
merging their accounts with the same primary email address. We will remove this
30+
table after the work around merging users with the same primary email is complete.
31+
"""
32+
33+
__relocation_scope__ = RelocationScope.Excluded
34+
35+
user = FlexibleForeignKey(settings.AUTH_USER_MODEL, unique=True)
36+
token = models.CharField(max_length=64, default=generate_token)
37+
expires_at = models.DateTimeField(default=generate_expires_at)
38+
39+
class Meta:
40+
app_label = "sentry"
41+
db_table = "sentry_user_verification_codes_temp"
42+
43+
__repr__ = sane_repr("user_id", "token")
44+
45+
def regenerate_token(self) -> None:
46+
self.token = generate_token()
47+
self.refresh_expires_at()
48+
49+
def refresh_expires_at(self) -> None:
50+
now = timezone.now()
51+
self.expires_at = now + timedelta(minutes=TOKEN_MINUTES_VALID)
52+
53+
def is_valid(self) -> bool:
54+
return timezone.now() < self.expires_at
55+
56+
@classmethod
57+
def send_email(cls, user: User | RpcUser, token: str) -> None:
58+
pass
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from datetime import UTC, datetime, timedelta
2+
3+
from sentry.testutils.cases import TestCase
4+
from sentry.testutils.helpers.datetime import freeze_time
5+
from sentry.testutils.silo import control_silo_test
6+
from sentry.users.models import UserMergeVerificationCode
7+
from sentry.users.models.user_merge_verification_code import TOKEN_MINUTES_VALID
8+
9+
10+
@control_silo_test
11+
class TestUserMergeVerificationCode(TestCase):
12+
@freeze_time()
13+
def test_regenerate_token(self):
14+
code = UserMergeVerificationCode(user=self.user)
15+
token = code.token
16+
code.expires_at = datetime(2025, 3, 14, 5, 32, 21, tzinfo=UTC)
17+
code.save()
18+
19+
code.regenerate_token()
20+
assert code.token != token
21+
assert code.expires_at == datetime.now(UTC) + timedelta(minutes=TOKEN_MINUTES_VALID)
22+
23+
@freeze_time()
24+
def test_expires_at(self):
25+
code = UserMergeVerificationCode(user=self.user)
26+
code.expires_at = datetime(2025, 3, 14, 5, 32, 21, tzinfo=UTC)
27+
code.save()
28+
29+
assert not code.is_valid()
30+
31+
code.regenerate_token()
32+
assert code.is_valid()

0 commit comments

Comments
 (0)