Skip to content

Commit

Permalink
Moved director only routes to director.py (#578)
Browse files Browse the repository at this point in the history
* Moved director only routes to director.py
- Fixed test_admin and test_director accordingly

* Updated director routes used in frontend
  • Loading branch information
IanWearsHat authored Jan 18, 2025
1 parent 0e4addf commit 962a6fe
Show file tree
Hide file tree
Showing 7 changed files with 422 additions and 401 deletions.
244 changes: 8 additions & 236 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import asyncio
from datetime import date, datetime
from logging import getLogger
from typing import Annotated, Any, Literal, Mapping, Optional, Sequence
from typing import Annotated, Any, Literal, Mapping, Optional

from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
from pydantic import BaseModel, TypeAdapter, ValidationError
from typing_extensions import assert_never

from admin import applicant_review_processor, participant_manager, summary_handler
from admin.participant_manager import Participant
from auth.authorization import require_role
from auth.user_identity import User, utc_now
from models.ApplicationData import Decision, Review
from models.user_record import Applicant, ApplicantStatus, Role, Status
from services import mongodb_handler, sendgrid_handler
from models.user_record import Applicant, ApplicantStatus, Role
from services import mongodb_handler
from services.mongodb_handler import BaseRecord, Collection
from services.sendgrid_handler import ApplicationUpdatePersonalization, Template
from utils import email_handler
from utils.batched import batched
from utils.email_handler import IH_SENDER

log = getLogger(__name__)

Expand All @@ -40,7 +36,6 @@
require_mentor_reviewer = require_role({Role.DIRECTOR, Role.MENTOR_REVIEWER})
require_volunteer_reviewer = require_role({Role.DIRECTOR, Role.VOLUNTEER_REVIEWER})
require_checkin_lead = require_role({Role.DIRECTOR, Role.CHECKIN_LEAD})
require_director = require_role({Role.DIRECTOR})
require_organizer = require_role({Role.ORGANIZER})


Expand Down Expand Up @@ -140,7 +135,7 @@ async def hacker_applicants(
],
)

thresholds: Optional[dict[str, float]] = await _retrieve_thresholds()
thresholds: Optional[dict[str, float]] = await retrieve_thresholds()

if not thresholds:
log.error("Could not retrieve thresholds")
Expand Down Expand Up @@ -292,62 +287,6 @@ async def submit_review(
)


@router.post("/set-thresholds")
async def set_hacker_score_thresholds(
user: Annotated[User, Depends(require_director)],
accept: float = Body(),
waitlist: float = Body(),
) -> None:
"""
Sets accepted and waitlisted score thresholds.
Any score under waitlisted is considered rejected.
"""

thresholds: Optional[dict[str, float]] = await _retrieve_thresholds()

if accept != -1 and thresholds is not None:
thresholds["accept"] = accept
if waitlist != -1 and thresholds is not None:
thresholds["waitlist"] = waitlist

if (
accept < -1
or accept > 10
or waitlist < -1
or waitlist > 10
or (accept != -1 and waitlist != -1 and waitlist > accept)
or (thresholds and thresholds["waitlist"] > thresholds["accept"])
):
log.error("Invalid threshold score submitted.")
raise HTTPException(status.HTTP_400_BAD_REQUEST)

log.info("%s changed thresholds: Accept-%f | Waitlist-%f", user, accept, waitlist)

# negative numbers should not be received, but -1 in this case
# means there is no update to the respective threshold
update_query = {}
if accept != -1:
update_query["accept"] = accept
if waitlist != -1:
update_query["waitlist"] = waitlist

try:
await mongodb_handler.raw_update_one(
Collection.SETTINGS,
{"_id": "hacker_score_thresholds"},
{"$set": update_query},
upsert=True,
)
except RuntimeError:
log.error(
"%s could not change thresholds: Accept-%f | Waitlist-%f",
user,
accept,
waitlist,
)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)


