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

Add daily applications summary charts for admins #529

Merged
merged 2 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions apps/api/src/admin/summary_handler.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from collections import Counter
from collections import Counter, defaultdict
from datetime import date, datetime
from typing import Iterable
from zoneinfo import ZoneInfo

from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel, TypeAdapter, ValidationError

from models.user_record import ApplicantStatus, Role
from services import mongodb_handler
from services.mongodb_handler import Collection

LOCAL_TIMEZONE = ZoneInfo("America/Los_Angeles")


class ApplicantSummaryRecord(BaseModel):
status: ApplicantStatus
Expand All @@ -21,3 +26,85 @@ async def applicant_summary() -> Counter[ApplicantStatus]:
applicants = TypeAdapter(list[ApplicantSummaryRecord]).validate_python(records)

return Counter(applicant.status for applicant in applicants)


class ApplicationSubmissionTime(BaseModel):
submission_time: datetime


class ApplicationSchoolAndTime(ApplicationSubmissionTime):
school: str


class ApplicantSchoolStats(BaseModel):
application_data: ApplicationSchoolAndTime


async def applications_by_school() -> dict[str, dict[date, int]]:
"""Get daily number of applications by school."""
records = await mongodb_handler.retrieve(
Collection.USERS,
{"roles": Role.APPLICANT},
["application_data.school", "application_data.submission_time"],
)

try:
applicant_stats_adapter = TypeAdapter(list[ApplicantSchoolStats])
applicants = applicant_stats_adapter.validate_python(records)
except ValidationError:
raise RuntimeError("Could not parse applicant data.")

grouped_applications: dict[str, dict[date, int]] = defaultdict(
lambda: defaultdict(int)
)

for applicant in applicants:
school = applicant.application_data.school
day = applicant.application_data.submission_time.astimezone(
LOCAL_TIMEZONE
).date()
grouped_applications[school][day] += 1

return grouped_applications


class ApplicantRoleStats(BaseModel):
roles: tuple[Role, ...]
application_data: ApplicationSubmissionTime


async def applications_by_role() -> dict[str, dict[date, int]]:
"""Get daily number of applications by role."""
records: list[dict[str, object]] = await mongodb_handler.retrieve(
Collection.USERS,
{"roles": Role.APPLICANT},
["roles", "application_data.submission_time"],
)

try:
applicant_stats_adapter = TypeAdapter(list[ApplicantRoleStats])
applicants = applicant_stats_adapter.validate_python(records)
except ValidationError:
raise RuntimeError("Could not parse applicant data.")

return {
role.value: _count_applications_by_day(
applicant.application_data
for applicant in applicants
if role in applicant.roles
)
for role in [Role.HACKER, Role.MENTOR, Role.VOLUNTEER]
}


def _count_applications_by_day(
application_data: Iterable[ApplicationSubmissionTime],
) -> dict[date, int]:
"""Group the applications by the date of submission."""
daily_applications = defaultdict[date, int](int)

for data in application_data:
day = data.submission_time.astimezone(LOCAL_TIMEZONE).date()
daily_applications[day] += 1

return daily_applications
23 changes: 19 additions & 4 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import asyncio
from datetime import datetime
from datetime import date, datetime
from logging import getLogger
from typing import Annotated, Any, Mapping, Optional, Sequence
from typing import Annotated, Any, Literal, Mapping, Optional, Sequence

from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
from typing_extensions import assert_never

from admin import participant_manager, summary_handler
from admin import applicant_review_processor
from admin import applicant_review_processor, participant_manager, summary_handler
from admin.participant_manager import Participant
from auth.authorization import require_role
from auth.user_identity import User, utc_now
Expand Down Expand Up @@ -150,6 +150,21 @@ async def applicant_summary() -> dict[ApplicantStatus, int]:
return await summary_handler.applicant_summary()


@router.get(
"/summary/applications",
response_model=dict[str, object],
dependencies=[Depends(require_manager)],
)
async def applications(
group_by: Literal["school", "role"]
) -> dict[str, dict[date, int]]:
if group_by == "school":
return await summary_handler.applications_by_school()
elif group_by == "role":
return await summary_handler.applications_by_role()
assert_never(group_by)


@router.post("/review")
async def submit_review(
applicant_review: ReviewRequest,
Expand Down
83 changes: 81 additions & 2 deletions apps/api/tests/test_summary_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import date, datetime, timezone
from unittest.mock import AsyncMock, patch

from admin.summary_handler import applicant_summary
from admin import summary_handler


@patch("services.mongodb_handler.retrieve", autospec=True)
Expand All @@ -12,7 +13,8 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No
+ [{"status": "WAITLISTED"}, {"status": "WAIVER_SIGNED"}] * 3
)

summary = await applicant_summary()
summary = await summary_handler.applicant_summary()

mock_mongodb_handler_retrieve.assert_awaited_once()
assert dict(summary) == {
"REJECTED": 20,
Expand All @@ -21,3 +23,80 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No
"WAIVER_SIGNED": 3,
"CONFIRMED": 24,
}


@patch("services.mongodb_handler.retrieve", autospec=True)
async def test_applications_by_school(mock_mongodb_handler_retrieve: AsyncMock) -> None:
"""Daily number of applications are grouped by school."""
mock_mongodb_handler_retrieve.return_value = [
{
"application_data": {
"school": "UC Irvine",
"submission_time": datetime(1965, 10, 4, 20, 2, 4, tzinfo=timezone.utc),
},
},
{
"application_data": {
"school": "UC Irvine",
"submission_time": datetime(
1965, 10, 4, 20, 15, 26, tzinfo=timezone.utc
),
},
},
{
"application_data": {
"school": "Cal State Long Beach",
"submission_time": datetime(
2024, 12, 17, 18, 4, 11, tzinfo=timezone.utc
),
},
},
]

