Skip to content

Commit

Permalink
Merge pull request #259 from HackAtUCI/feature/decision-release
Browse files Browse the repository at this point in the history
Create decision release endpoint for directors
  • Loading branch information
samderanova authored Jan 17, 2024
2 parents d10e0da + 146e6b9 commit b97a89c
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 48 deletions.
59 changes: 57 additions & 2 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down Expand Up @@ -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"]
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/utils/batched.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 15 additions & 23 deletions apps/api/src/utils/email_handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Protocol
from typing import Iterable, Protocol

from pydantic import EmailStr

Expand All @@ -9,6 +9,12 @@
IH_SENDER = ("[email protected]", "IrvineHacks 2024 Applications")
REPLY_TO_HACK_AT_UCI = ("[email protected]", "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
Expand Down Expand Up @@ -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)
36 changes: 13 additions & 23 deletions apps/api/tests/test_email_handler.py
Original file line number Diff line number Diff line change
@@ -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", "[email protected]"): Decision.ACCEPTED,
("test2", "[email protected]"): Decision.REJECTED,
("test3", "[email protected]"): Decision.WAITLISTED,
}

accepted = [
ApplicationUpdatePersonalization(first_name="test1", email="[email protected]")
users = [
("test1", "[email protected]"),
("test2", "[email protected]"),
("test3", "[email protected]"),
]
rejected = [
ApplicationUpdatePersonalization(first_name="test2", email="[email protected]")
]
waitlisted = [
ApplicationUpdatePersonalization(first_name="test3", email="[email protected]")

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
)

0 comments on commit b97a89c

Please sign in to comment.