diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py
index f3efbd4e..68b22a2f 100644
--- a/apps/api/src/routers/admin.py
+++ b/apps/api/src/routers/admin.py
@@ -1,10 +1,9 @@
-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
@@ -12,13 +11,10 @@
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__)
@@ -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})
@@ -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")
@@ -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)]
@@ -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,
@@ -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")
@@ -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,
*,
diff --git a/apps/api/src/routers/director.py b/apps/api/src/routers/director.py
index 8f792c22..a1397cc5 100644
--- a/apps/api/src/routers/director.py
+++ b/apps/api/src/routers/director.py
@@ -1,18 +1,28 @@
+import asyncio
+
from datetime import datetime
from logging import getLogger
-from typing import Annotated, Any, Optional
+from typing import Annotated, Any, Literal, Optional, Sequence
from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
+from admin import applicant_review_processor
from auth.authorization import require_role
from auth.user_identity import User, uci_email, utc_now
-from models.user_record import Role
+from models.ApplicationData import Decision
+from models.user_record import Role, Status
from services import mongodb_handler, sendgrid_handler
from services.mongodb_handler import BaseRecord, Collection
-from services.sendgrid_handler import PersonalizationData, Template
-from routers.admin import recover_email_from_uid
+from services.sendgrid_handler import (
+ ApplicationUpdatePersonalization,
+ PersonalizationData,
+ Template,
+)
+from routers.admin import recover_email_from_uid, retrieve_thresholds
+from utils import email_handler
from utils.email_handler import IH_SENDER
+from utils.batched import batched
log = getLogger(__name__)
@@ -209,3 +219,226 @@ async def apply_reminder(user: Annotated[User, Depends(require_director)]) -> No
personalizations,
True,
)
+
+
+@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("/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.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)
+
+
+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_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_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
+ )
+
+
+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
diff --git a/apps/api/tests/test_admin.py b/apps/api/tests/test_admin.py
index caa70fe1..bbeeffa5 100644
--- a/apps/api/tests/test_admin.py
+++ b/apps/api/tests/test_admin.py
@@ -1,13 +1,12 @@
from datetime import datetime
from typing import Any
-from unittest.mock import ANY, AsyncMock, call, patch
+from unittest.mock import ANY, AsyncMock, patch
from fastapi import FastAPI
from auth import user_identity
from auth.user_identity import NativeUser, UserTestClient
from models.ApplicationData import Decision
-from models.user_record import Status
from routers import admin
from services.mongodb_handler import Collection
from services.sendgrid_handler import Template
@@ -280,65 +279,6 @@ def test_submit_hacker_review_with_three_reviewers_fails(
assert res.status_code == 403
-@patch("services.mongodb_handler.update", autospec=True)
-@patch("services.mongodb_handler.retrieve_one", autospec=True)
-@patch("services.mongodb_handler.retrieve", autospec=True)
-def test_confirm_attendance_route(
- mock_mongodb_handler_retrieve: AsyncMock,
- mock_mongodb_handler_retrieve_one: AsyncMock,
- mock_mognodb_handler_update: AsyncMock,
-) -> None:
- """Test that confirmed status changes to void with accepted."""
-
- mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
- mock_mongodb_handler_retrieve.return_value = [
- {
- "_id": "edu.uc.tester",
- "status": Decision.ACCEPTED,
- },
- {
- "_id": "edu.uc.tester2",
- "status": Status.WAIVER_SIGNED,
- },
- {
- "_id": "edu.uc.tester3",
- "status": Status.CONFIRMED,
- },
- {
- "_id": "edu.uc.tester4",
- "status": Decision.WAITLISTED,
- },
- ]
-
- # Not doing this will result in the return value acting as a mock, which would be
- # tracked in the assert_has_calls below.
- mock_mognodb_handler_update.side_effect = [True, True, True]
-
- res = director_client.post("/confirm-attendance")
-
- assert res.status_code == 200
- mock_mongodb_handler_retrieve.assert_awaited_once()
- mock_mognodb_handler_update.assert_has_calls(
- [
- call(
- Collection.USERS,
- {"_id": {"$in": ("edu.uc.tester3",)}},
- {"status": Status.ATTENDING},
- ),
- call(
- Collection.USERS,
- {"_id": {"$in": ("edu.uc.tester",)}},
- {"status": Status.VOID},
- ),
- call(
- Collection.USERS,
- {"_id": {"$in": ("edu.uc.tester2",)}},
- {"status": Status.VOID},
- ),
- ]
- )
-
-
@patch("services.sendgrid_handler.send_email", autospec=True)
@patch("services.mongodb_handler.update_one", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
@@ -442,101 +382,6 @@ def test_hacker_applicants_returns_correct_applicants(
assert data == expected_records
-@patch("routers.admin._process_records_in_batches", autospec=True)
-@patch("services.mongodb_handler.retrieve", autospec=True)
-@patch("services.mongodb_handler.retrieve_one", autospec=True)
-def test_release_hacker_decisions_works(
- mock_mongodb_handler_retrieve_one: AsyncMock,
- mock_mongodb_handler_retrieve: AsyncMock,
- mock_admin_process_records_in_batches: AsyncMock,
-) -> None:
- """Test that the /release/hackers route works"""
- returned_records: list[dict[str, Any]] = [
- {
- "_id": "edu.uci.sydnee",
- "first_name": "sydnee",
- "application_data": {
- "reviews": [
- [datetime(2023, 1, 19), "edu.uci.alicia", 100],
- [datetime(2023, 1, 19), "edu.uci.alicia2", 300],
- ]
- },
- }
- ]
-
- threshold_record: dict[str, Any] = {"accept": 10, "waitlist": 5}
-
- mock_mongodb_handler_retrieve_one.side_effect = [
- DIRECTOR_IDENTITY,
- threshold_record,
- ]
- mock_mongodb_handler_retrieve.return_value = returned_records
- mock_admin_process_records_in_batches.return_value = None
-
- res = reviewer_client.post("/release/hackers")
-
- assert res.status_code == 200
- assert returned_records[0]["decision"] == Decision.ACCEPTED
-
-
-@patch("services.mongodb_handler.retrieve_one", autospec=True)
-@patch("services.mongodb_handler.raw_update_one", autospec=True)
-def test_set_thresholds_correctly(
- mock_mongodb_handler_raw_update_one: AsyncMock,
- mock_mongodb_handler_retrieve_one: AsyncMock,
-) -> None:
- """Test that the /set-thresholds route returns correctly"""
- mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
-
- res = reviewer_client.post(
- "/set-thresholds", json={"accept": "10", "waitlist": "5"}
- )
-
- assert res.status_code == 200
- mock_mongodb_handler_raw_update_one.assert_awaited_once_with(
- Collection.SETTINGS,
- {"_id": "hacker_score_thresholds"},
- {"$set": {"accept": 10, "waitlist": 5}},
- upsert=True,
- )
-
-
-@patch("services.mongodb_handler.retrieve_one", autospec=True)
-@patch("services.mongodb_handler.raw_update_one", autospec=True)
-def test_set_thresholds_with_empty_string_correctly(
- mock_mongodb_handler_raw_update_one: AsyncMock,
- mock_mongodb_handler_retrieve_one: AsyncMock,
-) -> None:
- """Test that the /set-thresholds route returns correctly with -1"""
- mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
-
- res = reviewer_client.post(
- "/set-thresholds", json={"accept": "10", "waitlist": "-1"}
- )
-
- assert res.status_code == 200
- mock_mongodb_handler_raw_update_one.assert_awaited_once_with(
- Collection.SETTINGS,
- {"_id": "hacker_score_thresholds"},
- {"$set": {"accept": 10}},
- upsert=True,
- )
-
-
-@patch("services.mongodb_handler.retrieve_one", autospec=True)
-def test_organizer_set_thresholds_forbidden(
- mock_mongodb_handler_retrieve_one: AsyncMock,
-) -> None:
- """Test whether anyone below a director can change threshold."""
- mock_mongodb_handler_retrieve_one.return_value = HACKER_REVIEWER_IDENTITY
-
- res = reviewer_client.post(
- "/set-thresholds", json={"accept": "12", "waitlist": "5"}
- )
-
- assert res.status_code == 403
-
-
@patch("services.mongodb_handler.raw_update_one", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
def test_review_on_invalid_value(
diff --git a/apps/api/tests/test_director.py b/apps/api/tests/test_director.py
index a51115d0..6d2aa14b 100644
--- a/apps/api/tests/test_director.py
+++ b/apps/api/tests/test_director.py
@@ -1,15 +1,25 @@
from datetime import datetime
-from unittest.mock import ANY, AsyncMock, patch
+from typing import Any
+from unittest.mock import ANY, AsyncMock, call, patch
from fastapi import FastAPI
from auth.user_identity import NativeUser, UserTestClient
-from models.user_record import Role
+from models.ApplicationData import Decision
+from models.user_record import Role, Status
from routers import director
from services.mongodb_handler import Collection
from services.sendgrid_handler import Template
from utils.email_handler import IH_SENDER
+
+USER_REVIEWER = NativeUser(
+ ucinetid="alicia",
+ display_name="Alicia",
+ email="alicia@uci.edu",
+ affiliations=["student"],
+)
+
USER_DIRECTOR = NativeUser(
ucinetid="dir",
display_name="Dir",
@@ -17,11 +27,18 @@
affiliations=["student"],
)
+HACKER_REVIEWER_IDENTITY = {
+ "_id": "edu.uci.alicia",
+ "roles": ["Organizer", "Hacker Reviewer"],
+}
+
DIRECTOR_IDENTITY = {"_id": "edu.uci.dir", "roles": [Role.ORGANIZER, Role.DIRECTOR]}
app = FastAPI()
app.include_router(director.router)
+reviewer_client = UserTestClient(USER_REVIEWER, app)
+
director_client = UserTestClient(USER_DIRECTOR, app)
SAMPLE_ORGANIZER = {
@@ -157,3 +174,157 @@ def test_get_apply_reminder_senders(
{"_id": "apply_reminder"},
["senders"],
)
+
+
+@patch("services.mongodb_handler.update", autospec=True)
+@patch("services.mongodb_handler.retrieve_one", autospec=True)
+@patch("services.mongodb_handler.retrieve", autospec=True)
+def test_confirm_attendance_route(
+ mock_mongodb_handler_retrieve: AsyncMock,
+ mock_mongodb_handler_retrieve_one: AsyncMock,
+ mock_mognodb_handler_update: AsyncMock,
+) -> None:
+ """Test that confirmed status changes to void with accepted."""
+
+ mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
+ mock_mongodb_handler_retrieve.return_value = [
+ {
+ "_id": "edu.uc.tester",
+ "status": Decision.ACCEPTED,
+ },
+ {
+ "_id": "edu.uc.tester2",
+ "status": Status.WAIVER_SIGNED,
+ },
+ {
+ "_id": "edu.uc.tester3",
+ "status": Status.CONFIRMED,
+ },
+ {
+ "_id": "edu.uc.tester4",
+ "status": Decision.WAITLISTED,
+ },
+ ]
+
+ # Not doing this will result in the return value acting as a mock, which would be
+ # tracked in the assert_has_calls below.
+ mock_mognodb_handler_update.side_effect = [True, True, True]
+
+ res = director_client.post("/confirm-attendance")
+
+ assert res.status_code == 200
+ mock_mongodb_handler_retrieve.assert_awaited_once()
+ mock_mognodb_handler_update.assert_has_calls(
+ [
+ call(
+ Collection.USERS,
+ {"_id": {"$in": ("edu.uc.tester3",)}},
+ {"status": Status.ATTENDING},
+ ),
+ call(
+ Collection.USERS,
+ {"_id": {"$in": ("edu.uc.tester",)}},
+ {"status": Status.VOID},
+ ),
+ call(
+ Collection.USERS,
+ {"_id": {"$in": ("edu.uc.tester2",)}},
+ {"status": Status.VOID},
+ ),
+ ]
+ )
+
+
+@patch("services.mongodb_handler.retrieve_one", autospec=True)
+@patch("services.mongodb_handler.raw_update_one", autospec=True)
+def test_set_thresholds_correctly(
+ mock_mongodb_handler_raw_update_one: AsyncMock,
+ mock_mongodb_handler_retrieve_one: AsyncMock,
+) -> None:
+ """Test that the /set-thresholds route returns correctly"""
+ mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
+
+ res = director_client.post(
+ "/set-thresholds", json={"accept": "10", "waitlist": "5"}
+ )
+
+ assert res.status_code == 200
+ mock_mongodb_handler_raw_update_one.assert_awaited_once_with(
+ Collection.SETTINGS,
+ {"_id": "hacker_score_thresholds"},
+ {"$set": {"accept": 10, "waitlist": 5}},
+ upsert=True,
+ )
+
+
+@patch("services.mongodb_handler.retrieve_one", autospec=True)
+@patch("services.mongodb_handler.raw_update_one", autospec=True)
+def test_set_thresholds_with_empty_string_correctly(
+ mock_mongodb_handler_raw_update_one: AsyncMock,
+ mock_mongodb_handler_retrieve_one: AsyncMock,
+) -> None:
+ """Test that the /set-thresholds route returns correctly with -1"""
+ mock_mongodb_handler_retrieve_one.return_value = DIRECTOR_IDENTITY
+
+ res = director_client.post(
+ "/set-thresholds", json={"accept": "10", "waitlist": "-1"}
+ )
+
+ assert res.status_code == 200
+ mock_mongodb_handler_raw_update_one.assert_awaited_once_with(
+ Collection.SETTINGS,
+ {"_id": "hacker_score_thresholds"},
+ {"$set": {"accept": 10}},
+ upsert=True,
+ )
+
+
+@patch("services.mongodb_handler.retrieve_one", autospec=True)
+def test_organizer_set_thresholds_forbidden(
+ mock_mongodb_handler_retrieve_one: AsyncMock,
+) -> None:
+ """Test whether anyone below a director can change threshold."""
+ mock_mongodb_handler_retrieve_one.return_value = HACKER_REVIEWER_IDENTITY
+
+ res = reviewer_client.post(
+ "/set-thresholds", json={"accept": "12", "waitlist": "5"}
+ )
+
+ assert res.status_code == 403
+
+
+@patch("routers.director._process_records_in_batches", autospec=True)
+@patch("services.mongodb_handler.retrieve", autospec=True)
+@patch("services.mongodb_handler.retrieve_one", autospec=True)
+def test_release_hacker_decisions_works(
+ mock_mongodb_handler_retrieve_one: AsyncMock,
+ mock_mongodb_handler_retrieve: AsyncMock,
+ mock_admin_process_records_in_batches: AsyncMock,
+) -> None:
+ """Test that the /release/hackers route works"""
+ returned_records: list[dict[str, Any]] = [
+ {
+ "_id": "edu.uci.sydnee",
+ "first_name": "sydnee",
+ "application_data": {
+ "reviews": [
+ [datetime(2023, 1, 19), "edu.uci.alicia", 100],
+ [datetime(2023, 1, 19), "edu.uci.alicia2", 300],
+ ]
+ },
+ }
+ ]
+
+ threshold_record: dict[str, Any] = {"accept": 10, "waitlist": 5}
+
+ mock_mongodb_handler_retrieve_one.side_effect = [
+ DIRECTOR_IDENTITY,
+ threshold_record,
+ ]
+ mock_mongodb_handler_retrieve.return_value = returned_records
+ mock_admin_process_records_in_batches.return_value = None
+
+ res = director_client.post("/release/hackers")
+
+ assert res.status_code == 200
+ assert returned_records[0]["decision"] == Decision.ACCEPTED
diff --git a/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx b/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx
index 1174fe76..baf4994a 100644
--- a/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx
+++ b/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx
@@ -24,7 +24,7 @@ function HackerThresholdInputs() {
if (isValidAccept() && isValidWaitlist()) {
await axios
- .post("/api/admin/set-thresholds", {
+ .post("/api/director/set-thresholds", {
accept: sentAcceptValue,
waitlist: sentWaitlistValue,
})
diff --git a/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx b/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx
index b08faccb..3f276897 100644
--- a/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx
+++ b/apps/site/src/app/admin/directors/email-sender/components/ReleaseDecisions.tsx
@@ -5,7 +5,7 @@ function ReleaseNonHackerDecisions() {
);
}
diff --git a/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx b/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx
index db9e1863..2d32d2fd 100644
--- a/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx
+++ b/apps/site/src/app/admin/directors/email-sender/components/ReleaseHackerDecisions.tsx
@@ -5,7 +5,7 @@ function ReleaseHackerDecisions() {
);
}