diff --git a/apps/api/src/admin/summary_handler.py b/apps/api/src/admin/summary_handler.py new file mode 100644 index 00000000..b0437217 --- /dev/null +++ b/apps/api/src/admin/summary_handler.py @@ -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) diff --git a/apps/api/src/routers/admin.py b/apps/api/src/routers/admin.py index bbd83be9..2404a74c 100644 --- a/apps/api/src/routers/admin.py +++ b/apps/api/src/routers/admin.py @@ -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 @@ -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__) @@ -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(), diff --git a/apps/api/src/utils/user_record.py b/apps/api/src/utils/user_record.py index 0807e2c8..d91c2cad 100644 --- a/apps/api/src/utils/user_record.py +++ b/apps/api/src/utils/user_record.py @@ -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 @@ -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 diff --git a/apps/api/tests/test_summary_handler.py b/apps/api/tests/test_summary_handler.py new file mode 100644 index 00000000..c1066e71 --- /dev/null +++ b/apps/api/tests/test_summary_handler.py @@ -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, + } diff --git a/apps/site/src/app/admin/dashboard/AdminDashboard.tsx b/apps/site/src/app/admin/dashboard/AdminDashboard.tsx index 5ae9f9c9..f673ce7c 100644 --- a/apps/site/src/app/admin/dashboard/AdminDashboard.tsx +++ b/apps/site/src/app/admin/dashboard/AdminDashboard.tsx @@ -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 ( - Admin Dashboard + + Admin Dashboard + {isApplicationManager(role) && } + ); } diff --git a/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx b/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx new file mode 100644 index 00000000..38dd8bb5 --- /dev/null +++ b/apps/site/src/app/admin/dashboard/components/ApplicantSummary.tsx @@ -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 ( + Applicant Summary}> + + `${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={ + + No data available + + There is no data available + + + } + /> + + ); +} + +const percentage = (value: number): string => (value * 100).toFixed(0); + +export default ApplicantSummary; diff --git a/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts b/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts new file mode 100644 index 00000000..7c00f6f1 --- /dev/null +++ b/apps/site/src/app/admin/dashboard/components/useApplicantSummary.ts @@ -0,0 +1,26 @@ +import axios from "axios"; +import useSWR from "swr"; + +import { Status } from "@/lib/admin/useApplicant"; + +type ApplicantSummary = Partial>; + +const fetcher = async (url: string) => { + const res = await axios.get(url); + return res.data; +}; + +function useApplicantSummary() { + const { data, error, isLoading } = useSWR( + "/api/admin/summary/applicants", + fetcher, + ); + + return { + summary: data ?? ({} as ApplicantSummary), + loading: isLoading, + error, + }; +} + +export default useApplicantSummary; diff --git a/apps/site/src/app/admin/layout/AdminSidebar.tsx b/apps/site/src/app/admin/layout/AdminSidebar.tsx index da29c439..e18c1909 100644 --- a/apps/site/src/app/admin/layout/AdminSidebar.tsx +++ b/apps/site/src/app/admin/layout/AdminSidebar.tsx @@ -6,10 +6,11 @@ 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(); @@ -17,20 +18,18 @@ function AdminSidebar() { 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 (