Skip to content

Commit 114259b

Browse files
authored
Added score normalization and list of scored apps (#692)
* Created empty route with function structure * Added query for retrieving all hacker apps * Filtered overqualified out of retrieve * Implemented function to get mean and std * Implemented normalizing function * Removed unnecessary check * returned new dict for normalized scores * Created bulk write function and tests * Implemented bulk update for normalized scores * Added skeleton for normalization page * Added normalized_scores to summary fetching * Added normalized-scores to list of hackers * Added email and resume to returned list * Created table and csv download * Filtered out overqualified * Added normalize scores button * Reordered imports * Removed test json
1 parent 06b30e9 commit 114259b

File tree

9 files changed

+438
-4
lines changed

9 files changed

+438
-4
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from collections import defaultdict
2+
from statistics import mean, pstdev
3+
from typing import Any
4+
5+
from pymongo import UpdateOne
6+
7+
from models.user_record import Role
8+
from services import mongodb_handler
9+
from services.mongodb_handler import Collection
10+
11+
GLOBAL_FIELDS = {"resume", "hackathon_experience"}
12+
13+
14+
async def add_normalized_scores_to_all_hacker_applicants() -> None:
15+
"""Calculates normalized scores and adds them to all hacker apps"""
16+
all_apps = await get_all_hacker_apps()
17+
reviewer_stats = get_reviewer_stats(all_apps)
18+
19+
normalized_scores = get_normalized_scores_for_hacker_applicants(
20+
all_apps, reviewer_stats
21+
)
22+
await update_hacker_applicants_in_collection(normalized_scores)
23+
24+
25+
async def get_all_hacker_apps() -> list[dict[str, object]]:
26+
return await mongodb_handler.retrieve(
27+
Collection.USERS,
28+
{
29+
"roles": Role.HACKER,
30+
"application_data.global_field_scores.resume": {"$gte": 0},
31+
"application_data.global_field_scores.hackathon_experience": {"$gte": 0},
32+
},
33+
[
34+
"_id",
35+
"status",
36+
"application_data.review_breakdown",
37+
"application_data.global_field_scores",
38+
],
39+
)
40+
41+
42+
def get_reviewer_stats(all_apps: list[dict[str, Any]]) -> dict[str, dict[str, float]]:
43+
"""Compute mean and std for each reviewer across all applications."""
44+
reviewer_totals: dict[str, list[float]] = defaultdict(list)
45+
46+
for app in all_apps:
47+
breakdown = app.get("application_data", {}).get("review_breakdown", {})
48+
for reviewer, scores_dict in breakdown.items():
49+
total_score = sum(
50+
[
51+
score
52+
for field, score in scores_dict.items()
53+
if field not in GLOBAL_FIELDS
54+
]
55+
)
56+
reviewer_totals[reviewer].append(total_score)
57+
58+
reviewer_stats = {
59+
reviewer: {
60+
"mean": mean(scores),
61+
"std": pstdev(scores) or 1.0, # avoid divide-by-zero if all same
62+
}
63+
for reviewer, scores in reviewer_totals.items()
64+
}
65+
66+
return reviewer_stats
67+
68+
69+
def get_normalized_scores_for_hacker_applicants(
70+
all_apps: list[dict[str, Any]], reviewer_stats: dict[str, dict[str, float]]
71+
) -> dict[str, dict[str, float]]:
72+
"""
73+
Compute normalized scores for each applicant and return a dict in the format:
74+
{
75+
"app1": {"ian": 0.5, "bob": -0.3},
76+
"app2": {"ian": 1.2}
77+
}
78+
79+
- all_apps: list of applicant dicts
80+
- reviewer_stats: dict of reviewer mean/std
81+
"""
82+
result: dict[str, dict[str, float]] = {}
83+
84+
for app in all_apps:
85+
app_id = app["_id"]
86+
breakdown = app.get("application_data", {}).get("review_breakdown", {})
87+
normalized_scores: dict[str, float] = {}
88+
89+
for reviewer, scores_dict in breakdown.items():
90+
total_score = sum(
91+
score
92+
for field, score in scores_dict.items()
93+
if field not in GLOBAL_FIELDS # exclude global fields if needed
94+
)
95+
stats = reviewer_stats.get(reviewer, {"mean": 0, "std": 1})
96+
normalized = (total_score - stats["mean"]) / stats["std"]
97+
normalized_scores[reviewer] = normalized
98+
99+
result[app_id] = normalized_scores
100+
101+
return result
102+
103+
104+
async def update_hacker_applicants_in_collection(
105+
normalized_scores: dict[str, dict[str, float]]
106+
) -> None:
107+
operations = [
108+
UpdateOne(
109+
{"_id": app_id}, {"$set": {"application_data.normalized_scores": scores}}
110+
)
111+
for app_id, scores in normalized_scores.items()
112+
]
113+
114+
await mongodb_handler.bulk_update(Collection.USERS, operations)

apps/api/src/routers/admin.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
from admin import applicant_review_processor, participant_manager, summary_handler
1111
from admin.participant_manager import Participant
12+
from admin.score_normalizing_handler import (
13+
add_normalized_scores_to_all_hacker_applicants,
14+
)
1215
from auth.authorization import require_role
1316
from auth.user_identity import User, utc_now
1417
from models.ApplicationData import Decision, Review
@@ -56,6 +59,9 @@ class ApplicationDataSummary(BaseModel):
5659
class ZotHacksApplicationDataSummary(BaseModel):
5760
school_year: str
5861
submission_time: Any
62+
normalized_scores: Optional[dict[str, float]] = None
63+
email: str
64+
resume_url: str
5965

6066

6167
class ApplicantSummary(BaseRecord):
@@ -182,7 +188,6 @@ async def hacker_applicants(
182188

183189
try:
184190
return TypeAdapter(list[HackerApplicantSummary]).validate_python(records)
185-
186191
except ValidationError:
187192
raise RuntimeError("Could not parse applicant data.")
188193

@@ -440,6 +445,18 @@ async def subevent_checkin(
440445
await participant_manager.subevent_checkin(event, uid, organizer)
441446

442447

448+
@router.get(
449+
"/normalize-detailed-scores",
450+
dependencies=[Depends(require_role({Role.DIRECTOR, Role.LEAD}))],
451+
)
452+
async def normalize_detailed_scores_for_all_hacker_apps() -> None:
453+
try:
454+
await add_normalized_scores_to_all_hacker_applicants()
455+
except RuntimeError:
456+
log.error("Could not update/add normalized scores to hacker applicants")
457+
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
458+
459+
443460
async def retrieve_thresholds() -> Optional[dict[str, Any]]:
444461
return await mongodb_handler.retrieve_one(
445462
Collection.SETTINGS, {"_id": "hacker_score_thresholds"}, ["accept", "waitlist"]

apps/api/src/services/mongodb_handler.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import os
33
from enum import Enum
44
from logging import getLogger
5-
from typing import Any, Mapping, Optional, Union
5+
from typing import Any, Mapping, Optional, Sequence, Union
66

77
from bson import CodecOptions
88
from motor.core import AgnosticClient, AgnosticDatabase
99
from motor.motor_asyncio import AsyncIOMotorClient
1010
from pydantic import BaseModel, ConfigDict, Field
11+
from pymongo import UpdateMany, UpdateOne
1112

1213
from utils.hackathon_context import hackathon_name_ctx, HackathonName
1314

@@ -153,3 +154,30 @@ async def update(
153154
raise RuntimeError("Could not update documents in MongoDB collection")
154155

155156
return result.modified_count > 0
157+
158+
159+
async def bulk_update(
160+
collection: Collection,
161+
operations: Sequence[Union[UpdateOne, UpdateMany]],
162+
) -> bool:
163+
"""
164+
Perform multiple updates in bulk on a collection.
165+
166+
operations should be a list of pymongo UpdateOne or UpdateMany objects.
167+
Returns True if at least one document was modified.
168+
"""
169+
if not operations:
170+
log.warning("No operations provided to bulk_update")
171+
return False
172+
173+
DB = get_database()
174+
COLLECTION = DB[collection.value]
175+
176+
result = await COLLECTION.bulk_write(operations)
177+
178+
if not result.acknowledged:
179+
log.error("MongoDB bulk write was not acknowledged")
180+
raise RuntimeError("Could not perform bulk write in MongoDB collection")
181+
182+
log.info(f"Bulk write completed: {result.modified_count} documents modified")
183+
return result.modified_count > 0

apps/api/tests/test_mongodb_handler.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest.mock import AsyncMock, MagicMock, Mock, patch
22

3+
from pymongo import UpdateOne
34
import pytest
45
from pymongo.results import InsertOneResult, UpdateResult
56

@@ -218,3 +219,60 @@ async def test_retrieve_documents_sorted_descending(mock_DB: MagicMock) -> None:
218219
mock_collection.find.assert_called_once_with(query, [])
219220
mock_collection.find.return_value.sort.assert_called_once_with(sort)
220221
assert result == SAMPLE_DOCUMENTS
222+
223+
224+
@patch("services.mongodb_handler.get_database")
225+
async def test_bulk_update_success(mock_DB: MagicMock) -> None:
226+
"""Test that bulk_update returns True if at least one document modified"""
227+
mock_collection = AsyncMock()
228+
mock_result = MagicMock()
229+
mock_result.acknowledged = True
230+
mock_result.modified_count = 2
231+
mock_collection.bulk_write.return_value = mock_result
232+
233+
mock_db_instance = MagicMock()
234+
mock_db_instance.__getitem__.return_value = mock_collection
235+
mock_DB.return_value = mock_db_instance
236+
237+
operations = [
238+
UpdateOne({"_id": "app1"}, {"$set": {"score": 1}}),
239+
UpdateOne({"_id": "app2"}, {"$set": {"score": 2}}),
240+
]
241+
242+
result = await mongodb_handler.bulk_update(Collection.TESTING, operations)
243+
244+
mock_collection.bulk_write.assert_awaited_once_with(operations)
245+
assert result is True
246+
247+
248+
@patch("services.mongodb_handler.get_database")
249+
async def test_bulk_update_no_acknowledgement(mock_DB: MagicMock) -> None:
250+
"""Test that bulk_update raises RuntimeError if not acknowledged"""
251+
mock_collection = AsyncMock()
252+
mock_result = MagicMock()
253+
mock_result.acknowledged = False
254+
mock_result.modified_count = 0
255+
mock_collection.bulk_write.return_value = mock_result
256+
257+
mock_db_instance = MagicMock()
258+
mock_db_instance.__getitem__.return_value = mock_collection
259+
mock_DB.return_value = mock_db_instance
260+
261+
operations = [UpdateOne({"_id": "app1"}, {"$set": {"score": 1}})]
262+
263+
with pytest.raises(RuntimeError):
264+
await mongodb_handler.bulk_update(Collection.TESTING, operations)
265+
266+
267+
@patch("services.mongodb_handler.get_database")
268+
async def test_bulk_update_empty_operations(mock_DB: MagicMock) -> None:
269+
"""Test that bulk_update returns False if no operations are provided"""
270+
mock_collection = AsyncMock()
271+
mock_db_instance = MagicMock()
272+
mock_db_instance.__getitem__.return_value = mock_collection
273+
mock_DB.return_value = mock_db_instance
274+
275+
result = await mongodb_handler.bulk_update(Collection.TESTING, [])
276+
277+
assert result is False
278+
mock_collection.bulk_write.assert_not_called()

apps/site/src/app/admin/layout/AdminSidebar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isApplicationManager,
1111
isHackerReviewer,
1212
isDirector,
13+
isLead,
1314
} from "@/lib/admin/authorization";
1415

1516
import UserContext from "@/lib/admin/UserContext";
@@ -56,6 +57,14 @@ function AdminSidebar() {
5657
// });
5758
// }
5859

60+
if (isLead(roles) || isDirector(roles)) {
61+
navigationItems.splice(1, 0, {
62+
type: "link",
63+
text: "Scores",
64+
href: "/admin/scores",
65+
});
66+
}
67+
5968
if (isApplicationManager(roles)) {
6069
navigationItems.splice(1, 0, {
6170
type: "link-group",

apps/site/src/app/admin/layout/Breadcrumbs.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const pathTitles: PathTitles = {
2121
organizers: "Organizers",
2222
"email-sender": "Email Sender",
2323
"zothacks-hackers": "ZotHacks Hacker Applications",
24+
scores: "Scores",
2425
};
2526

2627
const DEFAULT_ITEMS = [{ text: "Admin Dashboard", href: BASE_PATH }];

0 commit comments

Comments
 (0)