applications = await summary_handler.applications_by_school()

mock_mongodb_handler_retrieve.assert_awaited_once()
assert applications == {
"UC Irvine": {date(1965, 10, 4): 2},
"Cal State Long Beach": {date(2024, 12, 17): 1},
}


@patch("services.mongodb_handler.retrieve", autospec=True)
async def test_applications_by_role(mock_mongodb_handler_retrieve: AsyncMock) -> None:
"""Daily number of applications are grouped by role."""
mock_mongodb_handler_retrieve.return_value = [
{
"roles": ["Applicant", "Hacker"],
"application_data": {
"submission_time": datetime(
2024, 12, 12, 17, 0, 0, tzinfo=timezone.utc
),
},
},
{
"roles": ["Applicant", "Hacker"],
"application_data": {
"submission_time": datetime(
2024, 12, 12, 19, 0, 0, tzinfo=timezone.utc
),
},
},
{
"roles": ["Applicant", "Mentor"],
"application_data": {
"submission_time": datetime(
2024, 12, 14, 18, 0, 0, tzinfo=timezone.utc
),
},
},
]

applications = await summary_handler.applications_by_role()

mock_mongodb_handler_retrieve.assert_awaited_once()
assert applications == {
"Hacker": {date(2024, 12, 12): 2},
"Mentor": {date(2024, 12, 14): 1},
"Volunteer": {},
}
6 changes: 3 additions & 3 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"lint": "next lint"
},
"dependencies": {
"@cloudscape-design/collection-hooks": "^1.0.34",
"@cloudscape-design/components": "^3.0.475",
"@cloudscape-design/global-styles": "^1.0.20",
"@cloudscape-design/collection-hooks": "^1.0.56",
"@cloudscape-design/components": "^3.0.856",
"@cloudscape-design/global-styles": "^1.0.33",
"@fireworks-js/react": "^2.10.7",
"@portabletext/react": "^3.0.11",
"@radix-ui/react-accordion": "^1.1.2",
Expand Down
47 changes: 47 additions & 0 deletions apps/site/src/app/admin/applicants/(summary)/ApplicantsSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import Container from "@cloudscape-design/components/container";
import ContentLayout from "@cloudscape-design/components/content-layout";
import Header from "@cloudscape-design/components/header";
import SpaceBetween from "@cloudscape-design/components/space-between";

import ApplicationsByRoleChart from "./ApplicationsByRoleChart";
import ApplicationsBySchoolChart from "./ApplicationsBySchoolChart";

function ApplicantsSummary() {
return (
<ContentLayout
defaultPadding
header={<Header variant="h1">Applicants</Header>}
>
<SpaceBetween size="l">
<Container
header={
<Header
variant="h2"
description="Daily applications submitted by school"
>
Applications Submitted
</Header>
}
>
<ApplicationsBySchoolChart />
</Container>
<Container
header={
<Header
variant="h2"
description="Cumulative daily applications submitted by role"
>
Cumulative Applications Submitted
</Header>
}
>
<ApplicationsByRoleChart />
</Container>
</SpaceBetween>
</ContentLayout>
);
}

export default ApplicantsSummary;
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import AreaChart from "@cloudscape-design/components/area-chart";
import Box from "@cloudscape-design/components/box";

import { ParticipantRole } from "@/lib/userRecord";

import useApplicationsSummary from "./useApplicationsSummary";

const TIME_SPEC = "T00:00:00-08:00";
const START_DAY = new Date("2024-12-10" + TIME_SPEC);
const END_DAY = new Date("2025-01-11" + TIME_SPEC);

const ROLES = [
ParticipantRole.Hacker,
ParticipantRole.Mentor,
ParticipantRole.Volunteer,
] as const;

function ApplicationsByRoleChart() {
const { loading, applications, error } = useApplicationsSummary("role");

const today = new Date();
const end = today < END_DAY ? today : END_DAY;

const cumulativeApplications = Object.fromEntries(
ROLES.map((role) => [
role,
getCumulativeApplications(applications[role] ?? {}, end),
]),
);

return (
<AreaChart
series={ROLES.map((role) => ({
title: role,
type: "area",
data: cumulativeApplications[role].map(([d, count]) => {
return { x: d, y: count };
}),
}))}
xDomain={[START_DAY, end]}
i18nStrings={{
filterLabel: "Filter displayed data",
filterPlaceholder: "Filter data",
filterSelectedAriaLabel: "selected",
xTickFormatter: (e) =>
e.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
}}
ariaLabel="Stacked area chart."
errorText="Error loading data."
statusType={loading ? "loading" : error ? "error" : "finished"}
fitHeight
height={300}
loadingText="Loading chart"
xScaleType="time"
xTitle="Date (Pacific Time)"
yTitle="Cumulative total applications submitted"
empty={
<Box textAlign="center" color="inherit">
<b>No data available</b>
<Box variant="p" color="inherit">
There is no data available
</Box>
</Box>
}
/>
);
}

function getCumulativeApplications(
applications: Record<string, number>,
end: Date,
): [Date, number][] {
const cumulativeApplications: [Date, number][] = [];

let prev = 0;
for (let d = new Date(START_DAY); d <= end; d.setDate(d.getDate() + 1)) {
cumulativeApplications.push([
new Date(d),
// Index as YYYY-MM-DD and accumulate with previous value
prev + (applications[d.toISOString().substring(0, 10)] ?? 0),
]);
prev = cumulativeApplications.at(-1)![1];
}

return cumulativeApplications;
}

export default ApplicationsByRoleChart;
Loading
Loading