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 Applicant Status Summary to Admin Dashboard #306

Merged
merged 5 commits into from
Jan 21, 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
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
samderanova marked this conversation as resolved.
Show resolved Hide resolved
)

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