Skip to content

Commit

Permalink
feat(backup): Send relocation started email
Browse files Browse the repository at this point in the history
Once we have decent confidence that a relocation is not obviously
invalid, we send the owner (and creator, if they are different people)
an email indicating that their relocation has started.

Issue: getsentry/team-ospo#203
  • Loading branch information
azaslavsky committed Nov 16, 2023
1 parent 2b2ba06 commit 2145474
Show file tree
Hide file tree
Showing 8 changed files with 112 additions and 10 deletions.
35 changes: 32 additions & 3 deletions src/sentry/tasks/relocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
import yaml
from cryptography.fernet import Fernet
from django.db import router, transaction
from django.utils import timezone as get_timezone
from google.cloud.devtools.cloudbuild_v1 import Build
from google.cloud.devtools.cloudbuild_v1 import CloudBuildClient as CloudBuildClient

from sentry import options
from sentry.api.serializers.rest_framework.base import camel_to_snake_case, convert_dict_key_case
from sentry.backup.dependencies import NormalizedModelName, get_model
from sentry.backup.exports import export_in_config_scope, export_in_user_scope
Expand All @@ -26,6 +28,7 @@
)
from sentry.backup.imports import import_in_organization_scope
from sentry.filestore.gcs import GoogleCloudStorage
from sentry.http import get_server_hostname
from sentry.models.files.file import File
from sentry.models.files.utils import get_storage
from sentry.models.importchunk import RegionImportChunk
Expand All @@ -39,10 +42,12 @@
)
from sentry.models.user import User
from sentry.services.hybrid_cloud.organization import organization_service
from sentry.services.hybrid_cloud.user.service import user_service
from sentry.silo import SiloMode
from sentry.tasks.base import instrumented_task
from sentry.utils import json
from sentry.utils.db import atomic_transaction
from sentry.utils.email.message_builder import MessageBuilder
from sentry.utils.env import gcp_project_id
from sentry.utils.relocation import (
RELOCATION_BLOB_SIZE,
Expand Down Expand Up @@ -307,11 +312,35 @@ def preprocessing_scan(uuid: str) -> None:
relocation.want_usernames = sorted(usernames)
relocation.save()

# TODO(getsentry/team-ospo#203): The user's import data looks basically okay - we should
# use this opportunity to send a "your relocation request has been accepted and is in
# flight, please give it a couple hours" email.
preprocessing_baseline_config.delay(uuid)

# The user's import data looks basically okay - we can use this opportunity to send a
# "your relocation request has been accepted and is in flight, please give it a few
# hours" email.
msg = MessageBuilder(
subject=f"{options.get('mail.subject-prefix')} Your Relocation has Started",
template="sentry/emails/relocation-started.txt",
html_template="sentry/emails/relocation-started.html",
type="relocation.started",
context={
"domain": get_server_hostname(),
"datetime": get_timezone.now(),
"uuid": str(relocation.uuid),
"orgs": relocation.want_org_slugs,
},
)
email_to = []
owner = user_service.get_user(user_id=relocation.owner_id)
if owner is not None:
email_to.append(owner.email)

if relocation.owner_id != relocation.creator_id:
creator = user_service.get_user(user_id=relocation.creator_id)
if creator is not None:
email_to.append(creator.email)

msg.send_async(to=email_to)


@instrumented_task(
name="sentry.relocation.preprocessing_baseline_config",
Expand Down
5 changes: 4 additions & 1 deletion src/sentry/templates/sentry/debug/mail/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
<optgroup label="Account">
<option value="mail/confirm-email/">Confirm Email</option>
<option value="mail/recover-account/">Reset Password</option>
<option value="mail/relocate-account/">Relocate Account</option>
<option value="mail/invalid-identity/">Invalid Identity</option>
<option value="mail/sso-linked/">SSO Linked</option>
<option value="mail/sso-unlinked/">SSO Unlinked</option>
Expand All @@ -53,6 +52,10 @@
<option value="mail/sentry-app-notify-disable/">Sentry App Notify Disable</option>
<option value="mail/join-request/">Join Request</option>
</optgroup>
<optgroup label="Relocation">
<option value="mail/relocate-account/">Relocate Account</option>
<option value="mail/relocation-started/">Relocation Started</option>
</optgroup>
<optgroup label="Reports">
<option value="mail/weekly-reports/">Weekly Report</option>
</optgroup>
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/templates/sentry/emails/relocate_account.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
The following Sentry organizations that you are a member of have been migrated onto sentry.io:
{% for org in orgs %}
{{ org }}
* {% endfor %}
* {{ org }}
{% endfor %}

To continue with using these accounts at their new location, please claim your account with sentry.io.

Expand Down
17 changes: 17 additions & 0 deletions src/sentry/templates/sentry/emails/relocation_started.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "sentry/emails/base.html" %}

{% load i18n %}

{% block main %}
<h3>Relocation Accepted</h3>
<p>Your relocation request has been accepted. You requested that the following organizations be migrating to sentry.io:</p>
<ul>
{% for org in orgs %}
<li>
<a>{{ org }}</a>
</li>
{% endfor %}
</ul>
<p>Relocations usually complete in 24 hours or less. If you do not hear from us in that time frame, please <a href="https://help.sentry.io">contact support</a>.</p>
<p><small><i>ID: {{ uuid }}</i></small></p>
{% endblock %}
9 changes: 9 additions & 0 deletions src/sentry/templates/sentry/emails/relocation_started.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Your relocation request has been accepted. You requested that the following organizations be migrating to sentry.io:

{% for org in orgs %}
* {{ org }}
{% endfor %}

Relocations usually complete in 24 hours or less. If you do not hear from us in that time frame, please contact support at https://help.sentry.io.

ID: {{ uuid }}
1 change: 1 addition & 0 deletions src/sentry/web/debug_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
re_path(r"^debug/mail/confirm-email/$", sentry.web.frontend.debug.mail.confirm_email),
re_path(r"^debug/mail/recover-account/$", sentry.web.frontend.debug.mail.recover_account),
re_path(r"^debug/mail/relocate-account/$", sentry.web.frontend.debug.mail.relocate_account),
re_path(r"^debug/mail/relocation-started/$", sentry.web.frontend.debug.mail.relocation_started),
re_path(r"^debug/mail/unable-to-delete-repo/$", DebugUnableToDeleteRepository.as_view()),
re_path(r"^debug/mail/unable-to-fetch-commits/$", DebugUnableToFetchCommitsEmailView.as_view()),
re_path(r"^debug/mail/unassigned/$", DebugUnassignedEmailView.as_view()),
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/web/frontend/debug/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,20 @@ def relocate_account(request):
).render(request)


