Skip to content

Commit

Permalink
Add management command to send api move announcement email (#4229)
Browse files Browse the repository at this point in the history
* Add management command to send api move announcement email

* Add final text for email, with placeholder for make post URL

* Apply suggestions from code review

Co-authored-by: Dhruv Bhanushali <hi@dhruvkb.dev>

* Add link to make post

* Confirm command does not send duplicates

---------

Co-authored-by: Dhruv Bhanushali <hi@dhruvkb.dev>
  • Loading branch information
sarayourfriend and dhruvkb authored May 6, 2024
1 parent 765fc0e commit 988e227
Show file tree
Hide file tree
Showing 2 changed files with 220 additions and 0 deletions.
118 changes: 118 additions & 0 deletions api/api/management/commands/sendapimoveannouncement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import argparse

from django.conf import settings
from django.core.mail import send_mail

import django_redis
from django_tqdm import BaseCommand

from api.models.oauth import OAuth2Verification


message = """
On 3 June 2024, the Openverse API’s domain will be changed to api.openverse.org and api.openverse.engineering will start
redirecting to api.openverse.org. Existing API credentials and all other aspects of integrations with the
Openverse API will continue to work without changes, provided requests follow redirects.
For the best experience, and to avoid redirects, please update code referencing api.openverse.engineering to use api.openverse.org.
To read more about this change, including opportunities to ask questions or give feedback, please read the Make Openverse blog post announcing it:
https://make.wordpress.org/openverse/2024/05/06/the-openverse-api-is-moving-to-api-openverse-org/
You are receiving this email because you’ve registered and verified with the Openverse API.
Openverse only sends emails for critical operational notifications.
"""


class Command(BaseCommand):
help = "Resends verification emails for unverified Oauth applications."
"""
This command sends a corrected oauth verification email to users who have
not yet verified their Openverse oauth applications. A previous version sent
an email with an incorrectly formatted link.
It stores a cache of successfully sent emails in Redis, so running it multiple
times (in case of failure) should not be an issue.
"""

processed_key = "apimoveannouncement:processed"

def add_arguments(self, parser):
parser.add_argument(
"--dry_run",
help="Count the emails to send, and don't do anything else.",
type=bool,
default=True,
action=argparse.BooleanOptionalAction,
)

def handle(self, *args, **options):
dry = options["dry_run"]

redis = django_redis.get_redis_connection("default")

sent_emails = [
email.decode("utf-8") for email in redis.smembers(self.processed_key)
]

# Join from verification because it has a reference to both the application and the email
unsent_email_addresses = (
OAuth2Verification.objects.filter(
# Only send to verified email addresses to avoid bounce rate increase
associated_application__verified=True
)
.exclude(
email__in=sent_emails,
)
.distinct("email")
.values_list("email", flat=True)
)

count_to_process = unsent_email_addresses.count()
errored_emails = []

if dry:
self.info(
f"{count_to_process} announcement emails to send. This is a dry run, exiting without making changes."
)
return

if not count_to_process:
self.info("No emails to send.")
return

with self.tqdm(total=count_to_process) as progress:
while chunk := unsent_email_addresses.exclude(
email__in=sent_emails + errored_emails
)[:100]:
for email in chunk:
try:
send_mail(
subject="Openverse API is moving to api.openverse.org",
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[email],
fail_silently=False,
)
sent_emails.append(email)
redis.sadd(self.processed_key, email)
except BaseException as err:
errored_emails.append(email)
self.error(f"Unable to process {email}: " f"{err}")

progress.update(1)

if errored_emails:
joined = "\n".join(errored_emails)
self.info(
self.style.WARNING(
f"The following emails were unable to be processed.\n\n"
f"{joined}"
"\n\nPlease check the output above for the error related"
"to each email."
)
)

self.info(self.style.SUCCESS("Sent API move notifications."))
102 changes: 102 additions & 0 deletions api/test/unit/management/commands/test_sendapimoveannouncement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import smtplib
from io import StringIO

from django.core.management import call_command

import pytest

from test.factory.models.oauth2 import (
OAuth2VerificationFactory,
)


command_module_path = "api.management.commands.sendapimoveannouncement"


@pytest.fixture
def captured_emails(monkeypatch) -> list[str]:
captured = []

def send_mail(*args, **kwargs):
captured.extend(kwargs["recipient_list"])

monkeypatch.setattr(f"{command_module_path}.send_mail", send_mail)

yield captured


@pytest.fixture
def failed_emails(monkeypatch) -> list[str]:
failed = []

def send_mail(*args, **kwargs):
failed.extend(
kwargs["recipient_list"],
)
raise smtplib.SMTPAuthenticationError(1, "beep boop bad password")

monkeypatch.setattr(f"{command_module_path}.send_mail", send_mail)

yield failed


def call_cmd(**options):
out = StringIO()
err = StringIO()
options.update(stdout=out, stderr=err)
call_command("sendapimoveannouncement", **options)

res = out.getvalue(), err.getvalue()
print(res)

return res


def make_emails(count: int, *, verified: bool):
verifications = OAuth2VerificationFactory.create_batch(
count, associated_application__verified=verified
)
return [v.email for v in verifications]


@pytest.mark.django_db
def test_should_not_resend_for_already_sent(redis, captured_emails):
emails = make_emails(10, verified=True)
for email in emails:
redis.sadd("apimoveannouncement:processed", email)

call_cmd(dry_run=False)
assert captured_emails == []


@pytest.mark.django_db
def test_should_not_count_email_as_sent_if_failed(failed_emails, redis):
emails = make_emails(1, verified=True)
call_cmd(dry_run=False)
assert failed_emails == emails

for email in emails:
assert not (redis.sismember("apimoveannouncement:processed", email))


@pytest.mark.django_db
def test_should_not_send_to_unverified_emails(captured_emails):
make_emails(10, verified=False)

call_cmd(dry_run=False)
assert len(captured_emails) == 0


@pytest.mark.django_db
def test_should_not_send_to_email_twice(captured_emails):
emails = make_emails(10, verified=True)
OAuth2VerificationFactory.create(
email=emails[0],
)

call_cmd(dry_run=False)
# Not 11
assert len(captured_emails) == 10

# Only appears once
assert len([email for email in emails if email == emails[0]]) == 1

0 comments on commit 988e227

Please sign in to comment.