diff --git a/apps/api/src/admin/applicant_review_processor.py b/apps/api/src/admin/applicant_review_processor.py index f445fa00..fff5293a 100644 --- a/apps/api/src/admin/applicant_review_processor.py +++ b/apps/api/src/admin/applicant_review_processor.py @@ -9,7 +9,7 @@ def include_hacker_app_fields( _include_decision_based_on_threshold( applicant_record, accept_threshold, waitlist_threshold ) - _include_num_reviewers(applicant_record) + _include_reviewers(applicant_record) _include_avg_score(applicant_record) @@ -19,6 +19,12 @@ def include_review_decision(applicant_record: dict[str, Any]) -> None: applicant_record["decision"] = reviews[-1][2] if reviews else None +def get_unique_reviewers(applicant_record: dict[str, Any]) -> set[str]: + reviews = applicant_record["application_data"]["reviews"] + unique_reviewers = {t[1] for t in reviews} + return unique_reviewers + + def _get_last_score(reviewer: str, reviews: list[tuple[str, str, float]]) -> float: for review in reversed(reviews): if review[1] == reviewer: @@ -48,14 +54,9 @@ def _include_decision_based_on_threshold( 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_reviewers(applicant_record: dict[str, Any]) -> None: + unique_reviewers = get_unique_reviewers(applicant_record) + applicant_record["reviewers"] = sorted(list(unique_reviewers)) def _include_avg_score(applicant_record: dict[str, Any]) -> None: diff --git a/apps/api/src/models/ApplicationData.py b/apps/api/src/models/ApplicationData.py index a9c54120..051c1cc0 100644 --- a/apps/api/src/models/ApplicationData.py +++ b/apps/api/src/models/ApplicationData.py @@ -22,8 +22,7 @@ class Decision(str, Enum): REJECTED = "REJECTED" -HackerReview = tuple[datetime, str, float] -OtherReview = tuple[datetime, str, Decision] +Review = tuple[datetime, str, float] def make_empty_none(val: Union[str, None]) -> Union[str, None]: @@ -140,7 +139,7 @@ class ProcessedHackerApplicationData(BaseApplicationData): email: EmailStr resume_url: Union[HttpUrl, None] = None submission_time: datetime - reviews: list[HackerReview] = [] + reviews: list[Review] = [] @field_serializer("linkedin", "portfolio", "resume_url") def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]: @@ -153,7 +152,7 @@ class ProcessedMentorApplicationData(BaseMentorApplicationData): email: EmailStr resume_url: Union[HttpUrl, None] = None submission_time: datetime - reviews: list[OtherReview] = [] + reviews: list[Review] = [] @field_serializer("linkedin", "github", "portfolio", "resume_url") def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]: @@ -166,7 +165,7 @@ class ProcessedVolunteerApplication(BaseVolunteerApplicationData): # TODO: specify common attributes in mixin email: EmailStr submission_time: datetime - reviews: list[OtherReview] = [] + reviews: list[Review] = [] # 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 239b38eb..0625e160 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -1,7 +1,7 @@ import asyncio from datetime import datetime from logging import getLogger -from typing import Annotated, Any, Optional, Sequence +from typing import Annotated, Any, Mapping, Optional, Sequence from fastapi import APIRouter, Body, Depends, HTTPException, status from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError @@ -11,7 +11,7 @@ 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, OtherReview +from models.ApplicationData import Decision, Review from models.user_record import Applicant, ApplicantStatus, Role, Status from services import mongodb_handler, sendgrid_handler from services.mongodb_handler import BaseRecord, Collection @@ -48,11 +48,16 @@ class HackerApplicantSummary(BaseRecord): last_name: str status: str decision: Optional[Decision] = None - num_reviewers: int + reviewers: list[str] = [] avg_score: float application_data: ApplicationDataSummary +class ReviewRequest(BaseModel): + applicant: str + score: float + + @router.get("/applicants") async def applicants( user: Annotated[User, Depends(require_manager)] @@ -104,9 +109,7 @@ async def hacker_applicants( ], ) - thresholds: Optional[dict[str, float]] = await mongodb_handler.retrieve_one( - Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"] - ) + thresholds: Optional[dict[str, float]] = await _retrieve_thresholds() if not thresholds: log.error("Could not retrieve thresholds") @@ -149,27 +152,62 @@ async def applicant_summary() -> dict[ApplicantStatus, int]: @router.post("/review") async def submit_review( - applicant: str = Body(), - decision: Decision = Body(), + applicant_review: ReviewRequest, reviewer: User = Depends(require_role({Role.REVIEWER})), ) -> None: - """Submit a review decision from the reviewer for the given applicant.""" - log.info("%s reviewed applicant %s", reviewer, applicant) + """Submit a review decision from the reviewer for the given hacker applicant.""" + log.info("%s reviewed hacker %s", reviewer, applicant_review.applicant) - review: OtherReview = (utc_now(), reviewer.uid, decision) + review: Review = (utc_now(), reviewer.uid, applicant_review.score) + app = applicant_review.applicant - try: - await mongodb_handler.raw_update_one( - Collection.USERS, - {"_id": applicant}, - { + applicant_record = await mongodb_handler.retrieve_one( + Collection.USERS, + {"_id": applicant_review.applicant}, + ["_id", "application_data.reviews", "roles"], + ) + if not applicant_record: + log.error("Could not retrieve applicant after submitting review") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + + if Role.HACKER in applicant_record["roles"]: + unique_reviewers = applicant_review_processor.get_unique_reviewers( + applicant_record + ) + + # Only add a review if there are either less than 2 reviewers + # or reviewer is one of the reviewers + if len(unique_reviewers) >= 2 and reviewer.uid not in unique_reviewers: + log.error( + "%s tried to submit a review, but %s already has two reviewers", + reviewer, + app, + ) + raise HTTPException(status.HTTP_403_FORBIDDEN) + + update_query: dict[str, object] = { + "$push": {"application_data.reviews": review} + } + # Because reviewing a hacker requires 2 reviewers, only set the + # applicant's status to REVIEWED if there are at least 2 reviewers + if len(unique_reviewers | {reviewer.uid}) >= 2: + update_query.update({"$set": {"status": "REVIEWED"}}) + + await _try_update_applicant_with_query( + applicant_review, + update_query=update_query, + err_msg=f"{reviewer} could not submit review for {app}", + ) + + else: + await _try_update_applicant_with_query( + applicant_review, + update_query={ "$push": {"application_data.reviews": review}, "$set": {"status": "REVIEWED"}, }, + err_msg=f"{reviewer} could not submit review for {app}", ) - except RuntimeError: - log.error("Could not submit review for %s", applicant) - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) @router.post("/release", dependencies=[Depends(require_director)]) @@ -184,14 +222,32 @@ async def release_decisions() -> None: for record in records: 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] - if not group: - continue - await asyncio.gather( - *(_process_batch(batch, decision) for batch in batched(group, 100)) + await _process_records_in_batches(records) + + +# TODO: need to make release hackers check roles as part of query +@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}, + ["_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) + @router.post("/rsvp-reminder", dependencies=[Depends(require_director)]) async def rsvp_reminder() -> None: @@ -345,6 +401,16 @@ async def subevent_checkin( await participant_manager.subevent_checkin(event, uid, organizer) +async def _process_records_in_batches(records: list[dict[str, object]]) -> 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) 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} @@ -382,3 +448,26 @@ def _recover_email_from_uid(uid: str) -> str: local = local.replace("\n", ".") domain = ".".join(reversed(reversed_domain)) 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, + *, + update_query: Mapping[str, object], + err_msg: str = "", +) -> None: + try: + await mongodb_handler.raw_update_one( + Collection.USERS, + {"_id": applicant_review.applicant}, + update_query, + ) + except RuntimeError: + log.error(err_msg) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apps/api/tests/test_admin.py b/apps/api/tests/test_admin.py index cc15d497..b6c219fe 100644 --- a/apps/api/tests/test_admin.py +++ b/apps/api/tests/test_admin.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from unittest.mock import ANY, AsyncMock, call, patch from fastapi import FastAPI @@ -111,31 +112,145 @@ def test_can_retrieve_applicants( @patch("services.mongodb_handler.raw_update_one", autospec=True) @patch("services.mongodb_handler.retrieve_one", autospec=True) -def test_can_submit_review( +def test_can_submit_nonhacker_review( mock_mongodb_handler_retrieve_one: AsyncMock, mock_mongodb_handler_raw_update_one: AsyncMock, ) -> None: - """Test that a user can properly submit an applicant review.""" + """Test that a user can properly submit a nonhacker applicant review.""" + post_data = {"applicant": "edu.uci.sydnee", "score": 0} + + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Mentor"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + ] + }, + } - mock_mongodb_handler_retrieve_one.return_value = REVIEWER_IDENTITY + mock_mongodb_handler_retrieve_one.side_effect = [ + REVIEWER_IDENTITY, + returned_record, + ] + mock_mongodb_handler_raw_update_one.return_value = True - res = reviewer_client.post( - "/review", - json={"applicant": "edu.uci.applicant", "decision": Decision.ACCEPTED}, + res = reviewer_client.post("/review", json=post_data) + + assert res.status_code == 200 + mock_mongodb_handler_raw_update_one.assert_awaited_once_with( + Collection.USERS, + {"_id": "edu.uci.sydnee"}, + { + "$push": {"application_data.reviews": (ANY, "edu.uci.alicia", 0)}, + "$set": {"status": "REVIEWED"}, + }, ) - mock_mongodb_handler_retrieve_one.assert_awaited_once() + +@patch("services.mongodb_handler.raw_update_one", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_submit_hacker_review_with_one_reviewer_works( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_raw_update_one: AsyncMock, +) -> None: + """Test that a user can properly submit a hacker applicant review.""" + post_data = {"applicant": "edu.uci.sydnee", "score": 0} + + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Hacker"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia2", 100], + ] + }, + } + + mock_mongodb_handler_retrieve_one.side_effect = [REVIEWER_IDENTITY, returned_record] + mock_mongodb_handler_raw_update_one.return_value = True + + res = reviewer_client.post("/review", json=post_data) + + assert res.status_code == 200 mock_mongodb_handler_raw_update_one.assert_awaited_once_with( Collection.USERS, - {"_id": "edu.uci.applicant"}, + {"_id": "edu.uci.sydnee"}, { - "$push": { - "application_data.reviews": (ANY, "edu.uci.alicia", Decision.ACCEPTED) - }, + "$push": {"application_data.reviews": (ANY, "edu.uci.alicia", 0)}, "$set": {"status": "REVIEWED"}, }, ) + + +@patch("services.mongodb_handler.raw_update_one", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_submit_hacker_review_with_two_reviewers_works( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_raw_update_one: AsyncMock, +) -> None: + """Test that a user can submit a hacker applicant review with 2 reviewers.""" + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Hacker"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 100], + ] + }, + } + + mock_mongodb_handler_retrieve_one.side_effect = [ + REVIEWER_IDENTITY, + returned_record, + ] + mock_mongodb_handler_raw_update_one.return_value = True + + res = reviewer_client.post( + "/review", json={"applicant": "edu.uci.sydnee", "score": 0} + ) + assert res.status_code == 200 + mock_mongodb_handler_raw_update_one.assert_awaited_once_with( + Collection.USERS, + {"_id": "edu.uci.sydnee"}, + { + "$push": {"application_data.reviews": (ANY, "edu.uci.alicia", 0)}, + "$set": {"status": "REVIEWED"}, + }, + ) + + +@patch("services.mongodb_handler.raw_update_one", autospec=True) +@patch("services.mongodb_handler.retrieve_one", autospec=True) +def test_submit_hacker_review_with_three_reviewers_fails( + mock_mongodb_handler_retrieve_one: AsyncMock, + mock_mongodb_handler_raw_update_one: AsyncMock, +) -> None: + """Test that a hacker applicant review with 3 reviewers fails.""" + returned_record: dict[str, Any] = { + "_id": "edu.uci.sydnee", + "roles": ["Applicant", "Hacker"], + "application_data": { + "reviews": [ + [datetime(2023, 1, 19), "edu.uci.alicia3", 100], + [datetime(2023, 1, 19), "edu.uci.alicia2", 100], + ] + }, + } + + mock_mongodb_handler_retrieve_one.side_effect = [ + REVIEWER_IDENTITY, + returned_record, + ] + mock_mongodb_handler_raw_update_one.return_value = True + + res = reviewer_client.post( + "/review", json={"applicant": "edu.uci.sydnee", "score": 0} + ) + + assert res.status_code == 403 @patch("services.mongodb_handler.update", autospec=True) @@ -174,6 +289,7 @@ def test_confirm_attendance_route( 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( [ @@ -195,8 +311,6 @@ def test_confirm_attendance_route( ] ) - assert res.status_code == 200 - @patch("services.sendgrid_handler.send_email", autospec=True) @patch("services.mongodb_handler.update_one", autospec=True) @@ -251,7 +365,7 @@ 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""" + """Test that the /applicants/hackers route returns correctly""" returned_records: list[dict[str, object]] = [ { "_id": "edu.uci.sydnee", @@ -269,6 +383,22 @@ def test_hacker_applicants_returns_correct_applicants( } ] + expected_records = [ + { + "_id": "edu.uci.sydnee", + "first_name": "sydnee", + "last_name": "unknown", + "status": "REVIEWED", + "decision": "ACCEPTED", + "avg_score": 150, + "reviewers": ["edu.uci.alicia", "edu.uci.alicia2"], + "application_data": { + "school": "Hamburger University", + "submission_time": "2023-01-12T09:00:00", + }, + }, + ] + returned_thresholds: dict[str, object] = {"accept": 12, "waitlist": 5} mock_mongodb_handler_retrieve.return_value = returned_records @@ -282,18 +412,41 @@ def test_hacker_applicants_returns_correct_applicants( assert res.status_code == 200 mock_mongodb_handler_retrieve.assert_awaited_once() data = res.json() - assert data == [ + 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", - "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", + "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 diff --git a/apps/api/tests/test_applicant_review_processor.py b/apps/api/tests/test_applicant_review_processor.py index 25c9afe3..c8eea849 100644 --- a/apps/api/tests/test_applicant_review_processor.py +++ b/apps/api/tests/test_applicant_review_processor.py @@ -33,7 +33,7 @@ def test_can_include_decision_from_reviews() -> None: assert record["decision"] == "ACCEPTED" -def test_can_include_num_reviewers_from_reviews() -> None: +def test_can_include_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", @@ -46,8 +46,10 @@ def test_can_include_num_reviewers_from_reviews() -> None: }, } - applicant_review_processor._include_num_reviewers(record) - assert record["num_reviewers"] == 2 + expected_reviewers = ["edu.uci.alicia", "edu.uci.alicia2"] + + applicant_review_processor._include_reviewers(record) + assert record["reviewers"] == expected_reviewers def test_can_include_avg_score_from_reviews() -> None: diff --git a/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx b/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx index 866ed7ee..aa4a6754 100644 --- a/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/Applicant.tsx @@ -5,11 +5,13 @@ import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Spinner from "@cloudscape-design/components/spinner"; -import useApplicant from "@/lib/admin/useApplicant"; +import useApplicant from "@/app/admin/applicants/hackers/useApplicant"; import ApplicantActions from "./components/ApplicantActions"; import ApplicantOverview from "./components/ApplicantOverview"; import Application from "./components/Application"; +import HackerApplicantActions from "./components/HackerApplicantActions"; +import { ParticipantRole } from "@/lib/userRecord"; interface ApplicantProps { params: { uid: string }; @@ -28,7 +30,7 @@ function Applicant({ params }: ApplicantProps) { ); } - const { first_name, last_name } = applicant; + const { first_name, last_name, application_data } = applicant; return ( + applicant.roles.includes(ParticipantRole.Hacker) ? ( + + ) : ( + + ) } > {first_name} {last_name} diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx b/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx index 08d62e01..effad948 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/components/ApplicantActions.tsx @@ -5,9 +5,10 @@ import ButtonDropdown, { } from "@cloudscape-design/components/button-dropdown"; import { isReviewer } from "@/lib/admin/authorization"; -import { submitReview } from "@/lib/admin/useApplicant"; +import { submitReview } from "@/app/admin/applicants/hackers/useApplicant"; import UserContext from "@/lib/admin/UserContext"; import { Decision, Uid } from "@/lib/userRecord"; +import { decisionsToScores } from "@/lib/decisionScores"; interface ApplicantActionsProps { applicant: Uid; @@ -30,8 +31,8 @@ function ApplicantActions({ applicant, submitReview }: ApplicantActionsProps) { const handleClick = ( event: CustomEvent, ) => { - const review = event.detail.id; - submitReview(applicant, review as Decision); + const review = event.detail.id as Decision; + submitReview(applicant, decisionsToScores[review]); }; const dropdownItems: ReviewButtonItems = [ diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx b/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx index 94aa7d64..97eae362 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/components/ApplicantOverview.tsx @@ -4,9 +4,10 @@ import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; -import { Applicant } from "@/lib/admin/useApplicant"; +import { Applicant } from "@/app/admin/applicants/hackers/useApplicant"; import ApplicationReviews from "./ApplicationReviews"; +import { ParticipantRole } from "@/lib/userRecord"; interface ApplicantOverviewProps { applicant: Applicant; @@ -31,7 +32,10 @@ function ApplicantOverview({ applicant }: ApplicantOverviewProps) {
Reviews - +
diff --git a/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx b/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx index 15b9a797..e01f6ccd 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/components/Application.tsx @@ -2,7 +2,10 @@ import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; -import { Applicant, ApplicationQuestion } from "@/lib/admin/useApplicant"; +import { + Applicant, + ApplicationQuestion, +} from "@/app/admin/applicants/hackers/useApplicant"; import ApplicationSection from "./ApplicationSection"; diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx b/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx index 820ac909..e9488971 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/components/ApplicationReviews.tsx @@ -1,15 +1,17 @@ import { useContext } from "react"; import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; -import { Review } from "@/lib/admin/useApplicant"; +import { Review } from "@/app/admin/applicants/hackers/useApplicant"; import UserContext from "@/lib/admin/UserContext"; -import { Uid } from "@/lib/userRecord"; +import { Status, Uid } from "@/lib/userRecord"; +import { scoresToDecisions } from "@/lib/decisionScores"; interface ApplicationReviewsProps { reviews: Review[]; + isHacker: boolean; } -function ApplicationReviews({ reviews }: ApplicationReviewsProps) { +function ApplicationReviews({ reviews, isHacker }: ApplicationReviewsProps) { const { uid } = useContext(UserContext); if (reviews.length === 0) { @@ -22,13 +24,20 @@ function ApplicationReviews({ reviews }: ApplicationReviewsProps) { return (
    - {reviews.map(([date, reviewer, decision]) => + {reviews.map(([date, reviewer, score]) => reviewer === uid ? (
  • - <> - You reviewed as on{" "} - {formatDate(date)} - + {isHacker ? ( + <> + You scored this applicant a {score} on {formatDate(date)} + + ) : ( + <> + You reviewed as{" "} + {" "} + on {formatDate(date)} + + )}
  • ) : (
  • diff --git a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx b/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx index f4b52845..094417d8 100644 --- a/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx +++ b/apps/site/src/app/admin/applicants/[uid]/components/ApplicationSection.tsx @@ -1,7 +1,10 @@ import ColumnLayout from "@cloudscape-design/components/column-layout"; import TextContent from "@cloudscape-design/components/text-content"; -import { ApplicationData, ApplicationQuestion } from "@/lib/admin/useApplicant"; +import { + ApplicationData, + ApplicationQuestion, +} from "@/app/admin/applicants/hackers/useApplicant"; interface ApplicationResponseProps { value: string | boolean | string[] | null; diff --git a/apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx b/apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx new file mode 100644 index 00000000..8c3accec --- /dev/null +++ b/apps/site/src/app/admin/applicants/[uid]/components/HackerApplicantActions.tsx @@ -0,0 +1,90 @@ +import { useContext, useState } from "react"; + +import Box from "@cloudscape-design/components/box"; +import Button from "@cloudscape-design/components/button"; +import Input from "@cloudscape-design/components/input"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { + Review, + submitReview, +} from "@/app/admin/applicants/hackers/useApplicant"; +import { Uid } from "@/lib/userRecord"; +import UserContext from "@/lib/admin/UserContext"; +import { isReviewer } from "@/lib/admin/authorization"; + +interface ColoredTextBoxProps { + text: string | undefined; +} + +const ColoredTextBox = ({ text }: ColoredTextBoxProps) => { + return ( + + {text} + + ); +}; + +interface ApplicantActionsProps { + applicant: Uid; + reviews: Review[]; + submitReview: submitReview; +} + +function HackerApplicantActions({ + applicant, + reviews, + submitReview, +}: ApplicantActionsProps) { + const { uid, roles } = useContext(UserContext); + const [value, setValue] = useState(""); + + const uniqueReviewers = Array.from( + new Set(reviews.map((review) => review[1])), + ); + + // const canReview = either there are less than 2 reviewers or uid is in current reviews + const canReview = uid + ? uniqueReviewers.length < 2 || uniqueReviewers.includes(uid) + : false; + + if (!isReviewer(roles)) { + return null; + } + + const handleClick = () => { + // TODO: use flashbar or modal for submit status + submitReview(applicant, parseFloat(value)); + setValue(""); + }; + + return canReview ? ( + + setValue(detail.value)} + value={value} + type="number" + inputMode="decimal" + placeholder="Applicant score" + step={0.5} + disabled={!canReview} + /> + + + ) : ( + + + and{" "} + already + submitted reviews. + + + Contact Rosalind, Nicole, or Albert if you think this is an error. + + + ); +} + +export default HackerApplicantActions; diff --git a/apps/site/src/app/admin/applicants/components/ApplicantReviewerIndicator.tsx b/apps/site/src/app/admin/applicants/components/ApplicantReviewerIndicator.tsx new file mode 100644 index 00000000..f6d35130 --- /dev/null +++ b/apps/site/src/app/admin/applicants/components/ApplicantReviewerIndicator.tsx @@ -0,0 +1,51 @@ +import { Uid } from "@/lib/userRecord"; +import Box from "@cloudscape-design/components/box"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import StatusIndicator from "@cloudscape-design/components/status-indicator"; + +interface IndicatorContainerProps { + displayNumber: number; + reviewer: string | undefined; +} + +const IndicatorContainer = ({ + displayNumber, + reviewer, +}: IndicatorContainerProps) => { + return ( + <> + + Reviewer {displayNumber}: {reviewer} + + {reviewer ? ( + Reviewed + ) : ( + Not Reviewed + )} + + ); +}; + +interface ApplicantReviewerIndicatorProps { + reviewers: ReadonlyArray; +} + +function ApplicantReviewerIndicator({ + reviewers, +}: ApplicantReviewerIndicatorProps) { + const formatUid = (uid: Uid) => uid.split(".").at(-1); + + return ( + + {[0, 1].map((n) => ( + + ))} + + ); +} + +export default ApplicantReviewerIndicator; diff --git a/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx b/apps/site/src/app/admin/applicants/hackers/HackerApplicants.tsx index fb11748a..0d909485 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 ApplicantReviewerIndicator from "../components/ApplicantReviewerIndicator"; function HackerApplicants() { const router = useRouter(); @@ -77,6 +78,11 @@ function HackerApplicants() { header: "Status", content: ApplicantStatus, }, + { + id: "reviewers", + header: "", + content: ApplicantReviewerIndicator, + }, { id: "submission_time", header: "Applied", @@ -90,7 +96,7 @@ function HackerApplicants() { }, { id: "decision", - header: "Decision (based on average score)", + header: "Decision", content: DecisionStatus, }, ], diff --git a/apps/site/src/lib/admin/useApplicant.ts b/apps/site/src/app/admin/applicants/hackers/useApplicant.ts similarity index 74% rename from apps/site/src/lib/admin/useApplicant.ts rename to apps/site/src/app/admin/applicants/hackers/useApplicant.ts index 8e4fd460..4ee1646b 100644 --- a/apps/site/src/lib/admin/useApplicant.ts +++ b/apps/site/src/app/admin/applicants/hackers/useApplicant.ts @@ -1,9 +1,9 @@ import axios from "axios"; import useSWR from "swr"; -import { Decision, ParticipantRole, Status, Uid } from "@/lib/userRecord"; +import { ParticipantRole, Status, Uid, Score } from "@/lib/userRecord"; -export type Review = [string, Uid, Decision]; +export type Review = [string, Uid, Score]; // The application responses submitted by an applicant export interface ApplicationData { @@ -50,15 +50,20 @@ function useApplicant(uid: Uid) { [string, Uid] >(["/api/admin/applicant/", uid], fetcher); - async function submitReview(uid: Uid, review: Decision) { - await axios.post("/api/admin/review", { applicant: uid, decision: review }); + async function submitReview(uid: Uid, score: number) { + await axios.post("/api/admin/review", { applicant: uid, score: score }); // TODO: provide success status to display in alert mutate(); } - return { applicant: data, loading: isLoading, error, submitReview }; + return { + applicant: data, + loading: isLoading, + error, + submitReview, + }; } -export type submitReview = (uid: Uid, review: Decision) => Promise; +export type submitReview = (uid: Uid, score: number) => Promise; export default useApplicant; diff --git a/apps/site/src/lib/admin/useHackerApplicants.ts b/apps/site/src/lib/admin/useHackerApplicants.ts index ab5f3e73..b20db1ff 100644 --- a/apps/site/src/lib/admin/useHackerApplicants.ts +++ b/apps/site/src/lib/admin/useHackerApplicants.ts @@ -9,7 +9,7 @@ export interface HackerApplicantSummary { last_name: string; status: Status; decision: Decision | null; - num_reviewers: number; + reviewers: ReadonlyArray; avg_score: number; application_data: { school: string; diff --git a/apps/site/src/lib/decisionScores.ts b/apps/site/src/lib/decisionScores.ts new file mode 100644 index 00000000..a2b03f2e --- /dev/null +++ b/apps/site/src/lib/decisionScores.ts @@ -0,0 +1,18 @@ +import { Decision } from "./userRecord"; + +/** For use in nonhacker applicant reviews.*/ +const acceptScore = 100; +const waitlistScore = -2; +const rejectScore = 0; + +export const decisionsToScores: Record = { + [Decision.accepted]: acceptScore, + [Decision.waitlisted]: waitlistScore, + [Decision.rejected]: rejectScore, +}; + +export const scoresToDecisions: Record = { + [acceptScore]: Decision.accepted, + [waitlistScore]: Decision.waitlisted, + [rejectScore]: Decision.rejected, +}; diff --git a/apps/site/src/lib/userRecord.ts b/apps/site/src/lib/userRecord.ts index bd1420ca..fa7c8686 100644 --- a/apps/site/src/lib/userRecord.ts +++ b/apps/site/src/lib/userRecord.ts @@ -1,11 +1,15 @@ /** Represents a UID of a user record, just an alias for string. */ export type Uid = string; +/** Represents score for a hacker applicant, just an alias for number */ +export type Score = number; + // Note: role labels should match `user_record.Role` in the API /** The possible roles of general participants. */ export enum ParticipantRole { Applicant = "Applicant", + Hacker = "Hacker", Mentor = "Mentor", Volunteer = "Volunteer", Sponsor = "Sponsor",