[Studyplans] embrace course unit id

This commit is contained in:
joonas-a
2026-09-21 15:24:21 +03:00
committed by Aaro Koivukangas
parent 756338d15f
commit 7af9d19f04
10 changed files with 86 additions and 36 deletions
@@ -173,9 +173,26 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
],
})
return students.map(student => {
const { studyplans, ...rest }: StudentWithStudyplanNested = student.toJSON()
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
const plainStudents = students.map((student): StudentWithStudyplanNested => student.toJSON())
// included_courses holds course ids, with a rare fallback to a raw code for "custom" (non-catalogued)
// entries. Resolve ids to their current code for display - anything missing from the map is already a code.
const includedCourseIds = [
...new Set(
plainStudents.flatMap(student => student.studyplans.flatMap(({ included_courses }) => included_courses))
),
]
const courses: Pick<CourseModel, 'id' | 'code'>[] = await CourseModel.findAll({
attributes: ['id', 'code'],
where: { id: { [Op.in]: includedCourseIds } },
raw: true,
})
const idToCode = Object.fromEntries(courses.map(({ id, code }) => [id, code]))
return plainStudents.map(({ studyplans, ...rest }) => {
const coursesInStudyPlan = studyplans.flatMap(studyplan =>
studyplan.included_courses.map(idOrCode => idToCode[idOrCode] ?? idOrCode)
)
return { ...rest, coursesInStudyPlan }
})
}
@@ -41,7 +41,7 @@ const findThesisAndLatestAndEarliestAttainments = (
thesisData = attainment
}
attainmentDates.latestTotal ??= attainment.attainment_date
if (studyPlan.included_courses.includes(attainment.course?.code)) {
if (studyPlan.included_courses.includes(attainment.course_id)) {
attainmentDates.latestHops ??= attainment.attainment_date
if (
!attainmentDates.earliestHops ||
@@ -230,7 +230,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
},
{
model: CreditModel,
attributes: ['attainment_date', 'grade', 'studyright_id'],
attributes: ['attainment_date', 'grade', 'studyright_id', 'course_id'],
where: { credittypecode: CreditTypeCode.PASSED },
include: [
{
+14 -2
View File
@@ -1,6 +1,6 @@
import { Op } from 'sequelize'
import type { Credit, Student } from '@oodikone/shared/models'
import type { Credit, Student, Studyplan } from '@oodikone/shared/models'
import { CreditTypeCode, EnrollmentState, Unification } from '@oodikone/shared/types'
import { FormattedStudentForSearch, StudentPageStudent } from '@oodikone/shared/types/studentData'
import { enrollmentTimeDateThresholdAcademicYear, splitByEmptySpace } from '@oodikone/shared/util'
@@ -213,7 +213,7 @@ const formatSharedStudentData = ({
updatedAt,
createdAt,
sis_person_id,
}: Student): Omit<StudentPageStudent, 'tags'> => {
}: Student): Omit<StudentPageStudent, 'tags' | 'idToGroupIdMap'> => {
const toCourse = ({ grade, credits, credittypecode, is_open, attainment_date, course, isStudyModule }: Credit) => {
return {
course: {
@@ -251,13 +251,25 @@ const formatSharedStudentData = ({
}
}
const getIdToGroupIdMap = async (studyplans: Pick<Studyplan, 'included_courses' | 'includedModules'>[]) => {
const ids = [...new Set(studyplans.flatMap(plan => [...plan.included_courses, ...plan.includedModules]))]
const courses = await CourseModel.findAll({
attributes: ['id', 'groupId'],
where: { id: { [Op.in]: ids } },
raw: true,
})
return Object.fromEntries(courses.map(({ id, groupId }) => [id, groupId]))
}
export const withStudentNumber = async (studentNumber: string, userId: string): Promise<StudentPageStudent | null> => {
const student = await byStudentNumber(studentNumber)
if (student == null) {
return null
}
const idToGroupIdMap = await getIdToGroupIdMap(student.studyplans)
return {
...formatSharedStudentData(student),
idToGroupIdMap,
tags: student.tags
.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
.map(tag => ({
@@ -43,7 +43,7 @@ export const getStudyRightsInProgramme = async (
include: [
{
model: CreditModel,
attributes: ['attainment_date', 'credits', 'course_code'],
attributes: ['attainment_date', 'credits', 'course_code', 'course_id'],
required: false,
where: {
isStudyModule: false,
@@ -247,7 +247,7 @@ const getMainStatsByTrackAndYear = async (
const latestAttainmentWithinYearAndInHops = dayjs(
studyRight.student.credits
.filter(credit => studyRight.studyPlans.some(sp => sp.included_courses.includes(credit.course_code)))
.filter(credit => studyRight.studyPlans.some(sp => sp.included_courses.includes(credit.course_id)))
.reduce((latest, { attainment_date }) => {
return attainment_date > latest ? attainment_date : latest
}, new Date('1900-01-01'))
@@ -57,7 +57,6 @@ const studentMapper = (
curriculumGroupIds: string[],
substitutionsForGroupId: Record<string, string[][]>,
idToGroupIdMap: Record<string, string>,
codeToGroupId: Record<string, string>,
groupIdToCode: Record<string, string>
) => {
const courseMap = {}
@@ -72,7 +71,7 @@ const studentMapper = (
)
const hopsGroupIds = student.studyplans.flatMap(studyPlan =>
studyPlan.included_courses
.map(code => codeToGroupId[code] ?? code)
.map(id => idToGroupIdMap[id] ?? id)
.filter(groupId => curriculumGroupIds.includes(groupId))
)
@@ -346,19 +345,10 @@ export const CoursesTabContainer = ({ curriculum, students, courses, idToGroupId
curriculumGroupIds,
substitutionsForGroupId,
idToGroupIdMap,
codeToGroupId,
groupIdToCode
)
),
[
students,
includeSubstitutions,
curriculumGroupIds,
substitutionsForGroupId,
idToGroupIdMap,
codeToGroupId,
groupIdToCode,
]
[students, includeSubstitutions, curriculumGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
)
const columns = useGetColumnDefinitions(coursesByParentModule)
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { ModulesTab } from '@/components/PopulationComponents/Students/Table/ModulesTab/ModulesTable'
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { createLocaleComparator } from '@/util/comparator'
import { formatISODate } from '@/util/timeAndDate'
import { Name, ProgrammeCourse, FormattedStudent as Student } from '@oodikone/shared/types'
@@ -18,7 +19,12 @@ export type FormattedModules = Record<string, Name>
type StudyModuleData = { code: string; completed: boolean; completionDate: string | null }
const getModulesFromRelevantStudyPlan = (student: Student, degreeProgrammeCodes: string[]): StudyModuleData[] => {
const getModulesFromRelevantStudyPlan = (
student: Student,
degreeProgrammeCodes: string[],
idToGroupIdMap: Record<string, string>,
groupIdToCode: Record<string, string>
): StudyModuleData[] => {
if (!student.studyRights || !Array.isArray(student.studyRights)) {
return []
}
@@ -35,9 +41,10 @@ const getModulesFromRelevantStudyPlan = (student: Student, degreeProgrammeCodes:
)?.includedModules ?? []
return studyModules.map(studyModule => {
const completion = student.courses.find(course => course.course_code === studyModule && course.passed)
const moduleGroupId = idToGroupIdMap[studyModule] ?? studyModule
const completion = student.courses.find(course => course.course_id === moduleGroupId && course.passed)
return {
code: studyModule,
code: groupIdToCode[moduleGroupId] ?? studyModule,
completed: !!completion,
completionDate: completion ? formatISODate(completion.date) : null,
}
@@ -47,12 +54,17 @@ const getModulesFromRelevantStudyPlan = (student: Student, degreeProgrammeCodes:
return []
}
const formatStudent = (student: Student, degreeProgrammeCodes: string[]): ModuleTabStudent => {
const formatStudent = (
student: Student,
degreeProgrammeCodes: string[],
idToGroupIdMap: Record<string, string>,
groupIdToCode: Record<string, string>
): ModuleTabStudent => {
return {
firstNames: student.firstnames,
lastName: student.lastname,
studentNumber: student.studentNumber,
studyModulesInHOPS: getModulesFromRelevantStudyPlan(student, degreeProgrammeCodes),
studyModulesInHOPS: getModulesFromRelevantStudyPlan(student, degreeProgrammeCodes, idToGroupIdMap, groupIdToCode),
sisPersonID: student.sis_person_id,
}
}
@@ -76,9 +88,13 @@ const getDegreeProgrammeCodes = (curriculumModules): string[] => {
export const ModulesTabContainer = ({
curriculum,
students,
courses,
idToGroupIdMap,
}: {
curriculum: ExtendedCurriculumDetails | null | undefined
students: Student[]
courses: FilteredCourse[]
idToGroupIdMap: Record<string, string>
}) => {
const curriculumModules = useMemo(
() => (curriculum ? [...curriculum.defaultProgrammeModules, ...curriculum.secondProgrammeModules] : []),
@@ -93,9 +109,14 @@ export const ModulesTabContainer = ({
const formattedModules = useMemo(() => getAllModules(curriculumCourses), [curriculumCourses])
const groupIdToCode: Record<string, string> = useMemo(
() => Object.fromEntries(courses.map(({ course }) => [course.groupId, course.code])),
[courses]
)
const formattedStudents = useMemo(
() => students.map(student => formatStudent(student, degreeProgrammeCodes)),
[students, degreeProgrammeCodes]
() => students.map(student => formatStudent(student, degreeProgrammeCodes, idToGroupIdMap, groupIdToCode)),
[students, degreeProgrammeCodes, idToGroupIdMap, groupIdToCode]
)
return <ModulesTab formattedModules={formattedModules} formattedStudents={formattedStudents} />
@@ -106,7 +106,14 @@ export const PopulationStudents = ({
students={filteredStudents}
/>
),
Modules: <ModulesTab curriculum={curriculum} students={filteredStudents} />,
Modules: (
<ModulesTab
courses={filteredCourses ?? []}
curriculum={curriculum}
idToGroupIdMap={idToGroupIdMap ?? {}}
students={filteredStudents}
/>
),
Tags: <TagsTab combinedProgramme={combinedProgramme} programme={programme} students={filteredStudents} />,
Progress: (
<ProgressTab
@@ -19,13 +19,14 @@ import { StudentStudyPlan } from '@oodikone/shared/types/studentData'
const creditsColumnIndex = 3
// Some courses are without AY in the beginning in the studyplan even though the credits are registered with AY.
const isInStudyPlan = (plan: StudentStudyPlan, code: string) =>
const isInStudyPlan = (
plan: StudentStudyPlan,
idToGroupIdMap: Record<string, string>,
course: { code: string; groupId: string }
) =>
plan &&
(plan.included_courses.includes(code) ||
plan.included_courses.includes(code.replace('AY', '')) ||
plan.includedModules.includes(code) ||
code === plan.programme_code)
([...plan.included_courses, ...plan.includedModules].some(id => (idToGroupIdMap[id] ?? id) === course.groupId) ||
course.code === plan.programme_code)
const getAcademicYear = (date: string) => {
const year = new Date(date).getFullYear()
@@ -52,7 +53,7 @@ export const CourseTables = ({ student, selectedStudyPlanId }) => {
const attainment = student.courses[i]
const { course, credits, credittypecode, date, grade, isOpenCourse, isStudyModuleCredit, passed } = attainment
const isIncluded = isInStudyPlan(studyPlan, course.code)
const isIncluded = isInStudyPlan(studyPlan, student.idToGroupIdMap, course)
const academicYear = getAcademicYear(date)
groupedCourses[academicYear] ??= []
+2
View File
@@ -100,6 +100,8 @@ export type StudentPageStudent = {
updatedAt: Date
studyplans: Studyplan[]
sisPersonId: string
// Resolves the ids in each studyplan's included_courses/includedModules to their groupId.
idToGroupIdMap: Record<string, string>
}
export type StudentCourse = {