Skip to content

Commit 07ee7fd

Browse files
authored
Merge branch 'main' into feature/applicants-summary
2 parents c17d6b4 + 79a9a04 commit 07ee7fd

27 files changed

+579
-116
lines changed

apps/api/src/admin/applicant_review_processor.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def include_hacker_app_fields(
99
_include_decision_based_on_threshold(
1010
applicant_record, accept_threshold, waitlist_threshold
1111
)
12-
_include_num_reviewers(applicant_record)
12+
_include_reviewers(applicant_record)
1313
_include_avg_score(applicant_record)
1414

1515

@@ -19,6 +19,12 @@ def include_review_decision(applicant_record: dict[str, Any]) -> None:
1919
applicant_record["decision"] = reviews[-1][2] if reviews else None
2020

2121

22+
def get_unique_reviewers(applicant_record: dict[str, Any]) -> set[str]:
23+
reviews = applicant_record["application_data"]["reviews"]
24+
unique_reviewers = {t[1] for t in reviews}
25+
return unique_reviewers
26+
27+
2228
def _get_last_score(reviewer: str, reviews: list[tuple[str, str, float]]) -> float:
2329
for review in reversed(reviews):
2430
if review[1] == reviewer:
@@ -48,14 +54,9 @@ def _include_decision_based_on_threshold(
4854
applicant_record["decision"] = Decision.REJECTED
4955

5056

51-
def _get_num_unique_reviewers(applicant_record: dict[str, Any]) -> int:
52-
reviews = applicant_record["application_data"]["reviews"]
53-
unique_reviewers = {t[1] for t in reviews}
54-
return len(unique_reviewers)
55-
56-
57-
def _include_num_reviewers(applicant_record: dict[str, Any]) -> None:
58-
applicant_record["num_reviewers"] = _get_num_unique_reviewers(applicant_record)
57+
def _include_reviewers(applicant_record: dict[str, Any]) -> None:
58+
unique_reviewers = get_unique_reviewers(applicant_record)
59+
applicant_record["reviewers"] = sorted(list(unique_reviewers))
5960

6061

6162
def _include_avg_score(applicant_record: dict[str, Any]) -> None:

apps/api/src/models/ApplicationData.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ class Decision(str, Enum):
2222
REJECTED = "REJECTED"
2323

2424

25-
HackerReview = tuple[datetime, str, float]
26-
OtherReview = tuple[datetime, str, Decision]
25+
Review = tuple[datetime, str, float]
2726

2827

2928
def make_empty_none(val: Union[str, None]) -> Union[str, None]:
@@ -140,7 +139,7 @@ class ProcessedHackerApplicationData(BaseApplicationData):
140139
email: EmailStr
141140
resume_url: Union[HttpUrl, None] = None
142141
submission_time: datetime
143-
reviews: list[HackerReview] = []
142+
reviews: list[Review] = []
144143

145144
@field_serializer("linkedin", "portfolio", "resume_url")
146145
def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]:
@@ -153,7 +152,7 @@ class ProcessedMentorApplicationData(BaseMentorApplicationData):
153152
email: EmailStr
154153
resume_url: Union[HttpUrl, None] = None
155154
submission_time: datetime
156-
reviews: list[OtherReview] = []
155+
reviews: list[Review] = []
157156

158157
@field_serializer("linkedin", "github", "portfolio", "resume_url")
159158
def url2str(self, val: Union[HttpUrl, None]) -> Union[str, None]:
@@ -166,7 +165,7 @@ class ProcessedVolunteerApplication(BaseVolunteerApplicationData):
166165
# TODO: specify common attributes in mixin
167166
email: EmailStr
168167
submission_time: datetime
169-
reviews: list[OtherReview] = []
168+
reviews: list[Review] = []
170169

171170

172171
# To add more discriminating values, add a string

apps/api/src/routers/admin.py

+114-25
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
from datetime import date, datetime
33
from logging import getLogger
4-
from typing import Annotated, Any, Literal, Optional, Sequence
4+
from typing import Annotated, Any, Literal, Mapping, Optional, Sequence
55

66
from fastapi import APIRouter, Body, Depends, HTTPException, status
77
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
@@ -11,7 +11,7 @@
1111
from admin.participant_manager import Participant
1212
from auth.authorization import require_role
1313
from auth.user_identity import User, utc_now
14-
from models.ApplicationData import Decision, OtherReview
14+
from models.ApplicationData import Decision, Review
1515
from models.user_record import Applicant, ApplicantStatus, Role, Status
1616
from services import mongodb_handler, sendgrid_handler
1717
from services.mongodb_handler import BaseRecord, Collection
@@ -48,11 +48,16 @@ class HackerApplicantSummary(BaseRecord):
4848
last_name: str
4949
status: str
5050
decision: Optional[Decision] = None
51-
num_reviewers: int
51+
reviewers: list[str] = []
5252
avg_score: float
5353
application_data: ApplicationDataSummary
5454

5555

56+
class ReviewRequest(BaseModel):
57+
applicant: str
58+
score: float
59+
60+
5661
@router.get("/applicants")
5762
async def applicants(
5863
user: Annotated[User, Depends(require_manager)]
@@ -104,9 +109,7 @@ async def hacker_applicants(
104109
],
105110
)
106111

107-
thresholds: Optional[dict[str, float]] = await mongodb_handler.retrieve_one(
108-
Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"]
109-
)
112+
thresholds: Optional[dict[str, float]] = await _retrieve_thresholds()
110113

111114
if not thresholds:
112115
log.error("Could not retrieve thresholds")
@@ -164,27 +167,62 @@ async def applications(
164167

165168
@router.post("/review")
166169
async def submit_review(
167-
applicant: str = Body(),
168-
decision: Decision = Body(),
170+
applicant_review: ReviewRequest,
169171
reviewer: User = Depends(require_role({Role.REVIEWER})),
170172
) -> None:
171-
"""Submit a review decision from the reviewer for the given applicant."""
172-
log.info("%s reviewed applicant %s", reviewer, applicant)
173+
"""Submit a review decision from the reviewer for the given hacker applicant."""
174+
log.info("%s reviewed hacker %s", reviewer, applicant_review.applicant)
173175

174-
review: OtherReview = (utc_now(), reviewer.uid, decision)
176+
review: Review = (utc_now(), reviewer.uid, applicant_review.score)
177+
app = applicant_review.applicant
175178

176-
try:
177-
await mongodb_handler.raw_update_one(
178-
Collection.USERS,
179-
{"_id": applicant},
180-
{
179+
applicant_record = await mongodb_handler.retrieve_one(
180+
Collection.USERS,
181+
{"_id": applicant_review.applicant},
182+
["_id", "application_data.reviews", "roles"],
183+
)
184+
if not applicant_record:
185+
log.error("Could not retrieve applicant after submitting review")
186+
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
187+
188+
if Role.HACKER in applicant_record["roles"]:
189+
unique_reviewers = applicant_review_processor.get_unique_reviewers(
190+
applicant_record
191+
)
192+
193+
# Only add a review if there are either less than 2 reviewers
194+
# or reviewer is one of the reviewers
195+
if len(unique_reviewers) >= 2 and reviewer.uid not in unique_reviewers:
196+
log.error(
197+
"%s tried to submit a review, but %s already has two reviewers",
198+
reviewer,
199+
app,
200+
)
201+
raise HTTPException(status.HTTP_403_FORBIDDEN)
202+
203+
update_query: dict[str, object] = {
204+
"$push": {"application_data.reviews": review}
205+
}
206+
# Because reviewing a hacker requires 2 reviewers, only set the
207+
# applicant's status to REVIEWED if there are at least 2 reviewers
208+
if len(unique_reviewers | {reviewer.uid}) >= 2:
209+
update_query.update({"$set": {"status": "REVIEWED"}})
210+
211+
await _try_update_applicant_with_query(
212+
applicant_review,
213+
update_query=update_query,
214+
err_msg=f"{reviewer} could not submit review for {app}",
215+
)
216+
217+
else:
218+
await _try_update_applicant_with_query(
219+
applicant_review,
220+
update_query={
181221
"$push": {"application_data.reviews": review},
182222
"$set": {"status": "REVIEWED"},
183223
},
224+
err_msg=f"{reviewer} could not submit review for {app}",
184225
)
185-
except RuntimeError:
186-
log.error("Could not submit review for %s", applicant)
187-
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
188226

189227

190228
@router.post("/release", dependencies=[Depends(require_director)])
@@ -199,14 +237,32 @@ async def release_decisions() -> None:
199237
for record in records:
200238
applicant_review_processor.include_review_decision(record)
201239

202-
for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED):
203-
group = [record for record in records if record["decision"] == decision]
204-
if not group:
205-
continue
206-
await asyncio.gather(
207-
*(_process_batch(batch, decision) for batch in batched(group, 100))
240+
await _process_records_in_batches(records)
241+
242+
243+
# TODO: need to make release hackers check roles as part of query
244+
@router.post("/release/hackers", dependencies=[Depends(require_director)])
245+
async def release_hacker_decisions() -> None:
246+
"""Update hacker applicant status based on decision and send decision emails."""
247+
records = await mongodb_handler.retrieve(
248+
Collection.USERS,
249+
{"status": Status.REVIEWED},
250+
["_id", "application_data.reviews", "first_name"],
251+
)
252+
253+
thresholds: Optional[dict[str, float]] = await _retrieve_thresholds()
254+
255+
if not thresholds:
256+
log.error("Could not retrieve thresholds")
257+
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
258+
259+
for record in records:
260+
applicant_review_processor.include_hacker_app_fields(
261+
record, thresholds["accept"], thresholds["waitlist"]
208262
)
209263

264+
await _process_records_in_batches(records)
265+
210266

211267
@router.post("/rsvp-reminder", dependencies=[Depends(require_director)])
212268
async def rsvp_reminder() -> None:
@@ -360,6 +416,16 @@ async def subevent_checkin(
360416
await participant_manager.subevent_checkin(event, uid, organizer)
361417

362418

419+
async def _process_records_in_batches(records: list[dict[str, object]]) -> None:
420+
for decision in (Decision.ACCEPTED, Decision.WAITLISTED, Decision.REJECTED):
421+
group = [record for record in records if record["decision"] == decision]
422+
if not group:
423+
continue
424+
await asyncio.gather(
425+
*(_process_batch(batch, decision) for batch in batched(group, 100))
426+
)
427+
428+
363429
async def _process_status(uids: Sequence[str], status: Status) -> None:
364430
ok = await mongodb_handler.update(
365431
Collection.USERS, {"_id": {"$in": uids}}, {"status": status}
@@ -397,3 +463,26 @@ def _recover_email_from_uid(uid: str) -> str:
397463
local = local.replace("\n", ".")
398464
domain = ".".join(reversed(reversed_domain))
399465
return f"{local}@{domain}"
466+
467+
468+
async def _retrieve_thresholds() -> Optional[dict[str, Any]]:
469+
return await mongodb_handler.retrieve_one(
470+
Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"]
471+
)
472+
473+
474+
async def _try_update_applicant_with_query(
475+
applicant_review: ReviewRequest,
476+
*,
477+
update_query: Mapping[str, object],
478+
err_msg: str = "",
479+
) -> None:
480+
try:
481+
await mongodb_handler.raw_update_one(
482+
Collection.USERS,
483+
{"_id": applicant_review.applicant},
484+
update_query,
485+
)
486+
except RuntimeError:
487+
log.error(err_msg)
488+
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

0 commit comments

Comments
 (0)