diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py index 4e53a659..0c47cb24 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -1,16 +1,19 @@ +import asyncio from datetime import datetime from logging import getLogger from typing import Any, Optional from fastapi import APIRouter, Body, Depends, HTTPException, status -from pydantic import BaseModel, Field, TypeAdapter, ValidationError +from pydantic import BaseModel, EmailStr, Field, TypeAdapter, ValidationError from auth.authorization import require_role from auth.user_identity import User, utc_now from models.ApplicationData import Decision, Review from services import mongodb_handler from services.mongodb_handler import BaseRecord, Collection -from utils.user_record import Applicant, Role +from utils import email_handler +from utils.batched import batched +from utils.user_record import Applicant, Role, Status log = getLogger(__name__) @@ -106,6 +109,58 @@ async def submit_review( raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) +@router.post("/release", dependencies=[Depends(require_role([Role.DIRECTOR]))]) +async def release_decisions() -> None: + """Update applicant status based on decision and send decision emails.""" + records = await mongodb_handler.retrieve( + Collection.USERS, + {"status": Status.REVIEWED}, + ["_id", "application_data.reviews", "application_data.first_name"], + ) + + for record in records: + _include_review_decision(record) + + for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED): + group = [record for record in records if record["decision"] == decision] + if not group: + continue + + await asyncio.gather( + *(_process_batch(batch, decision) for batch in batched(group, 100)) + ) + + +async def _process_batch(batch: tuple[dict[str, Any], ...], decision: Decision) -> None: + uids: list[str] = [record["_id"] for record in batch] + log.info(f"Setting {','.join(uids)} as {decision}") + ok = await mongodb_handler.update( + Collection.USERS, {"_id": {"$in": uids}}, {"status": decision} + ) + if not ok: + raise RuntimeError("gg wp") + + # Send emails + log.info(f"Sending {decision} emails for {len(batch)} applicants") + await email_handler.send_decision_email( + map(_extract_personalizations, batch), decision + ) + + +def _extract_personalizations(decision_data: dict[str, Any]) -> tuple[str, EmailStr]: + name = decision_data["application_data"]["first_name"] + email = _recover_email_from_uid(decision_data["_id"]) + return name, email + + +def _recover_email_from_uid(uid: str) -> str: + """For NativeUsers, the email should still delivery properly.""" + *reversed_domain, local = uid.split(".") + local = local.replace("..", ".") + domain = ".".join(reversed(reversed_domain)) + return f"{local}@{domain}" + + def _include_review_decision(applicant_record: dict[str, Any]) -> None: """Sets the applicant's decision as the last submitted review decision or None.""" reviews = applicant_record["application_data"]["reviews"] diff --git a/apps/api/src/utils/batched.py b/apps/api/src/utils/batched.py new file mode 100644 index 00000000..45c28c31 --- /dev/null +++ b/apps/api/src/utils/batched.py @@ -0,0 +1,14 @@ +import itertools +from typing import Iterable, Iterator, TypeVar + +T = TypeVar("T") + + +def batched(iterable: Iterable[T], n: int) -> Iterator[tuple[T, ...]]: + """batched('ABCDEFG', 3) --> ABC DEF G""" + # from https://docs.python.org/3/library/itertools.html#itertools.batched + if n < 1: + raise ValueError("n must be at least one") + it = iter(iterable) + while batch := tuple(itertools.islice(it, n)): + yield batch diff --git a/apps/api/src/utils/email_handler.py b/apps/api/src/utils/email_handler.py index d30b7245..df0003a6 100644 --- a/apps/api/src/utils/email_handler.py +++ b/apps/api/src/utils/email_handler.py @@ -1,4 +1,4 @@ -from typing import Protocol +from typing import Iterable, Protocol from pydantic import EmailStr @@ -9,6 +9,12 @@ IH_SENDER = ("apply@irvinehacks.com", "IrvineHacks 2024 Applications") REPLY_TO_HACK_AT_UCI = ("hack@uci.edu", "Hack at UCI") +DECISION_TEMPLATES = { + Decision.ACCEPTED: Template.ACCEPTED_EMAIL, + Decision.REJECTED: Template.REJECTED_EMAIL, + Decision.WAITLISTED: Template.WAITLISTED_EMAIL, +} + class ContactInfo(Protocol): first_name: str @@ -46,27 +52,13 @@ async def send_guest_login_email(email: EmailStr, passphrase: str) -> None: async def send_decision_email( - sender_email: tuple[str, str], applicant_batch: dict[tuple[str, EmailStr], Decision] + applicant_batch: Iterable[tuple[str, EmailStr]], decision: Decision ) -> None: - personalization_dict: dict[Decision, list[ApplicationUpdatePersonalization]] = { - Decision.ACCEPTED: [], - Decision.REJECTED: [], - Decision.WAITLISTED: [], - } - - for (first_name, email), status in applicant_batch.items(): - personalization = ApplicationUpdatePersonalization( - email=email, first_name=first_name - ) - if status in (Decision.ACCEPTED, Decision.REJECTED, Decision.WAITLISTED): - personalization_dict[status].append(personalization) - - template_data = { - Template.ACCEPTED_EMAIL: personalization_dict[Decision.ACCEPTED], - Template.REJECTED_EMAIL: personalization_dict[Decision.REJECTED], - Template.WAITLISTED_EMAIL: personalization_dict[Decision.WAITLISTED], - } + """Send a specific decision email to a group of applicants.""" + personalizations = [ + ApplicationUpdatePersonalization(email=email, first_name=first_name) + for first_name, email in applicant_batch + ] - for template, data in template_data.items(): - if data: - await sendgrid_handler.send_email(template, sender_email, data, True) + template = DECISION_TEMPLATES[decision] + await sendgrid_handler.send_email(template, IH_SENDER, personalizations, True) diff --git a/apps/api/tests/test_email_handler.py b/apps/api/tests/test_email_handler.py index 0440848f..64868e76 100644 --- a/apps/api/tests/test_email_handler.py +++ b/apps/api/tests/test_email_handler.py @@ -1,36 +1,26 @@ -from unittest.mock import AsyncMock, call, patch - -from test_sendgrid_handler import SAMPLE_SENDER +from unittest.mock import AsyncMock, patch from models.ApplicationData import Decision from services.sendgrid_handler import ApplicationUpdatePersonalization, Template from utils import email_handler +from utils.email_handler import IH_SENDER @patch("services.sendgrid_handler.send_email") async def test_send_decision_email(mock_sendgrid_handler_send_email: AsyncMock) -> None: - applicants = { - ("test1", "test1@uci.edu"): Decision.ACCEPTED, - ("test2", "test2@uci.edu"): Decision.REJECTED, - ("test3", "test3@uci.edu"): Decision.WAITLISTED, - } - - accepted = [ - ApplicationUpdatePersonalization(first_name="test1", email="test1@uci.edu") + users = [ + ("test1", "test1@uci.edu"), + ("test2", "test2@uci.edu"), + ("test3", "test3@uci.edu"), ] - rejected = [ - ApplicationUpdatePersonalization(first_name="test2", email="test2@uci.edu") - ] - waitlisted = [ - ApplicationUpdatePersonalization(first_name="test3", email="test3@uci.edu") + + expected_personalizations = [ + ApplicationUpdatePersonalization(first_name=name, email=email) + for name, email in users ] - await email_handler.send_decision_email(SAMPLE_SENDER, applicants) + await email_handler.send_decision_email(users, Decision.ACCEPTED) - mock_sendgrid_handler_send_email.assert_has_calls( - [ - call(Template.ACCEPTED_EMAIL, SAMPLE_SENDER, accepted, True), - call(Template.REJECTED_EMAIL, SAMPLE_SENDER, rejected, True), - call(Template.WAITLISTED_EMAIL, SAMPLE_SENDER, waitlisted, True), - ] + mock_sendgrid_handler_send_email.assert_called_once_with( + Template.ACCEPTED_EMAIL, IH_SENDER, expected_personalizations, True )