[FilteredCourses][Populations] Use groupId

still wip
This commit is contained in:
joonas-a
2026-09-21 15:24:21 +03:00
committed by Aaro Koivukangas
parent d3b3d82fee
commit e69e1d2f16
16 changed files with 98 additions and 74 deletions
+1 -1
View File
@@ -29,7 +29,6 @@ import { findByCourseAndSemesters, getStartAndEndDates } from '../services/stude
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
import { parseUnification } from '@oodikone/shared/types/unification'
import logger from '../util/logger'
import { getAllCourseIds } from 'src/services/courses/helpers'
const router = Router()
@@ -88,6 +87,7 @@ const obfuscateStuff = ({
}),
coursestatistics: {
courses: coursestatistics.courses,
idToGroupIdMap: coursestatistics.idToGroupIdMap,
enrollments: coursestatistics.enrollments.map(enrollment => ({
...enrollment,
studentnumber: encryptionMap.get(enrollment.studentnumber) ?? enrollment.studentnumber,
+4 -11
View File
@@ -1,15 +1,9 @@
import crypto from 'crypto'
import { Op, fn as dbFn, col as dbCol, fn, col } from 'sequelize'
import { Op } from 'sequelize'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { Name, EnrollmentState, Unification, CreditTypeCode } from '@oodikone/shared/types'
import {
enrollmentTimeDateThreshold,
getSemesterCodeAt,
MAX_POPULATION_SIZE,
yearCodeToYear,
yearToYearCode,
} from '@oodikone/shared/util'
import { enrollmentTimeDateThreshold, getSemesterCodeAt, yearCodeToYear, yearToYearCode } from '@oodikone/shared/util'
import { dateIsBetween } from '@oodikone/shared/util/datetime'
import logger from '../../../src/util/logger'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
@@ -21,9 +15,8 @@ import {
getEnrollmentsForCourses,
getStudentNumberToSrElementsMap,
} from './creditsAndEnrollmentsOfCourse'
import { FormattedProgramme, getAllCourseIds, getIsOpen } from './helpers'
import { FormattedProgramme } from './helpers'
import { CourseYearlyStats } from '@oodikone/shared/types/courseYearlyStats'
import { difference } from 'lodash-es'
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
code: studyRightElement.code,
@@ -471,7 +464,7 @@ export const getCourseProvidersForCourses = async (courseIds: string[]) =>
export const getCourseDetails = async (courseGroupIds: string[]) =>
CourseModel.findAll({
attributes: ['groupId', 'code', 'name', 'substitution_groups'],
attributes: ['id', 'groupId', 'code', 'name', 'substitutionGroups', 'isStudyModule'],
where: { groupId: { [Op.in]: courseGroupIds }, isPrimary: true },
raw: true,
})
@@ -77,9 +77,9 @@ export const getOptionsForStudents = (
return optionMap
}
export const getCourses = (courseIds: string[]): Promise<Array<CourseStats>> =>
export const getCourseGroupIds = (courseIds: string[]): Promise<Array<Pick<CourseStats, 'id' | 'groupId'>>> =>
CourseModel.findAll({
attributes: ['id', 'groupId', 'code', 'name', 'substitutionGroups', 'isStudyModule'],
attributes: ['id', 'groupId'],
where: {
id: { [Op.in]: courseIds },
},
@@ -1,6 +1,7 @@
import { getDegreeProgrammeType } from '../../util'
import { now } from '../../util/clock'
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
import { getCourseDetails } from '../courses'
import { formatStudentForAPI } from './formatStatisticsForApi'
import {
type StudentEnrollment,
@@ -11,7 +12,7 @@ import {
getCredits,
getStudyRightElementsForStudyRight,
} from './getStudentData'
import { getCourses, getOptionsForStudents } from './shared'
import { getCourseGroupIds, getOptionsForStudents } from './shared'
export type OptimizedStatisticsQuery = {
userId: string
@@ -66,7 +67,10 @@ export const statisticsOf = async (
const courseIds = new Set<string>()
for (const { course_id } of credits) courseIds.add(course_id)
for (const { course_id } of enrollments) courseIds.add(course_id)
const courses = await getCourses([...courseIds])
const idToGroupIdRows = await getCourseGroupIds([...courseIds])
const idToGroupIdMap = Object.fromEntries(idToGroupIdRows.map(({ id, groupId }) => [id, groupId]))
const groupIds = [...new Set(idToGroupIdRows.map(({ groupId }) => groupId))]
const courses = await getCourseDetails(groupIds)
const optionData = getOptionsForStudents(studyRightElementsForStudyRight, degreeProgrammeType)
const formattedStudents = students.map(student => {
@@ -79,6 +83,6 @@ export const statisticsOf = async (
return {
students: formattedStudents,
criteria,
coursestatistics: { courses, enrollments, credits },
coursestatistics: { courses, idToGroupIdMap, enrollments, credits },
}
}
@@ -200,8 +200,9 @@ export const CoursePopulation = () => {
studentNumberFilter(),
ageFilter(),
courseFilter({
courses: population?.coursestatistics.courses,
courses: population?.coursestatistics.courses ?? [],
includeSubstitutions: substitutions === 'true',
idToGroupIdMap: population?.coursestatistics.idToGroupIdMap ?? {},
}),
creditsEarnedFilter(),
hetuFilter(),
@@ -61,7 +61,10 @@ export const CustomPopulationWrapper = ({
const filtersList = [
genderFilter(),
ageFilter(),
courseFilter({ courses: population?.coursestatistics.courses ?? [] }),
courseFilter({
courses: population?.coursestatistics.courses ?? [],
idToGroupIdMap: population?.coursestatistics.idToGroupIdMap ?? {},
}),
creditsEarnedFilter(),
transferredToProgrammeFilter(),
startYearAtUniFilter(),
@@ -26,11 +26,11 @@ const filterTexts = {
label: 'Enrolled, No Grade',
},
}
const getSubstitutionTooltip = (substitution_groups: string[][]) => (
const SubstitutionTooltip = ({ substitutionGroups }: { substitutionGroups: string[][] }) => (
<Typography fontSize="0.9rem" whiteSpace="pre-line">
Included course's equivalent groups:
<br />
{substitution_groups.map(group => group.join(', ')).join('\n\n')}
{substitutionGroups.map(group => group.join(', ')).join('\n\n')}
</Typography>
)
export const CourseCard = ({
@@ -64,10 +64,10 @@ export const CourseCard = ({
<Stack direction="row" sx={{ justifyContent: 'space-between' }}>
<Box sx={{ mb: 2 }}>
<Typography>{getTextIn(course?.name)}</Typography>
{course?.substitutions?.length ? (
<Tooltip title={getSubstitutionTooltip(course.substitution_groups)}>
{course?.substitutionGroups?.length ? (
<Tooltip title={<SubstitutionTooltip substitutionGroups={course.substitutionGroups} />}>
<Typography sx={{ color: 'text.secondary' }}>
{course?.code}... +{course?.substitutions?.length}
{course?.code}... +{course?.substitutionGroups?.length}
</Typography>
</Tooltip>
) : (
@@ -75,7 +75,7 @@ export const CourseCard = ({
)}
</Box>
<ClearIcon
data-cy={`courseFilter-${course?.code}-clear`}
data-cy={`courseFilter-${course?.groupId}-clear`}
onClick={() => onChange(null)}
sx={{
color: theme => theme.palette.error.dark,
@@ -86,7 +86,7 @@ export const CourseCard = ({
/>
</Stack>
<FilterSelect
filterKey={`courseFilter-${course?.code}`}
filterKey={`courseFilter-${course?.groupId}`}
label="Select course"
onChange={({ target }) => onChange(Number(target.value))}
options={dropdownOptions}
@@ -13,8 +13,9 @@ type Options = {
courses: Record<string, CourseStats>
substitutedBy: Record<string, string[][]>
includeSubstitutions: boolean
idToGroupIdMap: Record<string, string>
}
type Args = { courses: CourseStats[]; includeSubstitutions?: boolean }
type Args = { courses: CourseStats[]; includeSubstitutions?: boolean; idToGroupIdMap: Record<string, string> }
type Precompute = any
const CourseFilterCard = ({ options, onOptionsChange }: FilterTrayProps<Options, Args, Precompute>) => {
@@ -24,18 +25,18 @@ const CourseFilterCard = ({ options, onOptionsChange }: FilterTrayProps<Options,
const { getTextIn } = useLanguage()
const dropdownOptions = Object.values(courseStats)
.filter(cs => !courseFilters[cs.id])
.filter(cs => !courseFilters[cs.groupId])
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
.map(cs => ({
key: `courseFilter-option-${cs.code}`,
key: `courseFilter-option-${cs.groupId}`,
text: `${cs.code} - ${getTextIn(cs.name)}`,
value: cs.code,
value: cs.groupId,
}))
const setCourseFilter = (code: string, type: FTValue | null) => {
const setCourseFilter = (groupId: string, type: FTValue | null) => {
const newOpts = structuredClone(options)
if (type === null) delete newOpts.courseFilters[code]
else newOpts.courseFilters[code] = type
if (type === null) delete newOpts.courseFilters[groupId]
else newOpts.courseFilters[groupId] = type
onOptionsChange(newOpts)
}
@@ -49,12 +50,12 @@ const CourseFilterCard = ({ options, onOptionsChange }: FilterTrayProps<Options,
options={dropdownOptions}
value={null}
/>
{Object.entries(courseFilters).map(([code, type]) => (
{Object.entries(courseFilters).map(([groupId, type]) => (
<CourseCard
course={courseStats[code]}
course={courseStats[groupId]}
filterType={type}
key={`courseFilter-selected-course-${code}`}
onChange={type => setCourseFilter(code, type as FTValue)}
key={`courseFilter-selected-course-${groupId}`}
onChange={type => setCourseFilter(groupId, type as FTValue)}
/>
))}
</>
@@ -71,25 +72,27 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
courses: {},
substitutedBy: {},
includeSubstitutions: true,
idToGroupIdMap: {},
},
precompute: ({
args,
options,
}: {
args: { courses: CourseStats[]; includeSubstitutions?: boolean }
args: Args
options: {
courses?: Record<string, CourseStats>
substitutedBy?: Record<string, string[][]>
includeSubstitutions?: boolean
idToGroupIdMap?: Record<string, string>
}
}) => {
const substitutedBy = args.courses.reduce<Record<string, string[][]>>((acc, course: CourseStats) => {
const { code, substitutionGroups } = course
const { groupId, substitutionGroups } = course
if (substitutionGroups) {
for (const group of substitutionGroups) {
acc[code] ??= []
acc[code].push(group)
acc[groupId] ??= []
acc[groupId].push(group)
}
}
@@ -98,36 +101,42 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
/* option.courses maybe frozen even when it should be used only within the scope of createFilter factory. */ {
delete options.courses
options.courses = Object.fromEntries(args.courses.map(course => [course.code, course]))
options.courses = Object.fromEntries(args.courses.map(course => [course.groupId, course]))
}
options.includeSubstitutions = args.includeSubstitutions ?? true
options.substitutedBy = substitutedBy
options.idToGroupIdMap = args.idToGroupIdMap
},
isActive: ({ courseFilters }) => Object.keys(courseFilters).length > 0,
filter(student: FormattedStudent, { options }) {
const { courses, enrollments } = student
const passedCourseIds = courses
.filter(({ credittypecode }) => credittypecode !== CreditTypeCode.FAILED)
.map(({ course_id }) => course_id)
const toGroupIds = (courseIds: string[]) => courseIds.map(id => options.idToGroupIdMap[id]).filter(Boolean)
const courseIds = courses.map(({ course_id }) => course_id)
const enrollmentCodes = enrollments.map(({ course_id }) => course_id)
const passedCourseGroupIds = toGroupIds(
courses
.filter(({ credittypecode }) => credittypecode !== CreditTypeCode.FAILED)
.map(({ course_id }) => course_id)
)
for (const [mainCode, filterType] of Object.entries(options.courseFilters)) {
const courseGroupIds = toGroupIds(courses.map(({ course_id }) => course_id))
const enrollmentGroupIds = toGroupIds(enrollments.map(({ course_id }) => course_id))
for (const [mainGroupId, filterType] of Object.entries(options.courseFilters)) {
let foundPassed = false
let foundAttainment = false
let foundEnrollment = false
;[[mainCode], ...(options.includeSubstitutions ? (options.substitutedBy?.[mainCode] ?? []) : [])].forEach(
group => {
foundPassed = foundPassed ? true : group.every(code => passedCourseIds.includes(code))
foundAttainment = foundAttainment ? true : group.every(code => courseIds.includes(code))
foundEnrollment = foundEnrollment ? true : group.every(code => enrollmentCodes.includes(code))
}
)
;[
[mainGroupId],
...(options.includeSubstitutions ? (options.substitutedBy?.[mainGroupId] ?? []) : []),
].forEach(group => {
foundPassed = foundPassed ? true : group.every(groupId => passedCourseGroupIds.includes(groupId))
foundAttainment = foundAttainment ? true : group.every(groupId => courseGroupIds.includes(groupId))
foundEnrollment = foundEnrollment ? true : group.every(groupId => enrollmentGroupIds.includes(groupId))
})
switch (filterType) {
case FilterType.ALL:
@@ -150,16 +159,16 @@ export const courseFilter = createFilter<Options, Args, Precompute>({
selectors: {
// NOTE: Remember FilterType.ALL === 0 when checking if courseFilters[course] exists
isCourseSelected: ({ courseFilters }, course) => Object.values(FilterType).includes(courseFilters[course]),
selectedCourseName: ({ courses }, courseCodes) => courses[courseCodes[0]]?.name,
isCourseSelected: ({ courseFilters }, courseGroupId) => Object.values(FilterType).includes(courseFilters[courseGroupId]),
selectedCourseName: ({ courses }, courseGroupIds) => courses[courseGroupIds[0]]?.name,
},
actions: {
toggleCourseSelection: (options, code: string) => {
if (!Object.values(FilterType).includes(options.courseFilters[code])) {
options.courseFilters[code] = FilterType.ALL
toggleCourseSelection: (options, groupId: string) => {
if (!Object.values(FilterType).includes(options.courseFilters[groupId])) {
options.courseFilters[groupId] = FilterType.ALL
} else {
delete options.courseFilters[code]
delete options.courseFilters[groupId]
}
return options
@@ -4,10 +4,16 @@ import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
import { isCourseSelected, toggleCourseSelection } from '@/components/FilterView/filters/courses'
import { useFilters } from '@/components/FilterView/useFilters'
export const CourseFilterToggle = ({ courseCode, courseName }: { courseCode: string; courseName: string }) => {
export const CourseFilterToggle = ({
courseGroupId,
courseName,
}: {
courseGroupId: string
courseName: string
}) => {
const { useFilterSelector, useFilterDispatch: filterDispatch } = useFilters()
const isActive = useFilterSelector(isCourseSelected(courseCode))
const isActive = useFilterSelector(isCourseSelected(courseGroupId))
const title = (
<span>
@@ -18,7 +24,10 @@ export const CourseFilterToggle = ({ courseCode, courseName }: { courseCode: str
return (
<Tooltip arrow placement="top" title={title}>
<div>
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(courseCode))} />
<FilterToggleIcon
isActive={isActive}
onClick={() => filterDispatch(toggleCourseSelection(courseGroupId))}
/>
</div>
</Tooltip>
)
@@ -81,7 +81,7 @@ export const PassFailEnrollments = ({
header: 'Name',
cell: ({ row }) => {
const name = getTextIn(row.original.name)
const { code } = row.original
const { code, groupId } = row.original
const expansionStyle = row.getIsExpanded() ? { transform: 'rotate(90deg)' } : {}
const expansionArrow = row.getCanExpand() ? (
@@ -100,7 +100,7 @@ export const PassFailEnrollments = ({
const linkComponent =
row.originalSubRows === undefined ? (
<Stack flexDirection="row" sx={{ m: 'auto', mr: '0' }}>
<CourseFilterToggle courseCode={code} courseName={name ?? ''} />
<CourseFilterToggle courseGroupId={groupId!} courseName={name ?? ''} />
{!onlyIamRights ? (
<Link
to={`/coursestatistics?courseCodes=["${encodeURIComponent(code)}"]&separate=false&combineSubstitutions=true`}
@@ -49,6 +49,7 @@ export const PopulationCourseStatsFlat = ({
...course,
name: course.course.name,
code: course.course.code,
groupId: course.course.groupId,
stats,
}
})
@@ -56,11 +56,11 @@ export const PopulationCourseStats = ({
.flatMap(course => {
const defaultProgrammeCourses: FilteredProgrammeCourse[] = curriculum.defaultProgrammeCourses
.filter(pg => pg.code === course.course.code)
.map(programmeCourse => ({ ...course, ...programmeCourse }))
.map(programmeCourse => ({ ...course, ...programmeCourse, groupId: course.course.groupId }))
const secondProgrammeCourses: FilteredProgrammeCourse[] = curriculum.secondProgrammeCourses
.filter(pg => pg.code === course.course.code)
.map(programmeCourse => ({ ...course, ...programmeCourse }))
.map(programmeCourse => ({ ...course, ...programmeCourse, groupId: course.course.groupId }))
return [defaultProgrammeCourses, secondProgrammeCourses].flat()
})
@@ -194,7 +194,7 @@ export const PopulationStatistics = () => {
const filters: GenericFilter[] = [
!useUserHasRestrictedAccess() ? ageFilter() : null,
citizenshipFilter(),
courseFilter({ courses }),
courseFilter({ courses, idToGroupIdMap: population?.coursestatistics.idToGroupIdMap ?? {} }),
creditDateFilter(),
creditsEarnedFilter(),
curriculumPeriodFilter(),
@@ -37,6 +37,7 @@ export const useGetFilters = (
filters.tagsFilter(),
filters.courseFilter({
courses: population?.coursestatistics.courses ?? [],
idToGroupIdMap: population?.coursestatistics.idToGroupIdMap ?? {},
}),
filters.creditDateFilter(),
filters.creditsEarnedFilter(),
@@ -20,9 +20,10 @@ export type CourseModule = Pick<Module, 'code'> & {
name: Name
courses: FilteredProgrammeCourse[]
stats?: Partial<FilteredCourseStats>
groupId?: string
}
export type FilteredProgrammeCourse = FilteredCourse & ProgrammeCourse
type FilteredCourseModule = FilteredCourse & { name: Name; code: string }
export type FilteredProgrammeCourse = FilteredCourse & ProgrammeCourse & { groupId: string }
type FilteredCourseModule = FilteredCourse & { name: Name; code: string; groupId: string }
export type UnionOfFilteredModuleCourse = (
| (Omit<FilteredCourseModule, 'stats'> & { stats: Partial<FilteredCourseStats> })
| CourseModule
@@ -235,14 +236,14 @@ export const filterCourses = (
if (!courseStatistics) return []
const coursestats = new Map(
courseStatistics.courses.map(course => [course.id, { course, ...structuredClone(courseBaseStats) }])
courseStatistics.courses.map(course => [course.groupId, { course, ...structuredClone(courseBaseStats) }])
)
for (const { studentNumber, studyrightStart, enrollments, courses } of filteredStudents) {
const studentStartingYear = new Date(studyrightStart).getFullYear()
for (const { course_id, state, enrollment_date_time } of enrollments) {
const course = coursestats.get(course_id)
const course = coursestats.get(courseStatistics.idToGroupIdMap[course_id])
if (!course) continue
const initialDate = new Date(enrollment_date_time)
@@ -255,7 +256,7 @@ export const filterCourses = (
}
for (const { course_id, grade, credittypecode, date } of courses) {
const course = coursestats.get(course_id)
const course = coursestats.get(courseStatistics.idToGroupIdMap[course_id])
if (!course) continue
const passingGrade = [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
+2
View File
@@ -57,7 +57,9 @@ export type CloseToGraduationData = {
}
export type PopulationCourseStats = {
// One row per groupId (isPrimary=true course for that group)
courses: CourseStats[]
idToGroupIdMap: Record<string, string>
enrollments: Pick<
Enrollment,
'course_id' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber' | 'studyright_id'