Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Admin] New hacker review logic and indicator #526

Merged
merged 10 commits into from
Dec 29, 2024
14 changes: 7 additions & 7 deletions apps/api/src/admin/applicant_review_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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 _get_last_score(reviewer: str, reviews: list[tuple[str, str, float]]) -> float:
for review in reversed(reviews):
if review[1] == reviewer:
Expand Down Expand Up @@ -48,14 +54,8 @@ 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)
applicant_record["num_reviewers"] = get_num_unique_reviewers(applicant_record)


def _include_avg_score(applicant_record: dict[str, Any]) -> None:
Expand Down
84 changes: 77 additions & 7 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, HackerReview, OtherReview
from models.user_record import Applicant, ApplicantStatus, Role, Status
from services import mongodb_handler, sendgrid_handler
from services.mongodb_handler import BaseRecord, Collection
Expand Down Expand Up @@ -172,6 +172,47 @@ async def submit_review(
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)


@router.post("/review/hacker")
async def submit_hacker_review(
applicant: str = Body(),
score: float = Body(),
reviewer: User = Depends(require_role({Role.REVIEWER})),
) -> None:
"""Submit a review decision from the reviewer for the given hacker applicant."""
log.info("%s reviewed hacker %s", reviewer, applicant)

review: HackerReview = (utc_now(), reviewer.uid, score)

try:
await mongodb_handler.raw_update_one(
Collection.USERS,
{"_id": applicant},
{"$push": {"application_data.reviews": review}},
)
except RuntimeError:
log.error("Could not submit review for %s", applicant)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

applicant_record = await mongodb_handler.retrieve_one(
Collection.USERS,
{"_id": applicant},
["_id", "application_data.reviews"],
)
if not applicant_record:
log.error("Could not retrieve applicant after submitting review")
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

num_reviewers = applicant_review_processor.get_num_unique_reviewers(
applicant_record
)
if num_reviewers > 1:
await mongodb_handler.raw_update_one(
Collection.USERS,
{"_id": applicant},
{"$set": {"status": "REVIEWED"}},
)


@router.post("/release", dependencies=[Depends(require_director)])
async def release_decisions() -> None:
"""Update applicant status based on decision and send decision emails."""
Expand All @@ -184,14 +225,33 @@ 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)


@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 = 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"]
)

await _process_records_in_batches(records)


@router.post("/rsvp-reminder", dependencies=[Depends(require_director)])
async def rsvp_reminder() -> None:
Expand Down Expand Up @@ -345,6 +405,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}
Expand Down
83 changes: 82 additions & 1 deletion apps/api/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
from unittest.mock import ANY, AsyncMock, call, patch

from fastapi import FastAPI
Expand Down Expand Up @@ -251,7 +252,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",
Expand Down Expand Up @@ -297,3 +298,83 @@ def test_hacker_applicants_returns_correct_applicants(
},
},
]


@patch("services.mongodb_handler.raw_update_one", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
def test_submit_hacker_review_works(
mock_mongodb_handler_retrieve_one: AsyncMock,
mock_mongodb_handler_raw_update_one: AsyncMock,
) -> None:
"""Test that the /review/hacker route works"""
returned_record: dict[str, Any] = {
"_id": "edu.uci.sydnee",
"application_data": {
"reviews": [
[datetime(2023, 1, 19), "edu.uci.alicia", 100],
]
},
}

mock_mongodb_handler_retrieve_one.side_effect = [
REVIEWER_IDENTITY,
returned_record,
REVIEWER_IDENTITY,
returned_record,
]
mock_mongodb_handler_raw_update_one.side_effect = [True, True, True]

res = reviewer_client.post(
"/review/hacker", json={"applicant": "edu.uci.sydnee", "score": 0}
)

assert res.status_code == 200
assert mock_mongodb_handler_raw_update_one.await_count == 1

# Simulate 2 reviewers
returned_record["application_data"]["reviews"].append(
[datetime(2023, 1, 19), "edu.uci.alicia2", 200]
)
res = reviewer_client.post(
"/review/hacker", json={"applicant": "edu.uci.sydnee", "score": 0}
)

assert res.status_code == 200
assert mock_mongodb_handler_raw_update_one.await_count == 3


@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",
"application_data": {
"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
20 changes: 15 additions & 5 deletions apps/site/src/app/admin/applicants/[uid]/Applicant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import useApplicant from "@/lib/admin/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 };
Expand All @@ -18,7 +20,8 @@ interface ApplicantProps {
function Applicant({ params }: ApplicantProps) {
const { uid } = params;

const { applicant, loading, submitReview } = useApplicant(uid);
const { applicant, loading, submitReview, submitHackerReview } =
useApplicant(uid);

if (loading || !applicant) {
return (
Expand All @@ -37,10 +40,17 @@ function Applicant({ params }: ApplicantProps) {
variant="h1"
description="Applicant"
actions={
<ApplicantActions
applicant={applicant._id}
submitReview={submitReview}
/>
applicant.roles.includes(ParticipantRole.Hacker) ? (
<HackerApplicantActions
applicant={applicant._id}
submitHackerReview={submitHackerReview}
/>
) : (
<ApplicantActions
applicant={applicant._id}
submitReview={submitReview}
/>
)
}
>
{first_name} {last_name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ 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, HackerReview, Review } from "@/lib/admin/useApplicant";

import ApplicationReviews from "./ApplicationReviews";
import HackerApplicationReviews from "./HackerApplicationReviews";

function isHackerReviewArray(
arr: Review[] | HackerReview[],
): arr is HackerReview[] {
return arr.every((tuple) => typeof tuple[2] === "number");
}

interface ApplicantOverviewProps {
applicant: Applicant;
Expand All @@ -31,7 +38,11 @@ function ApplicantOverview({ applicant }: ApplicantOverviewProps) {
</div>
<div>
<Box variant="awsui-key-label">Reviews</Box>
<ApplicationReviews reviews={reviews} />
{isHackerReviewArray(reviews) ? (
<HackerApplicationReviews reviews={reviews} />
) : (
<ApplicationReviews reviews={reviews} />
)}
</div>
</ColumnLayout>
</Container>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useContext, useState } from "react";

import { Button, Input, SpaceBetween } from "@cloudscape-design/components";
import { submitHackerReview } from "@/lib/admin/useApplicant";
import { Uid } from "@/lib/userRecord";
import UserContext from "@/lib/admin/UserContext";
import { isReviewer } from "@/lib/admin/authorization";

interface ApplicantActionsProps {
applicant: Uid;
submitHackerReview: submitHackerReview;
}

function HackerApplicantActions({
applicant,
submitHackerReview,
}: ApplicantActionsProps) {
const { roles } = useContext(UserContext);
const [value, setValue] = useState("");

if (!isReviewer(roles)) {
return null;
}

return (
<SpaceBetween direction="horizontal" size="xs">
<Input
onChange={({ detail }) => setValue(detail.value)}
value={value}
type="number"
inputMode="decimal"
placeholder="Applicant score"
step={0.1}
/>
<Button
onClick={() => {
submitHackerReview(applicant, value);
setValue("");
}}
>
Submit
</Button>
</SpaceBetween>
);
}

export default HackerApplicantActions;
Loading
Loading