mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Kone] Progress criteria migration to group id step 1
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
+20
@@ -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`)
|
||||
}
|
||||
|
||||
+58
-51
@@ -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}
|
||||
|
||||
+7
-7
@@ -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]
|
||||
|
||||
+1
-1
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user