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={}
+ 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;