diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 73c2f3325101e0..a29668223d9ddd 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0001_release_models replays: 0006_add_bulk_delete_job -sentry: 0997_backfill_self_hosted_sentry_app_emails +sentry: 0998_delete_never_active_users_without_emails_self_hosted social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/0998_delete_never_active_users_without_emails_self_hosted.py b/src/sentry/migrations/0998_delete_never_active_users_without_emails_self_hosted.py new file mode 100644 index 00000000000000..765c22724ff4da --- /dev/null +++ b/src/sentry/migrations/0998_delete_never_active_users_without_emails_self_hosted.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.1 on 2025-10-21 23:09 + +import logging + +from django.conf import settings +from django.db import migrations, router +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.silo.safety import unguarded_write + +logger = logging.getLogger(__name__) + + +def delete_never_active_users_without_emails( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + if not settings.SENTRY_SELF_HOSTED: + # This operation for SaaS Sentry is handled using a script. + return + + User = apps.get_model("sentry", "User") + + target_users = User.objects.filter(last_active=None, email="") + + for user in target_users: + logger.info("Deleting user %s", user.id) + with unguarded_write(using=router.db_for_write(User)): + user.delete() + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = True + + dependencies = [ + ("sentry", "0997_backfill_self_hosted_sentry_app_emails"), + ] + + operations = [ + migrations.RunPython( + delete_never_active_users_without_emails, + migrations.RunPython.noop, + hints={"tables": ["auth_user"]}, + ) + ] diff --git a/tests/sentry/migrations/test_0998_delete_never_active_users_without_emails_self_hosted.py b/tests/sentry/migrations/test_0998_delete_never_active_users_without_emails_self_hosted.py new file mode 100644 index 00000000000000..b72fa093ad61fd --- /dev/null +++ b/tests/sentry/migrations/test_0998_delete_never_active_users_without_emails_self_hosted.py @@ -0,0 +1,23 @@ +from django.test import override_settings + +from sentry.testutils.cases import TestMigrations +from sentry.testutils.silo import control_silo_test +from sentry.users.models import User + + +@control_silo_test +@override_settings(SENTRY_SELF_HOSTED=True) +class BackfillSelfHostedSentryAppEmailsTest(TestMigrations): + migrate_from = "0997_backfill_self_hosted_sentry_app_emails" + migrate_to = "0998_delete_never_active_users_without_emails_self_hosted" + connection = "control" + + def setup_before_migration(self, apps): + self.target_user = self.create_user(username="froggy-chair", email="", last_active=None) + self.regular_user = self.create_user(username="regular@user", email="regular@user") + + return super().setup_before_migration(apps) + + def test(self) -> None: + assert User.objects.filter(id=self.target_user.id).first() is None + assert User.objects.filter(id=self.regular_user.id).first() == self.regular_user