@router.get("/get-thresholds")
async def get_hacker_score_thresholds(
user: Annotated[User, Depends(require_manager)]
Expand All @@ -368,121 +307,6 @@ async def get_hacker_score_thresholds(
return record


@router.post("/release/mentor-volunteer", dependencies=[Depends(require_director)])
async def release_mentor_volunteer_decisions() -> None:
"""Update applicant status based on decision and send decision emails."""
mentor_records = await mongodb_handler.retrieve(
Collection.USERS,
{"status": Status.REVIEWED, "roles": {"$in": [Role.MENTOR]}},
["_id", "application_data.reviews", "first_name"],
)

for record in mentor_records:
applicant_review_processor.include_review_decision(record)

volunteer_records = await mongodb_handler.retrieve(
Collection.USERS,
{"status": Status.REVIEWED, "roles": {"$in": [Role.VOLUNTEER]}},
["_id", "application_data.reviews", "first_name"],
)

for record in volunteer_records:
applicant_review_processor.include_review_decision(record)

await _process_records_in_batches(mentor_records, Role.MENTOR)
await _process_records_in_batches(volunteer_records, Role.VOLUNTEER)


@router.post("/release/hackers", dependencies=[Depends(require_director)])
async def release_hacker_decisions() -> None:
"""Update hacker applicant status based on decision and send decision emails."""
records = await mongodb_handler.retrieve(
Collection.USERS,
{"status": Status.REVIEWED, "roles": {"$in": [Role.HACKER]}},
["_id", "application_data.reviews", "first_name"],
)

thresholds: Optional[dict[str, float]] = await _retrieve_thresholds()

if not thresholds:
log.error("Could not retrieve thresholds")
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

for record in records:
applicant_review_processor.include_hacker_app_fields(
record, thresholds["accept"], thresholds["waitlist"]
)

await _process_records_in_batches(records, Role.HACKER)


@router.post("/rsvp-reminder", dependencies=[Depends(require_director)])
async def rsvp_reminder() -> None:
"""Send email to applicants who have a status of ACCEPTED or WAIVER_SIGNED
reminding them to RSVP."""
# TODO: Consider using Pydantic model validation instead of type annotations
not_yet_rsvpd: list[dict[str, Any]] = await mongodb_handler.retrieve(
Collection.USERS,
{
"roles": Role.APPLICANT,
"status": {"$in": [Decision.ACCEPTED, Status.WAIVER_SIGNED]},
},
["_id", "first_name"],
)

personalizations = []
for record in not_yet_rsvpd:
personalizations.append(
ApplicationUpdatePersonalization(
email=recover_email_from_uid(record["_id"]),
first_name=record["first_name"],
)
)

log.info(f"Sending RSVP reminder emails to {len(not_yet_rsvpd)} applicants")

await sendgrid_handler.send_email(
Template.RSVP_REMINDER,
IH_SENDER,
personalizations,
True,
)


@router.post("/confirm-attendance", dependencies=[Depends(require_director)])
async def confirm_attendance() -> None:
"""Update applicant status to void or attending based on their current status."""
# TODO: consider using Pydantic model, maybe BareApplicant
records = await mongodb_handler.retrieve(
Collection.USERS, {"roles": Role.APPLICANT}, ["_id", "status"]
)

statuses = {
Status.CONFIRMED: Status.ATTENDING,
Decision.ACCEPTED: Status.VOID,
Status.WAIVER_SIGNED: Status.VOID,
}

for status_from, status_to in statuses.items():
curr_records = [record for record in records if record["status"] == status_from]

for record in curr_records:
record["status"] = status_to

log.info(
f"Changing status of {len(curr_records)} from {status_from} to {status_to}"
)

await asyncio.gather(
*(
_process_status(batch, status_to)
for batch in batched(
[str(record["_id"]) for record in curr_records], 100
)
)
)


@router.post("/waitlist-release/{uid}")
async def waitlist_release(
uid: str,
Expand Down Expand Up @@ -568,58 +392,12 @@ async def subevent_checkin(
await participant_manager.subevent_checkin(event, uid, organizer)


async def _process_records_in_batches(
records: list[dict[str, object]],
application_type: Literal[Role.HACKER, Role.MENTOR, Role.VOLUNTEER],
) -> None:
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, application_type)
for batch in batched(group, 100)
)
)


async def _process_status(uids: Sequence[str], status: Status) -> None:
ok = await mongodb_handler.update(
Collection.USERS, {"_id": {"$in": uids}}, {"status": status}
)
if not ok:
raise RuntimeError("gg wp")


async def _process_batch(
batch: tuple[dict[str, Any], ...],
decision: Decision,
application_type: Literal[Role.HACKER, Role.MENTOR, Role.VOLUNTEER],
) -> None:
uids: list[str] = [record["_id"] for record in batch]
log.info(f"Setting {application_type}s {','.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 {application_type} {decision} emails for {len(batch)} applicants"
)
await email_handler.send_decision_email(
map(_extract_personalizations, batch), decision, application_type
async def retrieve_thresholds() -> Optional[dict[str, Any]]:
return await mongodb_handler.retrieve_one(
Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"]
)


def _extract_personalizations(decision_data: dict[str, Any]) -> tuple[str, EmailStr]:
name = decision_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."""
uid = uid.replace("..", "\n")
Expand All @@ -629,12 +407,6 @@ def recover_email_from_uid(uid: str) -> str:
return f"{local}@{domain}"


async def _retrieve_thresholds() -> Optional[dict[str, Any]]:
return await mongodb_handler.retrieve_one(
Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"]
)


async def _try_update_applicant_with_query(
applicant_review: ReviewRequest,
*,
Expand Down
Loading

0 comments on commit 962a6fe

Please sign in to comment.