Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add management command to send api move announcement email #4229

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line should be wrapped like the others.


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