diff --git a/apps/api/src/admin/applicant_review_processor.py b/apps/api/src/admin/applicant_review_processor.py new file mode 100644 index 00000000..f445fa00 --- /dev/null +++ b/apps/api/src/admin/applicant_review_processor.py @@ -0,0 +1,64 @@ +from typing import Any + +from models.ApplicationData import Decision + + +def include_hacker_app_fields( + applicant_record: dict[str, Any], accept_threshold: float, waitlist_threshold: float +) -> None: + _include_decision_based_on_threshold( + applicant_record, accept_threshold, waitlist_threshold + ) + _include_num_reviewers(applicant_record) + _include_avg_score(applicant_record) + + +def include_review_decision(applicant_record: dict[str, Any]) -> None: + """Sets the applicant's decision as the last submitted review decision or None.""" + reviews = applicant_record["application_data"]["reviews"] + applicant_record["decision"] = reviews[-1][2] if reviews else None + + +def _get_last_score(reviewer: str, reviews: list[tuple[str, str, float]]) -> float: + for review in reversed(reviews): + if review[1] == reviewer: + return review[2] + return -1 + + +def _get_avg_score(reviews: list[tuple[str, str, float]]) -> float: + unique_reviewers = {t[1] for t in reviews} + if len(unique_reviewers) < 2: + return -1 + + last_score = _get_last_score(unique_reviewers.pop(), reviews) + last_score2 = _get_last_score(unique_reviewers.pop(), reviews) + return (last_score + last_score2) / 2 + + +def _include_decision_based_on_threshold( + applicant_record: dict[str, Any], accept: float, waitlist: float +) -> None: + avg_score = _get_avg_score(applicant_record["application_data"]["reviews"]) + if avg_score >= accept: + applicant_record["decision"] = Decision.ACCEPTED + elif avg_score >= waitlist: + applicant_record["decision"] = Decision.WAITLISTED + else: + applicant_record["decision"] = Decision.REJECTED + + +def _get_num_unique_reviewers(applicant_record: dict[str, Any]) -> int: + reviews = applicant_record["application_data"]["reviews"] + unique_reviewers = {t[1] for t in reviews} + return len(unique_reviewers) + + +def _include_num_reviewers(applicant_record: dict[str, Any]) -> None: + applicant_record["num_reviewers"] = _get_num_unique_reviewers(applicant_record) + + +def _include_avg_score(applicant_record: dict[str, Any]) -> None: + applicant_record["avg_score"] = _get_avg_score( + applicant_record["application_data"]["reviews"] + ) diff --git a/apps/api/src/models/ApplicationData.py b/apps/api/src/models/ApplicationData.py index 71fa7873..a9c54120 100644 --- a/apps/api/src/models/ApplicationData.py +++ b/apps/api/src/models/ApplicationData.py @@ -22,7 +22,8 @@ class Decision(str, Enum): REJECTED = "REJECTED" -Review = tuple[datetime, str, Decision] +HackerReview = tuple[datetime, str, float] +OtherReview = tuple[datetime, str, Decision] def make_empty_none(val: Union[str, None]) -> Union[str, None]: @@ -139,7 +140,7 @@ class ProcessedHackerApplicationData(BaseApplicationData): email: EmailStr resume_url: Union[HttpUrl, None] = None submission_time: datetime - reviews: list[Review] = [] + reviews: list[HackerReview] = [] @field_serializer("linkedin", "portfolio", "resume_url") def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]: @@ -152,7 +153,7 @@ class ProcessedMentorApplicationData(BaseMentorApplicationData): email: EmailStr resume_url: Union[HttpUrl, None] = None submission_time: datetime - reviews: list[Review] = [] + reviews: list[OtherReview] = [] @field_serializer("linkedin", "github", "portfolio", "resume_url") def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]: @@ -165,7 +166,7 @@ class ProcessedVolunteerApplication(BaseVolunteerApplicationData): # TODO: specify common attributes in mixin email: EmailStr submission_time: datetime - reviews: list[Review] = [] + reviews: list[OtherReview] = [] # To add more discriminating values, add a string diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py index cecf1fa3..239b38eb 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -4,13 +4,14 @@ from typing import Annotated, Any, Optional, Sequence from fastapi import APIRouter, Body, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr, Field, TypeAdapter, ValidationError +from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError from admin import participant_manager, summary_handler +from admin import applicant_review_processor 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.ApplicationData import Decision, OtherReview from models.user_record import Applicant, ApplicantStatus, Role, Status from services import mongodb_handler, sendgrid_handler from services.mongodb_handler import BaseRecord, Collection @@ -35,7 +36,6 @@ class ApplicationDataSummary(BaseModel): class ApplicantSummary(BaseRecord): - uid: str = Field(alias="_id") first_name: str last_name: str status: str @@ -43,6 +43,16 @@ class ApplicantSummary(BaseRecord): application_data: ApplicationDataSummary +class HackerApplicantSummary(BaseRecord): + first_name: str + last_name: str + status: str + decision: Optional[Decision] = None + num_reviewers: int + avg_score: float + application_data: ApplicationDataSummary + + @router.get("/applicants") async def applicants( user: Annotated[User, Depends(require_manager)] @@ -65,7 +75,7 @@ async def applicants( ) for record in records: - _include_review_decision(record) + applicant_review_processor.include_review_decision(record) try: return TypeAdapter(list[ApplicantSummary]).validate_python(records) @@ -73,6 +83,46 @@ async def applicants( raise RuntimeError("Could not parse applicant data.") +@router.get("/applicants/hackers") +async def hacker_applicants( + user: Annotated[User, Depends(require_manager)] +) -> list[HackerApplicantSummary]: + """Get records of all hacker applicants.""" + log.info("%s requested hacker applicants", user) + + records: list[dict[str, object]] = await mongodb_handler.retrieve( + Collection.USERS, + {"roles": Role.HACKER}, + [ + "_id", + "status", + "first_name", + "last_name", + "application_data.school", + "application_data.submission_time", + "application_data.reviews", + ], + ) + + thresholds: Optional[dict[str, float]] = await mongodb_handler.retrieve_one( + Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"] + ) + + 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"] + ) + + try: + return TypeAdapter(list[HackerApplicantSummary]).validate_python(records) + except ValidationError: + raise RuntimeError("Could not parse applicant data.") + + @router.get("/applicant/{uid}", dependencies=[Depends(require_manager)]) async def applicant( uid: str, @@ -106,7 +156,7 @@ async def submit_review( """Submit a review decision from the reviewer for the given applicant.""" log.info("%s reviewed applicant %s", reviewer, applicant) - review: Review = (utc_now(), reviewer.uid, decision) + review: OtherReview = (utc_now(), reviewer.uid, decision) try: await mongodb_handler.raw_update_one( @@ -132,7 +182,7 @@ async def release_decisions() -> None: ) for record in records: - _include_review_decision(record) + applicant_review_processor.include_review_decision(record) for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED): group = [record for record in records if record["decision"] == decision] @@ -332,9 +382,3 @@ def _recover_email_from_uid(uid: str) -> str: local = local.replace("\n", ".") domain = ".".join(reversed(reversed_domain)) return f"{local}@{domain}" - - -def _include_review_decision(applicant_record: dict[str, Any]) -> None: - """Sets the applicant's decision as the last submitted review decision or None.""" - reviews = applicant_record["application_data"]["reviews"] - applicant_record["decision"] = reviews[-1][2] if reviews else None diff --git a/apps/api/tests/test_admin.py b/apps/api/tests/test_admin.py index 6bd2487b..cc15d497 100644 --- a/apps/api/tests/test_admin.py +++ b/apps/api/tests/test_admin.py @@ -109,34 +109,6 @@ def test_can_retrieve_applicants( ] -def test_can_include_decision_from_reviews() -> None: - """Test that a decision can be provided for an applicant with reviews.""" - record = { - "_id": "edu.uci.sydnee", - "status": "REVIEWED", - "application_data": { - "reviews": [[datetime(2023, 1, 19), "edu.uci.alicia", "ACCEPTED"]], - }, - } - - admin._include_review_decision(record) - assert record["decision"] == "ACCEPTED" - - -def test_no_decision_from_no_reviews() -> None: - """Test that a decision is None for an applicant with no reviews.""" - record = { - "_id": "edu.uci.pham", - "status": "PENDING_REVIEW", - "application_data": { - "reviews": [], - }, - } - - admin._include_review_decision(record) - assert record["decision"] is None - - @patch("services.mongodb_handler.raw_update_one", autospec=True) @patch("services.mongodb_handler.retrieve_one", autospec=True) def test_can_submit_review( @@ -271,3 +243,57 @@ def test_non_waitlisted_applicant_cannot_be_released( assert res.status_code == 404 mock_mongodb_handler_update_one.assert_not_awaited() + + +@patch("services.mongodb_handler.retrieve_one", autospec=True) +@patch("services.mongodb_handler.retrieve", autospec=True) +def test_hacker_applicants_returns_correct_applicants( + mock_mongodb_handler_retrieve: AsyncMock, + mock_mongodb_handler_retrieve_one: AsyncMock, +) -> None: + """Test that the /hackerApplicants route returns correctly""" + returned_records: list[dict[str, object]] = [ + { + "_id": "edu.uci.sydnee", + "first_name": "sydnee", + "last_name": "unknown", + "status": "REVIEWED", + "application_data": { + "school": "Hamburger University", + "submission_time": datetime(2023, 1, 12, 9, 0, 0), + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 200], + ], + }, + } + ] + + returned_thresholds: dict[str, object] = {"accept": 12, "waitlist": 5} + + mock_mongodb_handler_retrieve.return_value = returned_records + mock_mongodb_handler_retrieve_one.side_effect = [ + REVIEWER_IDENTITY, + returned_thresholds, + ] + + res = reviewer_client.get("/applicants/hackers") + + assert res.status_code == 200 + mock_mongodb_handler_retrieve.assert_awaited_once() + data = res.json() + assert data == [ + { + "_id": "edu.uci.sydnee", + "first_name": "sydnee", + "last_name": "unknown", + "status": "REVIEWED", + "decision": "ACCEPTED", + "avg_score": 150, + "num_reviewers": 2, + "application_data": { + "school": "Hamburger University", + "submission_time": "2023-01-12T09:00:00", + }, + }, + ] diff --git a/apps/api/tests/test_applicant_review_processor.py b/apps/api/tests/test_applicant_review_processor.py new file mode 100644 index 00000000..25c9afe3 --- /dev/null +++ b/apps/api/tests/test_applicant_review_processor.py @@ -0,0 +1,69 @@ +from datetime import datetime + +from typing import Any + +from admin import applicant_review_processor + + +def test_no_decision_from_no_reviews() -> None: + """Test that a decision is None for an applicant with no reviews.""" + record = { + "_id": "edu.uci.pham", + "status": "PENDING_REVIEW", + "application_data": { + "reviews": [], + }, + } + + applicant_review_processor.include_review_decision(record) + assert record["decision"] is None + + +def test_can_include_decision_from_reviews() -> None: + """Test that a decision can be provided for an applicant with reviews.""" + record = { + "_id": "edu.uci.sydnee", + "status": "REVIEWED", + "application_data": { + "reviews": [[datetime(2023, 1, 19), "edu.uci.alicia", "ACCEPTED"]], + }, + } + + applicant_review_processor.include_review_decision(record) + assert record["decision"] == "ACCEPTED" + + +def test_can_include_num_reviewers_from_reviews() -> None: + """Test that the number of reviewers are added to an applicant with reviews.""" + record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "status": "REVIEWED", + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 200], + ] + }, + } + + applicant_review_processor._include_num_reviewers(record) + assert record["num_reviewers"] == 2 + + +def test_can_include_avg_score_from_reviews() -> None: + """Test that an applicant's average score are added to an applicant with reviews.""" + record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "status": "REVIEWED", + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 10], + [datetime(2023, 1, 19), "edu.uci.alicia2", 0], + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 200], + ] + }, + } + + applicant_review_processor._include_avg_score(record) + assert record["avg_score"] == 150 diff --git a/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx b/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx new file mode 100644 index 00000000..eff5f099 --- /dev/null +++ b/apps/site/src/app/admin/applicants/ApplicantsSummary.tsx @@ -0,0 +1,3 @@ +export default function ApplicantsSummary() { + return

