[Kone] Progress criteria migration to group id step 1

This commit is contained in:
joonas-a
2026-09-29 12:38:40 +03:00
parent fb3ca8b699
commit a8fe237e3d
16 changed files with 288 additions and 144 deletions
+2
View File
@@ -5,6 +5,7 @@ import { initializeDatabaseConnection, dbConnections } from './database/connecti
import { startCron } from './events'
import routes from './routes'
import { backfillExcludedCourseGroupIds } from './services/excludedCourses'
import { backfillProgressCriteriaGroupIds } from './services/studyProgramme/studyProgrammeCriteria'
import logger from './util/logger'
import './worker/worker'
@@ -15,6 +16,7 @@ initializeDatabaseConnection()
dbConnections.on('connect', () => {
logger.info('Connected to sis db successfully')
backfillExcludedCourseGroupIds().catch(error => logger.error(error))
backfillProgressCriteriaGroupIds().catch(error => logger.error(error))
})
dbConnections.on('error', () => {
@@ -0,0 +1,20 @@
const { ARRAY, STRING } = require('sequelize')
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
// Migration step 1 of 2
module.exports = {
up: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.addColumn('progress_criteria', `course_group_ids_year_${year}`, {
type: ARRAY(STRING),
})
}
},
down: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.removeColumn('progress_criteria', `course_group_ids_year_${year}`)
}
},
}
@@ -34,6 +34,24 @@ export class ProgressCriteriaModel extends Model implements ProgressCriteria {
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearSix: ProgressCriteria['coursesYearSix']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearOne: ProgressCriteria['courseGroupIdsYearOne']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearTwo: ProgressCriteria['courseGroupIdsYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearThree: ProgressCriteria['courseGroupIdsYearThree']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearFour: ProgressCriteria['courseGroupIdsYearFour']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearFive: ProgressCriteria['courseGroupIdsYearFive']
@Column(DataType.ARRAY(DataType.STRING))
declare courseGroupIdsYearSix: ProgressCriteria['courseGroupIdsYearSix']
@Column(DataType.INTEGER)
declare creditsYearOne: ProgressCriteria['creditsYearOne']
@@ -26,17 +26,17 @@ router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
interface CriteriaCoursesRequest extends Request {
body: {
code: string
courses: string[]
courseGroupIds: string[]
year: number
}
}
router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
const { code, courses, year } = req.body
if (!code || !courses || !year) {
const { code, courseGroupIds, year } = req.body
if (!code || !courseGroupIds || !year) {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courseGroupIds, year)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
@@ -0,0 +1,21 @@
export const CRITERIA_YEARS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six'] as const
export type CriteriaYear = (typeof CRITERIA_YEARS)[number]
/**
* Converts course codes to course group ids, keeping the order and dropping duplicates.
* Codes that are not in the mapping are returned in `unmapped`.
*/
export const codesToGroupIds = (codes: readonly string[], groupIdByCode: ReadonlyMap<string, string>) => {
const groupIds = new Set<string>()
const unmapped: string[] = []
for (const code of codes) {
const groupId = groupIdByCode.get(code)
if (groupId) {
groupIds.add(groupId)
} else {
unmapped.push(code)
}
}
return { groupIds: [...groupIds], unmapped }
}
@@ -1,7 +1,10 @@
import { Op } from 'sequelize'
import { ProgressCriteria } from '@oodikone/shared/types'
import { CourseModel } from '../../models'
import { ProgressCriteriaModel } from '../../models/kone'
import logger from '../../util/logger'
import { CRITERIA_YEARS, codesToGroupIds } from './criteriaCourseGroupIds'
type CriteriaWithoutCurriculumVersion = Omit<ProgressCriteriaModel, 'curriculumVersion'>
@@ -11,31 +14,67 @@ const getCriteriaByStudyProgramme = async (code: string): Promise<CriteriaWithou
where: { code },
})
/** Construct course_code => substitutionGroups object */
const getSubstitutions = async (codes: string[]) => {
const courses: Array<Pick<CourseModel, 'code' | 'substitutionGroups'>> = await CourseModel.findAll({
attributes: ['code', 'substitutionGroups'],
where: { code: codes },
const getGroupIdByCode = async (codes: string[]) => {
if (codes.length === 0) return new Map<string, string>()
const courses: Array<Pick<CourseModel, 'code' | 'groupId'>> = await CourseModel.findAll({
attributes: ['code', 'groupId'],
where: { code: { [Op.in]: codes } },
raw: true,
})
return new Map(courses.map(({ code, groupId }) => [code, groupId]))
}
/** Construct course_group_id => substitutionGroups object */
const getSubstitutions = async (courseGroupIds: string[]) => {
const courses: Array<Pick<CourseModel, 'groupId' | 'substitutionGroups' | 'isPrimary'>> = await CourseModel.findAll({
attributes: ['groupId', 'substitutionGroups', 'isPrimary'],
where: { groupId: { [Op.in]: courseGroupIds } },
raw: true,
})
// A group can have several rows (one per course code), prefer the primary one
const substitutionsByGroupId = new Map<string, string[][]>()
for (const { groupId, substitutionGroups, isPrimary } of courses) {
if (isPrimary || !substitutionsByGroupId.has(groupId)) {
substitutionsByGroupId.set(groupId, substitutionGroups)
}
}
// Sort substitutionGroups by length, because (usually) the shortest substitution is the "correct" one
return Object.fromEntries(
courses.map(({ code, substitutionGroups }) => [code, substitutionGroups.sort((a, b) => b.length - a.length)])
[...substitutionsByGroupId].map(([groupId, substitutionGroups]) => [
groupId,
[...substitutionGroups].sort((a, b) => b.length - a.length),
])
)
}
/**
* TODO: Remove the fallback to coursesYear* (course codes) once all rows have courseGroupIdsYear*
* populated and the old columns are dropped.
*/
const formatCriteria = async (criteria: CriteriaWithoutCurriculumVersion | null) => {
const yearOne = criteria?.coursesYearOne ?? []
const yearTwo = criteria?.coursesYearTwo ?? []
const yearThree = criteria?.coursesYearThree ?? []
const yearFour = criteria?.coursesYearFour ?? []
const yearFive = criteria?.coursesYearFive ?? []
const yearSix = criteria?.coursesYearSix ?? []
const courseCodes = [...yearOne, ...yearTwo, ...yearThree, ...yearFour, ...yearFive, ...yearSix]
const legacyCodes = CRITERIA_YEARS.flatMap(year =>
criteria && !criteria[`courseGroupIdsYear${year}`] ? (criteria[`coursesYear${year}`] ?? []) : []
)
const groupIdByCode = await getGroupIdByCode(legacyCodes)
const getYear = (year: (typeof CRITERIA_YEARS)[number]) => {
const courseGroupIds = criteria?.[`courseGroupIdsYear${year}`]
if (courseGroupIds) return courseGroupIds
return codesToGroupIds(criteria?.[`coursesYear${year}`] ?? [], groupIdByCode).groupIds
}
const yearOne = getYear('One')
const yearTwo = getYear('Two')
const yearThree = getYear('Three')
const yearFour = getYear('Four')
const yearFive = getYear('Five')
const yearSix = getYear('Six')
const courseGroupIds = [...yearOne, ...yearTwo, ...yearThree, ...yearFour, ...yearFive, ...yearSix]
const formattedCriteria: ProgressCriteria = {
allCourseGroups: await getSubstitutions(courseCodes),
allCourseGroups: await getSubstitutions(courseGroupIds),
courses: { yearOne, yearTwo, yearThree, yearFour, yearFive, yearSix },
credits: {
yearOne: criteria?.creditsYearOne ?? 0,
@@ -56,12 +95,12 @@ const createCriteria = async (
) => {
const newProgrammeCriteria = {
code: studyProgramme,
coursesYearOne: courses.year1,
coursesYearTwo: courses.year2,
coursesYearThree: courses.year3,
coursesYearFour: courses.year4,
coursesYearFive: courses.year5,
coursesYearSix: courses.year6,
courseGroupIdsYearOne: courses.year1,
courseGroupIdsYearTwo: courses.year2,
courseGroupIdsYearThree: courses.year3,
courseGroupIdsYearFour: courses.year4,
courseGroupIdsYearFive: courses.year5,
courseGroupIdsYearSix: courses.year6,
creditsYearOne: credits.year1,
creditsYearTwo: credits.year2,
creditsYearThree: credits.year3,
@@ -109,35 +148,35 @@ export const saveYearlyCreditCriteria = async (studyProgramme: string, credits:
}
}
export const saveYearlyCourseCriteria = async (studyProgramme: string, courses: string[], year: number) => {
export const saveYearlyCourseCriteria = async (studyProgramme: string, courseGroupIds: string[], year: number) => {
const studyProgrammeToUpdate = await getCriteriaByStudyProgramme(studyProgramme)
if (!studyProgrammeToUpdate) {
const courseObj: Record<string, string[]> = { year1: [], year2: [], year3: [], year4: [], year5: [], year6: [] }
const creditObj = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
if (year === 1) {
courseObj.year1 = courses
courseObj.year1 = courseGroupIds
} else if (year === 2) {
courseObj.year2 = courses
courseObj.year2 = courseGroupIds
} else if (year === 3) {
courseObj.year3 = courses
courseObj.year3 = courseGroupIds
} else if (year === 4) {
courseObj.year4 = courses
courseObj.year4 = courseGroupIds
} else if (year === 5) {
courseObj.year5 = courses
courseObj.year5 = courseGroupIds
} else {
courseObj.year6 = courses
courseObj.year6 = courseGroupIds
}
return await createCriteria(studyProgramme, courseObj, creditObj)
}
try {
const years = {
1: 'coursesYearOne',
2: 'coursesYearTwo',
3: 'coursesYearThree',
4: 'coursesYearFour',
5: 'coursesYearFive',
6: 'coursesYearSix',
1: 'courseGroupIdsYearOne',
2: 'courseGroupIdsYearTwo',
3: 'courseGroupIdsYearThree',
4: 'courseGroupIdsYearFour',
5: 'courseGroupIdsYearFive',
6: 'courseGroupIdsYearSix',
} as const
if (!(year in years)) {
throw new Error(`Invalid year: ${year}`)
@@ -145,7 +184,7 @@ export const saveYearlyCourseCriteria = async (studyProgramme: string, courses:
const yearToUpdate = years[year as keyof typeof years]
const updatedCriteria = await studyProgrammeToUpdate.update({ [yearToUpdate]: courses })
const updatedCriteria = await studyProgrammeToUpdate.update({ [yearToUpdate]: courseGroupIds })
return await formatCriteria(updatedCriteria)
} catch (error) {
logger.error(`Updating yearly credit criteria failed: ${error}`)
@@ -158,3 +197,39 @@ export const getCriteria = async (studyProgramme: string) => {
return await formatCriteria(studyProgrammeCriteria)
}
/**
* Fills courseGroupIdsYear* for rows that only have the legacy coursesYear* (course codes). Safe to run repeatedly.
* A year whose codes cannot all be found in the course table is left untouched and reported.
*/
export const backfillProgressCriteriaGroupIds = async () => {
const rows = await ProgressCriteriaModel.findAll()
const rowsToBackfill = rows.filter(row =>
CRITERIA_YEARS.some(year => !row[`courseGroupIdsYear${year}`] && row[`coursesYear${year}`])
)
if (rowsToBackfill.length === 0) return
const allCodes = rowsToBackfill.flatMap(row => CRITERIA_YEARS.flatMap(year => row[`coursesYear${year}`] ?? []))
const groupIdByCode = await getGroupIdByCode([...new Set(allCodes)])
let updatedYears = 0
for (const row of rowsToBackfill) {
const updates: Partial<Record<`courseGroupIdsYear${(typeof CRITERIA_YEARS)[number]}`, string[]>> = {}
for (const year of CRITERIA_YEARS) {
const codes = row[`coursesYear${year}`]
if (row[`courseGroupIdsYear${year}`] || !codes) continue
const { groupIds, unmapped } = codesToGroupIds(codes, groupIdByCode)
if (unmapped.length > 0) {
logger.warn(
`Could not find a course group id for progress criteria codes of ${row.code} (${year}): ${unmapped.join(', ')}`
)
continue
}
updates[`courseGroupIdsYear${year}`] = groupIds
updatedYears++
}
if (Object.keys(updates).length > 0) await row.update(updates)
}
logger.info(`Backfilled course group ids for ${updatedYears} progress criteria years`)
}
@@ -42,23 +42,26 @@ dayjsExtend(isSameOrAfter)
const columnHelper = createColumnHelper<FormattedStudent>()
/** Return course + all of it's substitution groups */
type IdToGroupId = Record<string, string>
/** Return course + all of it's substitution groups (courses are identified by their course group id) */
const getCourseGroups = (
courseCode: string,
courseGroupId: string,
criteria: ProgressCriteria | undefined,
student: FormattedStudent
student: FormattedStudent,
idToGroupId: IdToGroupId
): StudentCourse[][] => {
const studentCourseCodes = student.courses.map(({ course_code }) => course_code)
const studentGroupIds = student.courses.map(({ course_id }) => idToGroupId[course_id])
// Return groups that have all courses in student.courses
const courseGroups: StudentCourse[][] = []
for (const group of [[courseCode]].concat(criteria?.allCourseGroups[courseCode] ?? [])) {
if (group.every(code => studentCourseCodes.includes(code))) {
for (const group of [[courseGroupId]].concat(criteria?.allCourseGroups[courseGroupId] ?? [])) {
if (group.every(groupId => studentGroupIds.includes(groupId))) {
// We know all of the group's courses exist, we just checked that
courseGroups.push(
...group.map(code =>
...group.map(groupId =>
student.courses
.filter(course => course.course_code === code)
.filter(course => idToGroupId[course.course_id] === groupId)
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
)
@@ -115,29 +118,31 @@ const hasFailed = (courseGroups: StudentCourse[][]) =>
return passedCredits.length === 0 ? false : passedCredits.some(course => !course.passed)
})
/** Return enrollments only for main course codes */
const hasEnrolled = (student: FormattedStudent, courseCode: string) =>
student.enrollments?.map(course => course.course_code).includes(courseCode)
/** Return enrollments only for main courses */
const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments?.some(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getEnrollment = (student: FormattedStudent, courseCode: string) =>
student.enrollments.filter(enrollment => enrollment.course_code === courseCode)
const getEnrollment = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
student.enrollments.filter(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
const getRowContent = (
student: FormattedStudent,
courseCode: string,
label: Label,
year: string,
start: dayjs.Dayjs,
end: dayjs.Dayjs,
criteria: ProgressCriteria | undefined
criteria: ProgressCriteria | undefined,
idToGroupId: IdToGroupId
) => {
if (courseCode.includes('Credits')) {
if (label.code.includes('Credits')) {
if (student.criteriaProgress[year]?.credits) {
return <CheckIcon color="success" />
}
return null
}
if (!label.groupId) return null
const courses = getCourseGroups(courseCode, criteria, student)
const courses = getCourseGroups(label.groupId, criteria, student, idToGroupId)
if (hasCreditTransfer(courses)) {
return <SwapHorizIcon color="success" />
@@ -155,7 +160,7 @@ const getRowContent = (
return <CloseIcon color="error" />
}
if (hasEnrolled(student, courseCode)) {
if (hasEnrolled(student, label.groupId, idToGroupId)) {
return <RemoveIcon color="disabled" />
}
@@ -163,16 +168,18 @@ const getRowContent = (
}
const getExcelText = (
courseCode: string,
label: Label,
criteria: ProgressCriteria | undefined,
student: FormattedStudent,
year: string
year: string,
idToGroupId: IdToGroupId
) => {
if (courseCode.includes('Credits')) {
if (label.code.includes('Credits')) {
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
}
if (!label.groupId) return ''
const courseGroups = getCourseGroups(courseCode, criteria, student)
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
if (hasPassedOutsideAcademicYear(courseGroups)) {
const latestCompletedGroup = courseGroups.find(group => group.every(course => course.passed))
@@ -186,8 +193,8 @@ const getExcelText = (
return `Failed ${formatDate(latestFailedCourse ? latestFailedCourse.date : '(substituted)', DateFormat.ISO_DATE)}`
}
if (hasEnrolled(student, courseCode)) {
const enrollment = getEnrollment(student, courseCode)
if (hasEnrolled(student, label.groupId, idToGroupId)) {
const enrollment = getEnrollment(student, label.groupId, idToGroupId)
return `Enrollment ${formatDate(enrollment[0].enrollment_date_time, DateFormat.ISO_DATE)}`
}
@@ -211,18 +218,23 @@ const getCriteriaHeaders = (months: number, programme: string) => {
}
type Label = {
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
code: string
/** Set for course labels */
groupId?: string
name: Name | null
}
export const ProgressTable = ({
curriculum,
idToGroupIdMap,
students,
months,
programme,
studyGuidanceGroupProgramme,
}: {
curriculum?: ExtendedCurriculumDetails | null
idToGroupIdMap: IdToGroupId
students: FormattedStudent[]
months: number
programme: string
@@ -234,18 +246,10 @@ export const ProgressTable = ({
const { semesters: allSemesters } = useSemesters()
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
const creditMonths = [12, 24, 36, 48, 60, 72]
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'code')
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'code')
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'group_id')
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'group_id')
const getCourseName = (courseCode: string): Name | null => {
if (defaultCourses[courseCode]) {
return defaultCourses[courseCode].name
}
if (coursesSecondProgramme[courseCode]) {
return coursesSecondProgramme[courseCode].name
}
return null
}
const getCourse = (courseGroupId: string) => defaultCourses[courseGroupId] ?? coursesSecondProgramme[courseGroupId]
const labelCriteria = Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
acc[year] = [
@@ -257,12 +261,12 @@ export const ProgressTable = ({
sv: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
},
},
...[...(criteria?.courses[year] ?? [])]
.sort((a: string, b: string) => a.localeCompare(b))
.map((courseCode: string) => ({
code: courseCode,
name: getCourseName(courseCode),
})),
...(criteria?.courses[year] ?? [])
.map((courseGroupId: string): Label => {
const course = getCourse(courseGroupId)
return { code: course?.code ?? courseGroupId, groupId: courseGroupId, name: course?.name ?? null }
})
.sort((a, b) => a.code.localeCompare(b.code)),
{
code: 'Criteria',
name: {
@@ -352,12 +356,13 @@ export const ProgressTable = ({
// 0 Failed
// 1 Enrolled
// 2 None of aove
if (!label.groupId) return 2
const criteriaCourses = student.criteriaProgress[year]?.coursesSatisfied
const course = criteriaCourses?.[label.code]
const course = criteriaCourses?.[label.groupId]
if (course) return -1
if (student.courses.find(course => course.course_code === label.code)?.passed === false) return 0
if (!course && hasEnrolled(student, label.code)) return 1
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
if (!course && hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
return 2
}
@@ -365,18 +370,18 @@ export const ProgressTable = ({
return (
labels?.map(label =>
columnHelper.accessor(() => undefined, {
id: `${year}-${label.code}-${label.name?.fi ?? ''}`,
id: `${year}-${label.groupId ?? label.code}-${label.name?.fi ?? ''}`,
header: getTextIn(label.name) ?? label.code,
cell: ({ row: { original: student } }) => {
const title = label.code.includes('Criteria')
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fullfilled`
: getExcelText(label.code, criteria, student, year)
: getExcelText(label, criteria, student, year, idToGroupIdMap)
const content = label.code.includes('Criteria')
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
: label.code.includes('Enrollment')
? getSemesterEnrollmentContent(student, semesters)
: getRowContent(student, label.code, year, start, end, criteria)
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
return (
<Tooltip title={title}>
@@ -496,8 +501,10 @@ export const ProgressTable = ({
}
default: {
const course = student.courses.find(course => course.course_code === label.code)
const enrollment = student.enrollments.find(enrollment => enrollment.course_code === label.code)
const course = student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)
const enrollment = student.enrollments.find(
enrollment => idToGroupIdMap[enrollment.course_id] === label.groupId
)
result = [
[
getTextIn(label.name),
@@ -514,7 +521,7 @@ export const ProgressTable = ({
.filter(([key, _]) => !!key)
),
})),
[students, allSemesters, programme, labelCriteria]
[students, allSemesters, programme, labelCriteria, idToGroupIdMap]
)
const accessorKeys = useMemo(
@@ -589,7 +596,7 @@ export const ProgressTable = ({
}
return columns
}, [criteria, students, curriculum, getTextIn, namesVisible])
}, [criteria, students, curriculum, getTextIn, namesVisible, idToGroupIdMap])
const isCriteriaSet =
criteria && Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0)
@@ -120,6 +120,7 @@ export const PopulationStudents = ({
Progress: (
<ProgressTab
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
months={months ?? Infinity}
programme={programme}
students={filteredStudents}
@@ -27,9 +27,9 @@ export const CriterionLabelSelectButton = ({
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
useEffect(() => {
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.code))
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
setSelectedOptions(selected)
}, [criteria, course.code])
}, [criteria, course.group_id])
const options = [
{
@@ -70,16 +70,16 @@ export const CriterionLabelSelectButton = ({
}
const handleCheckboxChange = (key: string, year: number) => {
let courses: string[]
let courseGroupIds: string[]
if (selectedOptions.includes(key)) {
courses = criteria.courses[key]?.filter((courseCode: string) => courseCode !== course.code)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courses, year })
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
} else {
courses = criteria.courses ? [...criteria.courses[key], course.code] : [course.code]
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
}
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courses, year })
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
setSelectedOptions(prevSelected =>
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
@@ -218,7 +218,7 @@ export const DegreeCourseTable = ({
<Stack direction="row" gap={1}>
{Object.keys(criteria.courses).map(
(year, index) =>
criteria.courses[year].includes(course.code) && (
criteria.courses[year].includes(course.group_id) && (
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
)
)}
@@ -39,8 +39,7 @@ export const getProgressCriteria = (
studyRightStartDate: string,
hops: StudentStudyPlan | undefined,
credits: PopulationCourseStatsCredit[],
idToCode: Record<string, string>,
groupIdToCode: Record<string, string>
idToGroupId: Record<string, string>
) => {
const [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
if (!thereAreCriteria) return criteriaChecked
@@ -50,41 +49,41 @@ export const getProgressCriteria = (
/** Number of credits completed during each academic year */
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
/** Credits produced by a student, resolved from their (possibly historical) course_id to the course's current code */
/** Credits produced by a student, resolved from their (possibly historical) course_id to the course's group id */
const courses = credits
.map(({ attainment_date, course_id, credits, credittypecode }) => ({
course_code: idToCode[course_id],
group_id: idToGroupId[course_id],
credits,
credittypecode,
date: attainment_date,
}))
.filter((course): course is typeof course & { course_code: string } => !!course.course_code)
.filter((course): course is typeof course & { group_id: string } => !!course.group_id)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
Object.entries(criteria.allCourseGroups).map(([mainCourseCode, substitutionGroups]) => {
Object.entries(criteria.allCourseGroups).map(([mainGroupId, substitutionGroups]) => {
const mainCourse = courses.find(
course => course.course_code === mainCourseCode && passedCreditTypeCodes.includes(course.credittypecode)
course => course.group_id === mainGroupId && passedCreditTypeCodes.includes(course.credittypecode)
)
yearMap.forEach(([yearToAdd, criteriaYear]) => {
if (criteria.courses[criteriaYear].includes(mainCourseCode)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode]
if (criteria.courses[criteriaYear].includes(mainGroupId)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId]
// Credit found, course was passed normally
if (mainCourse) {
const courseDate = new Date(mainCourse.date)
// Add date to courses that have been passed
if (!currentDate || courseDate < new Date(currentDate)) {
criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode] = mainCourse.date.toLocaleString()
criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId] = mainCourse.date.toLocaleString()
}
} else {
// Credit for mainCourseCode not found, checking substitution groups (each entry is a groupId, not a code)
const passedCourseCodes = courses
// Credit for mainGroupId not found, checking substitution groups
const passedGroupIds = courses
.filter(course => passedCreditTypeCodes.includes(course.credittypecode))
.map(({ course_code }) => course_code)
.map(({ group_id }) => group_id)
for (const group of substitutionGroups) {
// Add date to the course that has a completed substitution group
if (group.every(groupId => passedCourseCodes.includes(groupIdToCode[groupId]))) {
criteriaChecked[yearToAdd].coursesSatisfied[mainCourseCode] = 'substituted'
if (group.every(groupId => passedGroupIds.includes(groupId))) {
criteriaChecked[yearToAdd].coursesSatisfied[mainGroupId] = 'substituted'
}
}
}
@@ -98,17 +97,15 @@ export const getProgressCriteria = (
const courseDate = new Date(course.date)
if (!(studyRightStartDateFromISO < courseDate)) return
const mainCourseCodes = Object.keys(criteria.allCourseGroups).filter(mainCourseCode => {
if (mainCourseCode === course.course_code) return true
return criteria.allCourseGroups[mainCourseCode].some(group =>
group.some(groupId => groupIdToCode[groupId] === course.course_code)
)
const mainGroupIds = Object.keys(criteria.allCourseGroups).filter(mainGroupId => {
if (mainGroupId === course.group_id) return true
return criteria.allCourseGroups[mainGroupId].some(group => group.includes(course.group_id))
})
// included_courses holds course ids, with a rare fallback to a raw code for "custom" entries
const hopsCodes = hops.included_courses.map(idOrCode => idToCode[idOrCode] ?? idOrCode)
// included_courses holds course ids, resolved to group ids
const hopsGroupIds = hops.included_courses.map(id => idToGroupId[id]).filter(Boolean)
const isInStudyPlan =
hopsCodes.includes(course.course_code) || mainCourseCodes.some(code => hopsCodes.includes(code))
hopsGroupIds.includes(course.group_id) || mainGroupIds.some(groupId => hopsGroupIds.includes(groupId))
if (!isInStudyPlan) return
Object.keys(academicYears)
@@ -29,13 +29,6 @@ export const formatPopulationData = <T extends RequiredFields>(
) => {
const code = query?.programme ?? ''
const groupIdToCode = Object.fromEntries(coursestatistics.courses.map(({ code, groupId }) => [groupId, code]))
const idToCode = Object.fromEntries(
Object.entries(coursestatistics.idToGroupIdMap)
.map(([id, groupId]) => [id, groupIdToCode[groupId]])
.filter((entry): entry is [string, string] => !!entry[1])
)
// TODO: These are typed incorrectly with attainment_date and enrollment_date_time as Dates (they are strings)
const credits = coursestatistics.credits.sort(
(a, b) => new Date(b.attainment_date).getTime() - new Date(a.attainment_date).getTime()
@@ -70,8 +63,7 @@ export const formatPopulationData = <T extends RequiredFields>(
student.studyrightStart,
hops,
credits,
idToCode,
groupIdToCode
coursestatistics.idToGroupIdMap
),
courses: credits.map(credit => {
const passed = [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credit.credittypecode)
@@ -7,13 +7,16 @@ const programmeProgressCriteria = RTKApi.injectEndpoints({
query: ({ programmeCode }) => `/programme-criteria?programmeCode=${programmeCode}`,
providesTags: ['ProgressCriteria'],
}),
addProgressCriteriaCourse: builder.mutation<void, { courses: string[]; programmeCode: string; year: number }>({
query: ({ courses, programmeCode, year }) => ({
addProgressCriteriaCourse: builder.mutation<
void,
{ courseGroupIds: string[]; programmeCode: string; year: number }
>({
query: ({ courseGroupIds, programmeCode, year }) => ({
url: '/programme-criteria/courses',
method: 'POST',
body: {
code: programmeCode,
courses,
courseGroupIds,
year,
},
}),
@@ -6,7 +6,7 @@ import { createCredit as createBaseCredit, createStudyPlan } from '@oodikone/sha
import { describe, it, assert } from 'vitest'
// Course identities: course_id (what credits/enrollments/hops actually reference) -> groupId (stable
// across a course's historical ids/codes) -> code (display, used to key ProgressCriteria)
// across a course's historical ids/codes, used to key ProgressCriteria) -> code (display)
const COURSES = {
CS001: { id: 'id-CS001', groupId: 'group-CS001', code: 'CS001' },
CS002: { id: 'id-CS002', groupId: 'group-CS002', code: 'CS002' },
@@ -15,17 +15,16 @@ const COURSES = {
CS003: { id: 'id-CS003', groupId: 'group-CS003', code: 'CS003' },
} as const
const idToCode = Object.fromEntries(Object.values(COURSES).map(({ id, code }) => [id, code]))
const groupIdToCode = Object.fromEntries(Object.values(COURSES).map(({ groupId, code }) => [groupId, code]))
const idToGroupId = Object.fromEntries(Object.values(COURSES).map(({ id, groupId }) => [id, groupId]))
const createCriteria = (overrides: Partial<ProgressCriteria> = {}): ProgressCriteria => ({
allCourseGroups: {
CS001: [],
CS002: [[COURSES.CS002A.groupId, COURSES.CS002B.groupId]],
[COURSES.CS001.groupId]: [],
[COURSES.CS002.groupId]: [[COURSES.CS002A.groupId, COURSES.CS002B.groupId]],
},
courses: {
yearOne: ['CS001', 'CS002'],
yearTwo: ['CS001', 'CS002'],
yearOne: [COURSES.CS001.groupId, COURSES.CS002.groupId],
yearTwo: [COURSES.CS001.groupId, COURSES.CS002.groupId],
yearThree: [],
yearFour: [],
yearFive: [],
@@ -58,7 +57,7 @@ const getProgress = (
criteria: ProgressCriteria,
hops: ReturnType<typeof createHops> | undefined,
credits: PopulationCourseStatsCredit[]
) => getProgressCriteria(criteria, STUDY_RIGHT_START.toISOString(), hops, credits, idToCode, groupIdToCode)
) => getProgressCriteria(criteria, STUDY_RIGHT_START.toISOString(), hops, credits, idToGroupId)
void describe('getProgressCriteria', () => {
void it('returns empty criteria when no criteria courses or credits are defined', () => {
@@ -89,8 +88,8 @@ void describe('getProgressCriteria', () => {
createCredit(COURSES.CS001.id, 5, CreditTypeCode.PASSED, new Date('2024-09-01')),
])
assert.ok(result.year1.coursesSatisfied.CS001)
assert.equal(result.year1.coursesSatisfied.CS002, null)
assert.ok(result.year1.coursesSatisfied[COURSES.CS001.groupId])
assert.equal(result.year1.coursesSatisfied[COURSES.CS002.groupId], null)
assert.equal(result.year1.totalSatisfied, 1)
assert.equal(result.year1.credits, false)
})
@@ -101,8 +100,8 @@ void describe('getProgressCriteria', () => {
createCredit(COURSES.CS002B.id, 2, CreditTypeCode.PASSED, new Date('2024-09-02')),
])
assert.equal(result.year1.coursesSatisfied.CS002, 'substituted')
assert.equal(result.year1.coursesSatisfied.CS001, null)
assert.equal(result.year1.coursesSatisfied[COURSES.CS002.groupId], 'substituted')
assert.equal(result.year1.coursesSatisfied[COURSES.CS001.groupId], null)
assert.equal(result.year1.totalSatisfied, 1)
})
@@ -5,12 +5,19 @@ export type ProgressCriteriaCreation = Optional<ProgressCriteria, 'curriculumVer
export type ProgressCriteria = {
code: string
curriculumVersion: string
// TODO: Remove coursesYear* once all rows have been migrated to courseGroupIdsYear*
coursesYearOne: string[]
coursesYearTwo: string[]
coursesYearThree: string[]
coursesYearFour: string[]
coursesYearFive: string[]
coursesYearSix: string[]
courseGroupIdsYearOne: string[] | null
courseGroupIdsYearTwo: string[] | null
courseGroupIdsYearThree: string[] | null
courseGroupIdsYearFour: string[] | null
courseGroupIdsYearFive: string[] | null
courseGroupIdsYearSix: string[] | null
creditsYearOne: number
creditsYearTwo: number
creditsYearThree: number
@@ -1,5 +1,7 @@
export type ProgressCriteria = {
/** Course group id => substitution groups (each entry is an array of course group ids) */
allCourseGroups: Record<string, string[][]>
/** Course group ids of the criteria courses for each year */
courses: {
yearOne: string[]
yearTwo: string[]