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() { ); }