PLACEHOLDER: Various summary charts

; +} diff --git a/apps/site/src/app/admin/applicants/Applicants.tsx b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx similarity index 80% rename from apps/site/src/app/admin/applicants/Applicants.tsx rename to apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx index 283ee6c8..fb11748a 100644 --- a/apps/site/src/app/admin/applicants/Applicants.tsx +++ b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx @@ -10,15 +10,17 @@ import Header from "@cloudscape-design/components/header"; import Link from "@cloudscape-design/components/link"; import { useFollowWithNextLink } from "@/app/admin/layout/common"; -import useApplicants, { ApplicantSummary } from "@/lib/admin/useApplicants"; +import useHackerApplicants, { + HackerApplicantSummary, +} from "@/lib/admin/useHackerApplicants"; -import ApplicantFilters, { Options } from "./components/ApplicantFilters"; -import ApplicantStatus from "./components/ApplicantStatus"; +import ApplicantFilters, { Options } from "../components/ApplicantFilters"; +import ApplicantStatus from "../components/ApplicantStatus"; import UserContext from "@/lib/admin/UserContext"; import { isApplicationManager } from "@/lib/admin/authorization"; -function Applicants() { +function HackerApplicants() { const router = useRouter(); const { roles } = useContext(UserContext); @@ -29,7 +31,7 @@ function Applicants() { const [selectedStatuses, setSelectedStatuses] = useState([]); const [selectedDecisions, setSelectedDecisions] = useState([]); - const { applicantList, loading } = useApplicants(); + const { applicantList, loading } = useHackerApplicants(); const selectedStatusValues = selectedStatuses.map(({ value }) => value); const selectedDecisionValues = selectedDecisions.map(({ value }) => value); @@ -81,9 +83,14 @@ function Applicants() { content: ({ application_data }) => new Date(application_data.submission_time).toLocaleDateString(), }, + { + id: "avg_score", + header: "Averaged Score", + content: ({ avg_score }) => (avg_score === -1 ? "-" : avg_score), + }, { id: "decision", - header: "Decision", + header: "Decision (based on average score)", content: DecisionStatus, }, ], @@ -108,7 +115,7 @@ function Applicants() { ); } -const CardHeader = ({ _id, first_name, last_name }: ApplicantSummary) => { +const CardHeader = ({ _id, first_name, last_name }: HackerApplicantSummary) => { const followWithNextLink = useFollowWithNextLink(); return ( { ); }; -const DecisionStatus = ({ decision }: ApplicantSummary) => +const DecisionStatus = ({ decision }: HackerApplicantSummary) => decision ? : "-"; -export default Applicants; +export default HackerApplicants; diff --git a/apps/site/src/app/admin/applicants/hackers/page.tsx b/apps/site/src/app/admin/applicants/hackers/page.tsx new file mode 100644 index 00000000..3480dad7 --- /dev/null +++ b/apps/site/src/app/admin/applicants/hackers/page.tsx @@ -0,0 +1 @@ +export { default as default } from "./HackerApplicants"; diff --git a/apps/site/src/app/admin/applicants/page.tsx b/apps/site/src/app/admin/applicants/page.tsx index 350cac4a..c577b25d 100644 --- a/apps/site/src/app/admin/applicants/page.tsx +++ b/apps/site/src/app/admin/applicants/page.tsx @@ -1 +1 @@ -export { default as default } from "./Applicants"; +export { default as default } from "./ApplicantsSummary"; diff --git a/apps/site/src/lib/admin/useHackerApplicants.ts b/apps/site/src/lib/admin/useHackerApplicants.ts new file mode 100644 index 00000000..ab5f3e73 --- /dev/null +++ b/apps/site/src/lib/admin/useHackerApplicants.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import useSWR from "swr"; + +import { Decision, Status } from "@/lib/userRecord"; + +export interface HackerApplicantSummary { + _id: string; + first_name: string; + last_name: string; + status: Status; + decision: Decision | null; + num_reviewers: number; + avg_score: number; + application_data: { + school: string; + submission_time: string; + }; +} + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useHackerApplicants() { + const { data, error, isLoading } = useSWR( + "/api/admin/applicants/hackers", + fetcher, + ); + + return { applicantList: data || [], loading: isLoading, error }; +} + +export default useHackerApplicants;