Skip to content

Commit

Permalink
[Admin] Hacker Applicants Overview (#514)
Browse files Browse the repository at this point in the history
* Added route for getting hacker applicants

* Added hacker applicants overview

* -Moved admin functions to applicant_review_processor.py
-Changed route to /applicants/hackers
-Updated test_admin

* Explicitly imported applicant_review_processor functions in test_admin

* - scoped usage of  functions from applicant_review_processor.py
- renamed applicant_review_processor function names
- moved applicant_review_processor unit tests to new file

* - Added placeholder page at /applicants
- Removed _omit_reviews
- Reverted _process_batch
- Moved all applicant_review_processor tests

* Merged main into current branch (squash):

commit ab1d081
Author: Taesung Hwang <[email protected]>
Date:   Sun Dec 22 21:37:33 2024 -0800

    Refactor user roles from admin site to main site (#519)

    - Create new enums for user roles for both the admin site and main site
    - Move status enums from `useApplicant` to the new `userRecord.ts`
    - Move `isNonHacker` check to `ParticipantAction`
    - The Portal still needs to be updated to use the new common enums

commit c8e1d0c
Author: Noah Kim <[email protected]>
Date:   Sun Dec 22 20:18:15 2024 -0800

    Remove Three.js (#498)

    * update dependencies

    * Remove other unnecessary dependencies and directories

    * Minor refactor

* Changed enum for useHackerApplicants.ts

* Revert "Changed enum for useHackerApplicants.ts"

This reverts commit 0d3d887.

* Revert "Merged main into current branch (squash):"

This reverts commit 92eaa86.

* deleted Applicants.tsx

* Changed enum for useHackerApplicants.ts
  • Loading branch information
IanWearsHat authored Dec 23, 2024
1 parent ab1d081 commit 0b81969
Show file tree
Hide file tree
Showing 10 changed files with 303 additions and 54 deletions.
64 changes: 64 additions & 0 deletions apps/api/src/admin/applicant_review_processor.py
Original file line number Diff line number Diff line change
@@ -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"]
)
9 changes: 5 additions & 4 deletions apps/api/src/models/ApplicationData.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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]:
Expand All @@ -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]:
Expand All @@ -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
Expand Down
68 changes: 56 additions & 12 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,14 +36,23 @@ class ApplicationDataSummary(BaseModel):


class ApplicantSummary(BaseRecord):
uid: str = Field(alias="_id")
first_name: str
last_name: str
status: str
decision: Optional[Decision] = None
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)]
Expand All @@ -65,14 +75,54 @@ 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)
except ValidationError:
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,
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down Expand Up @@ -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
82 changes: 54 additions & 28 deletions apps/api/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
},
},
]
69 changes: 69 additions & 0 deletions apps/api/tests/test_applicant_review_processor.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/site/src/app/admin/applicants/ApplicantsSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ApplicantsSummary() {
return <p>PLACEHOLDER: Various summary charts</p>;
}
Loading

0 comments on commit 0b81969

Please sign in to comment.