diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py index bb563467..7dc7f18b 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -225,6 +225,63 @@ async def submit_review( ) +@router.post("/set-thresholds") +async def set_hacker_score_thresholds( + user: Annotated[User, Depends(require_manager)], + accept: float = Body(), + waitlist: float = Body(), +) -> None: + """ + Sets accepted and waitlisted score thresholds. + Any score under waitlisted is considered rejected. + """ + 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)] +) -> Optional[dict[str, Any]]: + """ + Gets accepted and waitlisted thresholds + """ + log.info("%s requested thresholds", user) + + try: + record = await mongodb_handler.retrieve_one( + Collection.SETTINGS, + {"_id": "hacker_score_thresholds"}, + ) + except RuntimeError: + log.error("%s could not retrieve thresholds", user) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + return record + + @router.post("/release", dependencies=[Depends(require_director)]) async def release_decisions() -> None: """Update applicant status based on decision and send decision emails.""" diff --git a/apps/api/tests/test_admin.py b/apps/api/tests/test_admin.py index b6c219fe..34808a33 100644 --- a/apps/api/tests/test_admin.py +++ b/apps/api/tests/test_admin.py @@ -450,3 +450,47 @@ def test_release_hacker_decisions_works( 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 = REVIEWER_IDENTITY + + res = reviewer_client.post( + "/set-thresholds", json={"accept": "12", "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": 12, "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 = REVIEWER_IDENTITY + + res = reviewer_client.post( + "/set-thresholds", json={"accept": "12", "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": 12}}, + upsert=True, + ) diff --git a/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx b/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx new file mode 100644 index 00000000..c878ddeb --- /dev/null +++ b/apps/site/src/app/admin/applicants/components/HackerThresholdInputs.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import axios from "axios"; + +import { + Box, + Button, + Input, + SpaceBetween, +} from "@cloudscape-design/components"; + +import useHackerThresholds from "@/lib/admin/useHackerThresholds"; + +function HackerThresholdInputs() { + const { thresholds } = useHackerThresholds(); + + const [acceptValue, setAcceptValue] = useState(""); + const [waitlistValue, setWaitlistValue] = useState(""); + + const [status, setStatus] = useState(""); + + async function submitThresholds() { + const sentAcceptValue = acceptValue ? acceptValue : -1; + const sentWaitlistValue = waitlistValue ? waitlistValue : -1; + + await axios + .post("/api/admin/set-thresholds", { + accept: sentAcceptValue, + waitlist: sentWaitlistValue, + }) + .then((response) => { + // TODO: Add flashbar or modal to show post status + if (response.status === 200) { + setStatus( + "Successfully updated thresholds. Reload to see changes to applicants.", + ); + } else { + setStatus("Failed to update thresholds"); + } + }); + } + + return ( + + {thresholds && ( + <> + + Current Accept Threshold: {thresholds.accept} + + + Current Waitlist Threshold: {thresholds.waitlist} + + + )} + Accept Threshold + setAcceptValue(detail.value)} + value={acceptValue} + type="number" + inputMode="decimal" + placeholder="Accept Threshold" + step={0.1} + /> + Waitlist Threshold + setWaitlistValue(detail.value)} + value={waitlistValue} + type="number" + inputMode="decimal" + placeholder="Waitlist Threshold" + step={0.1} + /> + + Any score under{" "} + {waitlistValue ? waitlistValue : "the waitlist threshold"} will be + rejected + + + {status ? ( + + {status} + + ) : null} + + ); +} + +export default HackerThresholdInputs; diff --git a/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx index 0d909485..6b7cfb5b 100644 --- a/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx +++ b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx @@ -19,6 +19,7 @@ import ApplicantStatus from "../components/ApplicantStatus"; import UserContext from "@/lib/admin/UserContext"; import { isApplicationManager } from "@/lib/admin/authorization"; +import HackerThresholdInputs from "../components/HackerThresholdInputs"; import ApplicantReviewerIndicator from "../components/ApplicantReviewerIndicator"; function HackerApplicants() { @@ -116,7 +117,11 @@ function HackerApplicants() { /> } empty={emptyContent} - header={
Applicants
} + header={ +
}> + Applicants +
+ } /> ); } diff --git a/apps/site/src/lib/admin/useHackerThresholds.ts b/apps/site/src/lib/admin/useHackerThresholds.ts new file mode 100644 index 00000000..1d1f25d2 --- /dev/null +++ b/apps/site/src/lib/admin/useHackerThresholds.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import useSWR from "swr"; + +export interface ScoreThresholds { + _id: string; + accept: number; + waitlist: number; +} + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useHackerThresholds() { + const { data, error, isLoading } = useSWR( + "/api/admin/get-thresholds", + fetcher, + ); + + return { thresholds: data, loading: isLoading, error }; +} + +export default useHackerThresholds;