Skip to content

Commit

Permalink
Merge pull request #306 from HackAtUCI/feature/applicant-summary
Browse files Browse the repository at this point in the history
Add Applicant Status Summary to Admin Dashboard
  • Loading branch information
taesungh authored Jan 21, 2024
2 parents e1fad83 + d74ea59 commit ca6bf0c
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 12 deletions.
23 changes: 23 additions & 0 deletions apps/api/src/admin/summary_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from collections import Counter

from pydantic import BaseModel, TypeAdapter

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


class ApplicantSummaryRecord(BaseModel):
status: ApplicantStatus


async def applicant_summary() -> Counter[ApplicantStatus]:
"""Get summary of applicants by status."""
records = await mongodb_handler.retrieve(
Collection.USERS,
{"role": Role.APPLICANT},
["status"],
)
applicants = TypeAdapter(list[ApplicantSummaryRecord]).validate_python(records)

return Counter(applicant.status for applicant in applicants)
12 changes: 11 additions & 1 deletion apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, Field, TypeAdapter, ValidationError

from admin import summary_handler
from auth.authorization import require_role
from auth.user_identity import User, utc_now
from models.ApplicationData import Decision, Review
Expand All @@ -15,7 +16,7 @@
from utils import email_handler
from utils.batched import batched
from utils.email_handler import IH_SENDER, REPLY_TO_HACK_AT_UCI
from utils.user_record import Applicant, Role, Status
from utils.user_record import Applicant, ApplicantStatus, Role, Status

log = getLogger(__name__)

Expand Down Expand Up @@ -86,6 +87,15 @@ async def applicant(
raise RuntimeError("Could not parse applicant data.")


@router.get(
"/summary/applicants",
dependencies=[Depends(require_role(ADMIN_ROLES))],
)
async def applicant_summary() -> dict[ApplicantStatus, int]:
"""Provide summary of statuses of applicants."""
return await summary_handler.applicant_summary()


@router.post("/review")
async def submit_review(
applicant: str = Body(),
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/utils/user_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Literal, Union

from pydantic import Field
from typing_extensions import TypeAlias

from models.ApplicationData import Decision, ProcessedApplicationData
from services.mongodb_handler import BaseRecord
Expand Down Expand Up @@ -31,7 +32,10 @@ class UserRecord(BaseRecord):
role: Role


ApplicantStatus: TypeAlias = Union[Status, Decision]


class Applicant(UserRecord):
role: Literal[Role.APPLICANT] = Role.APPLICANT
status: Union[Status, Decision]
status: ApplicantStatus
application_data: ProcessedApplicationData
23 changes: 23 additions & 0 deletions apps/api/tests/test_summary_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from unittest.mock import AsyncMock, patch

from admin.summary_handler import applicant_summary


@patch("services.mongodb_handler.retrieve", autospec=True)
async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> None:
"""Test applicant summary counts by status."""
mock_mongodb_handler_retrieve.return_value = (
[{"status": "ACCEPTED"}, {"status": "REJECTED"}] * 20
+ [{"status": "CONFIRMED"}] * 24
+ [{"status": "WAITLISTED"}, {"status": "WAIVER_SIGNED"}] * 3
)

summary = await applicant_summary()
mock_mongodb_handler_retrieve.assert_awaited_once()
assert dict(summary) == {
"REJECTED": 20,
"WAITLISTED": 3,
"ACCEPTED": 20,
"WAIVER_SIGNED": 3,
"CONFIRMED": 24,
}
15 changes: 14 additions & 1 deletion apps/site/src/app/admin/dashboard/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
"use client";

import { useContext } from "react";

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

import { isApplicationManager } from "@/lib/admin/authorization";
import UserContext from "@/lib/admin/UserContext";

import ApplicantSummary from "./components/ApplicantSummary";

function AdminDashboard() {
const { role } = useContext(UserContext);

return (
<ContentLayout>
<Container>Admin Dashboard</Container>
<SpaceBetween size="l">
<Container>Admin Dashboard</Container>
{isApplicationManager(role) && <ApplicantSummary />}
</SpaceBetween>
</ContentLayout>
);
}
Expand Down
57 changes: 57 additions & 0 deletions apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Box from "@cloudscape-design/components/box";
import Container from "@cloudscape-design/components/container";
import PieChart from "@cloudscape-design/components/pie-chart";

import { Status } from "@/lib/admin/useApplicant";

import useApplicantSummary from "./useApplicantSummary";

function ApplicantSummary() {
const { summary, loading, error } = useApplicantSummary();
const totalApplicants = Object.values(summary).reduce((s, v) => s + v, 0);

const orderedData = [
Status.rejected,
Status.waitlisted,
Status.accepted,
Status.signed,
Status.confirmed,
Status.attending,
Status.void,
].map((status) => ({
title: status,
value: summary[status] ?? 0,
}));

return (
<Container header={<Box variant="h2">Applicant Summary</Box>}>
<PieChart
data={orderedData}
statusType={(loading && "loading") || (error && "error")}
loadingText="Loading chart"
hideFilter={true}
segmentDescription={(datum, sum) =>
`${datum.value} applicants (${percentage(datum.value / sum)}%)`
}
ariaDescription="Donut chart showing summary of applicant statuses."
ariaLabel="Donut chart"
innerMetricDescription="total applicants"
innerMetricValue={`${totalApplicants}`}
size="large"
variant="donut"
empty={
<Box textAlign="center" color="inherit">
<b>No data available</b>
<Box variant="p" color="inherit">
There is no data available
</Box>
</Box>
}
/>
</Container>
);
}

const percentage = (value: number): string => (value * 100).toFixed(0);

export default ApplicantSummary;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import axios from "axios";
import useSWR from "swr";

import { Status } from "@/lib/admin/useApplicant";

type ApplicantSummary = Partial<Record<Status, number>>;

const fetcher = async (url: string) => {
const res = await axios.get<ApplicantSummary>(url);
return res.data;
};

function useApplicantSummary() {
const { data, error, isLoading } = useSWR<ApplicantSummary>(
"/api/admin/summary/applicants",
fetcher,
);

return {
summary: data ?? ({} as ApplicantSummary),
loading: isLoading,
error,
};
}

export default useApplicantSummary;
17 changes: 8 additions & 9 deletions apps/site/src/app/admin/layout/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,30 @@ import SideNavigation, {
SideNavigationProps,
} from "@cloudscape-design/components/side-navigation";

import { BASE_PATH, useFollowWithNextLink } from "./common";
import { isApplicationManager } from "@/lib/admin/authorization";
import UserContext from "@/lib/admin/UserContext";

import { BASE_PATH, useFollowWithNextLink } from "./common";

function AdminSidebar() {
const pathname = usePathname();
const followWithNextLink = useFollowWithNextLink();

const { role } = useContext(UserContext);

const navigationItems: SideNavigationProps.Item[] = [
{ type: "link", text: "Dashboard", href: "/admin/dashboard" },
{ type: "link", text: "Participants", href: "/admin/participants" },
{ type: "divider" },
{ type: "link", text: "Back to main site", href: "/" },
];

if (isApplicationManager(role)) {
navigationItems.unshift(
{
type: "link",
text: "Applicants",
href: "/admin/applicants",
},
{ type: "divider" },
);
navigationItems.splice(1, 0, {
type: "link",
text: "Applicants",
href: "/admin/applicants",
});
}

return (
Expand Down

0 comments on commit ca6bf0c

Please sign in to comment.