@login_required
def relocation_started(request):
return MailPreview(
html_template="sentry/emails/relocation_started.html",
text_template="sentry/emails/relocation_started.txt",
context={
"domain": get_server_hostname(),
"datetime": timezone.now(),
"uuid": str(uuid.uuid4().hex),
"orgs": ["testsentry", "testgetsentry"],
},
).render(request)


@login_required
def org_delete_confirm(request):
from sentry.models.auditlogentry import AuditLogEntry
Expand Down
37 changes: 33 additions & 4 deletions tests/sentry/tasks/test_relocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ class RelocationTaskTestCase(TestCase):
def setUp(self):
super().setUp()
self.owner = self.create_user(
email="owner", is_superuser=False, is_staff=False, is_active=True
email="owner@example.com", is_superuser=False, is_staff=False, is_active=True
)
self.superuser = self.create_user(
"superuser", is_superuser=True, is_staff=True, is_active=True
email="superuser@example.com", is_superuser=True, is_staff=True, is_active=True
)
self.login_as(user=self.superuser, superuser=True)
self.relocation: Relocation = Relocation.objects.create(
Expand Down Expand Up @@ -231,8 +231,10 @@ def setUp(self):
self.relocation.latest_task = "UPLOADING_COMPLETE"
self.relocation.save()

def test_success(
@patch("sentry.utils.email.MessageBuilder.send_async")
def test_success_admin_assisted_relocation(
self,
send_async_mock: Mock,
preprocessing_baseline_config_mock: Mock,
fake_kms_client: FakeKeyManagementServiceClient,
):
Expand All @@ -243,6 +245,26 @@ def test_success(
assert fake_kms_client.asymmetric_decrypt.call_count == 1
assert fake_kms_client.get_public_key.call_count == 0
assert preprocessing_baseline_config_mock.call_count == 1
send_async_mock.assert_called_once_with(to=[self.owner.email, self.superuser.email])
assert Relocation.objects.get(uuid=self.uuid).want_usernames == ["[email protected]"]

@patch("sentry.utils.email.MessageBuilder.send_async")
def test_success_self_service_relocation(
self,
send_async_mock: Mock,
preprocessing_baseline_config_mock: Mock,
fake_kms_client: FakeKeyManagementServiceClient,
):
self.mock_kms_client(fake_kms_client)
self.relocation.creator_id = self.relocation.owner_id
self.relocation.save()

preprocessing_scan(self.uuid)

assert fake_kms_client.asymmetric_decrypt.call_count == 1
assert fake_kms_client.get_public_key.call_count == 0
assert preprocessing_baseline_config_mock.call_count == 1
send_async_mock.assert_called_once_with(to=[self.owner.email])
assert Relocation.objects.get(uuid=self.uuid).want_usernames == ["[email protected]"]

def test_retry_if_attempts_left(
Expand Down Expand Up @@ -462,7 +484,7 @@ def test_success(
# Only user `superuser` is an admin, so only they should be exported.
for json_model in json_models:
if NormalizedModelName(json_model["model"]) == get_model_name(User):
assert json_model["fields"]["username"] in "superuser"
assert json_model["fields"]["username"] in "superuser@example.com"

def test_retry_if_attempts_left(
self,
Expand Down Expand Up @@ -1227,6 +1249,7 @@ def test_fail_if_no_attempts_left(self, completed_mock: Mock):
"sentry.tasks.relocation.CloudBuildClient",
new_callable=lambda: FakeCloudBuildClient,
)
@patch("sentry.utils.email.MessageBuilder.send_async")
@region_silo_test
class EndToEndTest(RelocationTaskTestCase, TransactionTestCase):
def setUp(self):
Expand All @@ -1252,6 +1275,7 @@ def setUp(self):

def test_valid_no_retries(
self,
send_async_mock: Mock,
fake_cloudbuild_client: FakeCloudBuildClient,
fake_kms_client: FakeKeyManagementServiceClient,
):
Expand Down Expand Up @@ -1287,8 +1311,11 @@ def test_valid_no_retries(
"sentry.useremail",
]

assert send_async_mock.call_count == 1

def test_invalid_no_retries(
self,
send_async_mock: Mock,
fake_cloudbuild_client: FakeCloudBuildClient,
fake_kms_client: FakeKeyManagementServiceClient,
):
Expand All @@ -1305,6 +1332,8 @@ def test_invalid_no_retries(
assert relocation.failure_reason
assert Organization.objects.filter(slug__startswith="testing").count() == org_count

assert send_async_mock.call_count == 1

# TODO(getsentry/team-ospo#190): We should add "max retry" tests as well, but these are quite
# hard to mock in celery at the moment. We may need to use the mock sync celery test scheduler,
# rather than the "self.tasks()" approach above, to accomplish this.

0 comments on commit 2145474

Please sign in to comment.