Skip to content
Open
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
18 changes: 16 additions & 2 deletions admin-frontend/src/analytics/AnalyticsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,16 @@ export default class AnalyticsService {
}
}

async getSkillGapStats(limit = 10, institution?: string): Promise<SkillGapStatsResponse> {
async getSkillGapStats(
limit = 10,
institution?: string,
province?: string,
sector?: string
): Promise<SkillGapStatsResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (institution) params.set("institution", institution);
if (province) params.set("province", province);
if (sector) params.set("sector", sector);
const url = `${this.baseUrl}/analytics/skill-gap-stats?${params}`;
const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getSkillGapStats", "GET", url);
const response = await customFetch(url, {
Expand Down Expand Up @@ -203,9 +210,16 @@ export default class AnalyticsService {
}
}

async getSkillsSupplyStats(limit = 10, institution?: string): Promise<SkillsSupplyStatsResponse> {
async getSkillsSupplyStats(
limit = 10,
institution?: string,
province?: string,
sector?: string
): Promise<SkillsSupplyStatsResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (institution) params.set("institution", institution);
if (province) params.set("province", province);
if (sector) params.set("sector", sector);
const url = `${this.baseUrl}/analytics/skills-supply-stats?${params}`;
const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getSkillsSupplyStats", "GET", url);
const response = await customFetch(url, {
Expand Down
35 changes: 30 additions & 5 deletions admin-frontend/src/components/SkillsAnalytics/SkillsAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from "react";
import React, { useState } from "react";
import { Box, Skeleton, Typography, useTheme, Select, MenuItem, LinearProgress } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { SkillsGapSectorData } from "src/types";
import { useSkillGapStats } from "src/hooks/useSkillGapStats";
import { useSkillsSupplyStats } from "src/hooks/useSkillsSupplyStats";
import MetricInfoIcon from "src/components/MetricInfoIcon/MetricInfoIcon";
import { MODULE_FILTER_LOCATIONS, MODULE_FILTER_SECTORS } from "src/data/moduleFilterOptions";

interface SkillsAnalyticsProps {
institution?: string;
Expand All @@ -13,8 +14,20 @@ interface SkillsAnalyticsProps {
const SkillsAnalytics: React.FC<SkillsAnalyticsProps> = ({ institution }) => {
const theme = useTheme();
const { t } = useTranslation();
const { data: skillGapData, loading: skillGapLoading } = useSkillGapStats(5, institution);
const { data: skillSupplyData, loading: skillSupplyLoading } = useSkillsSupplyStats(5, institution);
const [province, setProvince] = useState("");
const [sector, setSector] = useState("");
const { data: skillGapData, loading: skillGapLoading } = useSkillGapStats(
5,
institution,
province || undefined,
sector || undefined
);
const { data: skillSupplyData, loading: skillSupplyLoading } = useSkillsSupplyStats(
5,
institution,
province || undefined,
sector || undefined
);

// Supply: top skills students actually have, as % of students with that skill vs total with any skill
const supplyTotal = skillSupplyData?.total_students_with_skills ?? 0;
Expand Down Expand Up @@ -85,19 +98,31 @@ const SkillsAnalytics: React.FC<SkillsAnalyticsProps> = ({ institution }) => {
<Box display="flex" gap={2}>
<Select
size="small"
value=""
value={province}
onChange={(e) => setProvince(e.target.value)}
displayEmpty
sx={{ backgroundColor: theme.palette.background.paper, minWidth: 140 }}
>
<MenuItem value="">{t("dashboard.skillsAnalytics.filters.allProvinces")}</MenuItem>
{MODULE_FILTER_LOCATIONS.map((p) => (
<MenuItem key={p} value={p}>
{p}
</MenuItem>
))}
</Select>
<Select
size="small"
value=""
value={sector}
onChange={(e) => setSector(e.target.value)}
displayEmpty
sx={{ backgroundColor: theme.palette.background.paper, minWidth: 140 }}
>
<MenuItem value="">{t("dashboard.skillsAnalytics.filters.allSectors")}</MenuItem>
{MODULE_FILTER_SECTORS.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</Box>
</Box>
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/data/moduleFilterOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const MODULE_FILTER_INSTITUTIONS: string[] = [

export const MODULE_FILTER_YEARS: string[] = ["Year 1", "Year 2", "Year 3"];

export const MODULE_FILTER_SECTORS: string[] = ["Agriculture", "Energy", "Hospitality", "Mining", "Water"];

export const MODULE_FILTER_PROGRAMMES: string[] = [
"Accountancy",
"Accounting and Finance",
Expand Down
11 changes: 8 additions & 3 deletions admin-frontend/src/hooks/useSkillGapStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export interface UseSkillGapStatsResult {
error: Error | null;
}

export function useSkillGapStats(limit = 10, institution?: string): UseSkillGapStatsResult {
export function useSkillGapStats(
limit = 10,
institution?: string,
province?: string,
sector?: string
): UseSkillGapStatsResult {
const [data, setData] = useState<SkillGapStatsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand All @@ -17,7 +22,7 @@ export function useSkillGapStats(limit = 10, institution?: string): UseSkillGapS
let isMounted = true;
setLoading(true);
AnalyticsService.getInstance()
.getSkillGapStats(limit, institution)
.getSkillGapStats(limit, institution, province, sector)
.then((result) => {
if (!isMounted) return;
setData(result);
Expand All @@ -32,7 +37,7 @@ export function useSkillGapStats(limit = 10, institution?: string): UseSkillGapS
return () => {
isMounted = false;
};
}, [limit, institution]);
}, [limit, institution, province, sector]);

return { data, loading, error };
}
11 changes: 8 additions & 3 deletions admin-frontend/src/hooks/useSkillsSupplyStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export interface UseSkillsSupplyStatsResult {
error: Error | null;
}

export function useSkillsSupplyStats(limit = 10, institution?: string): UseSkillsSupplyStatsResult {
export function useSkillsSupplyStats(
limit = 10,
institution?: string,
province?: string,
sector?: string
): UseSkillsSupplyStatsResult {
const [data, setData] = useState<SkillsSupplyStatsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand All @@ -17,7 +22,7 @@ export function useSkillsSupplyStats(limit = 10, institution?: string): UseSkill
let isMounted = true;
setLoading(true);
AnalyticsService.getInstance()
.getSkillsSupplyStats(limit, institution)
.getSkillsSupplyStats(limit, institution, province, sector)
.then((result) => {
if (!isMounted) return;
setData(result);
Expand All @@ -32,7 +37,7 @@ export function useSkillsSupplyStats(limit = 10, institution?: string): UseSkill
return () => {
isMounted = false;
};
}, [limit, institution]);
}, [limit, institution, province, sector]);

return { data, loading, error };
}
36 changes: 21 additions & 15 deletions backend/app/analytics/skill_gap/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
SkillGapAnalyticsRepository,
)
from app.analytics.skill_gap.types import SkillGapStatsResponse
from app.analytics.user_filter import (
resolve_user_ids_for_institution,
resolve_user_ids_for_province,
resolve_user_ids_for_sector,
intersect_user_id_sets,
)
from app.constants.errors import HTTPErrorResponse
from app.server_dependencies.db_dependencies import CompassDBProvider
from app.server_dependencies.database_collections import Collections
from app.users.auth import Authentication
from app.users.access_role import AccessRole, get_access_role_dependency, decode_institution_id

Expand All @@ -28,17 +33,6 @@ async def _get_skill_gap_analytics_repository(
return SkillGapAnalyticsRepository(application_db)


async def _resolve_user_ids_for_institution(
institution_name: str,
userdata_db: AsyncIOMotorDatabase,
) -> Optional[list[str]]:
"""Return user_ids belonging to a given institution, or None if no filter."""
docs = await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).find(
{"data.institution_name": institution_name}, {"user_id": 1}
).to_list(length=None)
return [d["user_id"] for d in docs if d.get("user_id")]


def add_skill_gap_analytics_routes(router: APIRouter, auth: Authentication) -> None:
"""Register skill gap analytics routes on the given router."""

Expand All @@ -50,20 +44,32 @@ def add_skill_gap_analytics_routes(router: APIRouter, auth: Authentication) -> N
},
description=(
"Aggregate skill gap statistics across students with pre-computed recommendations. "
"Institution staff are automatically scoped to their own institution."
"Institution staff are automatically scoped to their own institution. "
"Province filter is admin-only. Sector filter applies to all roles."
),
)
async def _skill_gap_stats(
limit: Annotated[int, Query(ge=1, le=100, description="Maximum number of top skill gaps to return.")] = 10,
province: Optional[str] = Query(None, description="Filter by student province (admin only)."),
sector: Optional[str] = Query(None, description="Filter by programme sector (e.g. Agriculture, Energy)."),
access_role: AccessRole = Depends(get_access_role_dependency(auth)),
repo: ISkillGapAnalyticsRepository = Depends(_get_skill_gap_analytics_repository),
userdata_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_userdata_db),
) -> SkillGapStatsResponse:
try:
user_ids: Optional[list[str]] = None
user_id_sets: list[list[str]] = []

if access_role.is_institution_staff and access_role.institution_id:
institution_name = decode_institution_id(access_role.institution_id)
user_ids = await _resolve_user_ids_for_institution(institution_name, userdata_db)
user_id_sets.append(await resolve_user_ids_for_institution(institution_name, userdata_db))

if province and not access_role.is_institution_staff:
user_id_sets.append(await resolve_user_ids_for_province(province, userdata_db))

if sector:
user_id_sets.append(await resolve_user_ids_for_sector(sector, userdata_db))

user_ids = intersect_user_id_sets(user_id_sets)
return await repo.get_skill_gap_stats(limit, user_ids=user_ids)
except Exception as e:
logger.exception(e)
Expand Down
35 changes: 21 additions & 14 deletions backend/app/analytics/skills_supply/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
SkillsSupplyAnalyticsRepository,
)
from app.analytics.skills_supply.types import SkillsSupplyStatsResponse
from app.analytics.user_filter import (
resolve_user_ids_for_institution,
resolve_user_ids_for_province,
resolve_user_ids_for_sector,
intersect_user_id_sets,
)
from app.constants.errors import HTTPErrorResponse
from app.server_dependencies.database_collections import Collections
from app.server_dependencies.db_dependencies import CompassDBProvider
from app.users.auth import Authentication
from app.users.access_role import AccessRole, get_access_role_dependency, decode_institution_id
Expand All @@ -28,16 +33,6 @@ async def _get_skills_supply_repository(
return SkillsSupplyAnalyticsRepository(application_db)


async def _resolve_user_ids_for_institution(
institution_name: str,
userdata_db: AsyncIOMotorDatabase,
) -> Optional[list[str]]:
docs = await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).find(
{"data.institution_name": institution_name}, {"user_id": 1}
).to_list(length=None)
return [d["user_id"] for d in docs if d.get("user_id")]


def add_skills_supply_analytics_routes(router: APIRouter, auth: Authentication) -> None:
@router.get(
path="/skills-supply-stats",
Expand All @@ -47,20 +42,32 @@ def add_skills_supply_analytics_routes(router: APIRouter, auth: Authentication)
},
description=(
"Aggregate the most common skills identified by students during skills discovery. "
"Institution staff are automatically scoped to their own institution."
"Institution staff are automatically scoped to their own institution. "
"Province filter is admin-only. Sector filter applies to all roles."
),
)
async def _skills_supply_stats(
limit: int = Query(default=10, ge=1, le=50, description="Number of top skills to return"),
province: Optional[str] = Query(None, description="Filter by student province (admin only)."),
sector: Optional[str] = Query(None, description="Filter by programme sector (e.g. Agriculture, Energy)."),
access_role: AccessRole = Depends(get_access_role_dependency(auth)),
repo: ISkillsSupplyAnalyticsRepository = Depends(_get_skills_supply_repository),
userdata_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_userdata_db),
) -> SkillsSupplyStatsResponse:
try:
user_ids: Optional[list[str]] = None
user_id_sets: list[list[str]] = []

if access_role.is_institution_staff and access_role.institution_id:
institution_name = decode_institution_id(access_role.institution_id)
user_ids = await _resolve_user_ids_for_institution(institution_name, userdata_db)
user_id_sets.append(await resolve_user_ids_for_institution(institution_name, userdata_db))

if province and not access_role.is_institution_staff:
user_id_sets.append(await resolve_user_ids_for_province(province, userdata_db))

if sector:
user_id_sets.append(await resolve_user_ids_for_sector(sector, userdata_db))

user_ids = intersect_user_id_sets(user_id_sets)
return await repo.get_skills_supply_stats(limit=limit, user_ids=user_ids)
except Exception as e:
logger.exception(e)
Expand Down
64 changes: 64 additions & 0 deletions backend/app/analytics/user_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Shared utilities for resolving user_id sets by filter dimension (institution, province, sector).
Used by analytics route handlers to scope aggregations to a filtered population.
"""
from typing import Optional

from motor.motor_asyncio import AsyncIOMotorDatabase

from app.server_dependencies.database_collections import Collections
from app.teveta.loader import get_data, SECTOR_KEY_MAP


async def resolve_user_ids_for_institution(institution_name: str, userdata_db: AsyncIOMotorDatabase) -> list[str]:
docs = await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).find(
{"data.institution_name": institution_name}, {"user_id": 1}
).to_list(length=None)
return [d["user_id"] for d in docs if d.get("user_id")]


async def resolve_user_ids_for_province(province: str, userdata_db: AsyncIOMotorDatabase) -> list[str]:
docs = await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).find(
{"data.province": province}, {"user_id": 1}
).to_list(length=None)
return [d["user_id"] for d in docs if d.get("user_id")]


async def resolve_user_ids_for_sector(sector: str, userdata_db: AsyncIOMotorDatabase) -> list[str]:
"""
Resolve user_ids whose enrolled programme belongs to the given sector.

Uses the in-memory TEVETA data (loaded at startup) to find programme names
that have priority_sectors[<teveta_key>] == True, then matches those names
against PLAIN_PERSONAL_DATA.data.programme_name.

sector must be one of the hub display names: Agriculture, Energy, Hospitality, Mining, Water.
SECTOR_KEY_MAP translates these to the TEVETA internal keys used in priority_sectors.
"""
teveta_key = SECTOR_KEY_MAP.get(sector)
if not teveta_key:
return []
programme_names = {
p["name"]
for p in get_data().get("programmes", [])
if p.get("priority_sectors", {}).get(teveta_key)
}
if not programme_names:
return []
ppd_docs = await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).find(
{"data.programme_name": {"$in": list(programme_names)}}, {"user_id": 1}
).to_list(length=None)
return [d["user_id"] for d in ppd_docs if d.get("user_id")]


def intersect_user_id_sets(sets: list[list[str]]) -> Optional[list[str]]:
"""
Intersect multiple user_id lists. Returns None when no filters are active
(meaning: all users). Returns an empty list when filters yield no overlap.
"""
if not sets:
return None
result: set[str] = set(sets[0])
for s in sets[1:]:
result &= set(s)
return list(result)
Loading
Loading