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) {
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",