mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Course rework] finishing touches, mainly programme courses
This commit is contained in:
committed by
Aaro Koivukangas
parent
359e23ef84
commit
eabe1406be
@@ -84,6 +84,10 @@ export class CourseModel extends Model implements Course {
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
|
||||
declare enrollments: Course['enrollments']
|
||||
|
||||
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
|
||||
@BelongsToMany(() => OrganizationModel, {
|
||||
through: () => CourseProviderModel,
|
||||
foreignKey: 'coursecode',
|
||||
sourceKey: 'groupId',
|
||||
})
|
||||
declare organizations: Course['organizations']
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { OrganizationModel } from './organization'
|
||||
export class CourseProviderModel extends Model implements CourseProvider {
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
// TODO: RENAME: this is courseGroupId, not code
|
||||
declare coursecode: CourseProvider['coursecode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
@@ -20,6 +21,7 @@ export class CourseProviderModel extends Model implements CourseProvider {
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
// TODO: RENAME: this is organizationId, not code
|
||||
declare organizationcode: CourseProvider['organizationcode']
|
||||
|
||||
@CreatedAt
|
||||
|
||||
@@ -45,7 +45,11 @@ export class OrganizationModel extends Model implements Organization {
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
declare SISStudyRights: Organization['SISStudyRights']
|
||||
|
||||
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
|
||||
@BelongsToMany(() => CourseModel, {
|
||||
through: () => CourseProviderModel,
|
||||
foreignKey: 'organizationcode',
|
||||
targetKey: 'groupId',
|
||||
})
|
||||
declare courses: Organization['courses']
|
||||
|
||||
@CreatedAt
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memoize } from 'lodash-es'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { Name, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThresholdAcademicYear } from '@oodikone/shared/util'
|
||||
import { dateIsBetween } from '@oodikone/shared/util/datetime'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
@@ -9,9 +9,9 @@ import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, Programme
|
||||
import logger from '../../util/logger'
|
||||
|
||||
export const getAllProgrammeCourses = async (providerCode: string) => {
|
||||
const courses: Pick<CourseModel, 'id' | 'code' | 'name' | 'substitution_groups'>[] = await CourseModel.findAll({
|
||||
const courses: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name'>[] = await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['id', 'code', 'name', 'substitution_groups'],
|
||||
attributes: ['id', 'groupId', 'code', 'name'],
|
||||
include: [
|
||||
{
|
||||
model: OrganizationModel,
|
||||
@@ -29,198 +29,210 @@ export const getAllProgrammeCourses = async (providerCode: string) => {
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getCoursesByGroupIds = async (groupIds: string[]) => {
|
||||
if (groupIds.length === 0) return []
|
||||
return (
|
||||
await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['id'],
|
||||
where: { groupId: { [Op.in]: groupIds } },
|
||||
})
|
||||
).map(course => course.id)
|
||||
}
|
||||
|
||||
export const getPrimaryCoursesByGroupIds = async (
|
||||
groupIds: string[]
|
||||
): Promise<Pick<CourseModel, 'code' | 'name' | 'groupId'>[]> => {
|
||||
if (groupIds.length === 0) return []
|
||||
return CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['code', 'name', 'groupId'],
|
||||
where: { groupId: { [Op.in]: groupIds }, isPrimary: true },
|
||||
})
|
||||
}
|
||||
|
||||
type NotCompletedCourseRow = {
|
||||
groupId: string
|
||||
isStudyModule: boolean
|
||||
allNotPassed: number
|
||||
}
|
||||
|
||||
export const getNotCompletedForProgrammeCourses = async (
|
||||
from: Date,
|
||||
to: Date,
|
||||
programmeCourses: string[],
|
||||
programmeCourseIds: string[],
|
||||
yearRanges: Array<{ year: number; from: Date; to: Date }>,
|
||||
start: Date,
|
||||
end: Date
|
||||
) => {
|
||||
): Promise<Map<number, NotCompletedCourseRow[]>> => {
|
||||
const emptyResult = new Map<number, NotCompletedCourseRow[]>(yearRanges.map(({ year }) => [year, []]))
|
||||
|
||||
try {
|
||||
const enrollments: Array<
|
||||
Pick<EnrollmentModel, 'studentnumber' | 'course_code' | 'enrollment_date_time' | 'course'>
|
||||
> = await EnrollmentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber', 'course_code', 'enrollment_date_time'],
|
||||
include: {
|
||||
model: CourseModel,
|
||||
attributes: ['is_study_module'],
|
||||
},
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: programmeCourses,
|
||||
const [enrollments, credits]: [
|
||||
Pick<EnrollmentModel, 'studentnumber' | 'enrollment_date_time' | 'course'>[],
|
||||
Pick<CreditModel, 'student_studentnumber' | 'credittypecode' | 'isStudyModule' | 'attainment_date' | 'course'>[],
|
||||
] = await Promise.all([
|
||||
EnrollmentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber', 'enrollment_date_time'],
|
||||
include: {
|
||||
model: CourseModel,
|
||||
attributes: ['isStudyModule', 'groupId'],
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
where: {
|
||||
course_id: {
|
||||
[Op.in]: programmeCourseIds,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.between]: [start, end],
|
||||
[Op.gte]: enrollmentTimeDateThresholdAcademicYear, // This has to be Academic year to match with Course statistics which doesn't display enrollments for ...-2021
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
order: [['enrollment_date_time', 'DESC']],
|
||||
}),
|
||||
CreditModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['student_studentnumber', 'credittypecode', 'isStudyModule', 'attainment_date'],
|
||||
include: {
|
||||
model: CourseModel,
|
||||
attributes: ['groupId'],
|
||||
},
|
||||
where: {
|
||||
course_id: {
|
||||
[Op.in]: programmeCourseIds,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.between]: [start, end],
|
||||
[Op.gte]: enrollmentTimeDateThresholdAcademicYear, // This has to be Academic year to match with Course statistics which doesn't display enrollments for ...-2021
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
order: [['enrollment_date_time', 'DESC']],
|
||||
})
|
||||
order: [
|
||||
['attainment_date', 'DESC'],
|
||||
['credittypecode', 'ASC'],
|
||||
], // Passed credits for a student have to be handled first (only PASSED, and FAILED matter)
|
||||
}),
|
||||
])
|
||||
|
||||
// TODO: This is being called for each year, could be optimized to call only once
|
||||
const credits: Array<
|
||||
Pick<
|
||||
CreditModel,
|
||||
'course_code' | 'student_studentnumber' | 'credittypecode' | 'isStudyModule' | 'attainment_date'
|
||||
>
|
||||
> = await CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule', 'attainment_date'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: programmeCourses,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.between]: [start, end],
|
||||
},
|
||||
},
|
||||
order: [
|
||||
['attainment_date', 'DESC'],
|
||||
['credittypecode', 'ASC'],
|
||||
], // Passed credits for a student have to be handled first (only PASSED, and FAILED matter)
|
||||
})
|
||||
const creditKey = (studentNumber: string, courseGroupId: string) => `${studentNumber}::${courseGroupId}`
|
||||
|
||||
const enrolledStudentsByCourseCode = new Map<string, Set<string>>()
|
||||
const everPassedByStudentCourse = new Set<string>()
|
||||
const latestFailedDateByStudentCourse = new Map<string, Date>()
|
||||
|
||||
// Pick only this (between to, from) year's credits
|
||||
const filteredCredits = credits.filter(credit => dateIsBetween(credit.attainment_date, from, to))
|
||||
for (const credit of credits) {
|
||||
const groupId = credit.course.groupId
|
||||
|
||||
const filteredEnrollments = enrollments
|
||||
// Only include the first enrollment for a course for a student.
|
||||
// This assumes that enrollments are sorted in DESC order by enrollment_date_time
|
||||
.filter(({ course_code: courseCode, studentnumber: studentNumber }) => {
|
||||
const courseEnrollments = enrolledStudentsByCourseCode.get(courseCode)
|
||||
const hasEnrolled = courseEnrollments?.has(studentNumber) ?? false
|
||||
|
||||
if (!courseEnrollments) enrolledStudentsByCourseCode.set(courseCode, new Set<string>())
|
||||
if (!hasEnrolled) enrolledStudentsByCourseCode.get(courseCode)!.add(studentNumber)
|
||||
|
||||
return !hasEnrolled
|
||||
})
|
||||
.filter(enrollment => dateIsBetween(enrollment.enrollment_date_time, from, to))
|
||||
|
||||
const courseCodeToDetails = (
|
||||
await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['code', 'name', 'groupId'],
|
||||
where: {
|
||||
code: programmeCourses,
|
||||
},
|
||||
})
|
||||
).reduce<Map<string, { name: Name; groupId: string }>>((acc, val) => {
|
||||
acc.set(val.code, { name: val.name, groupId: val.groupId })
|
||||
return acc
|
||||
}, new Map())
|
||||
|
||||
const creditCourses = filteredCredits.map(credit => ({
|
||||
code: credit.course_code,
|
||||
studentNumber: credit.student_studentnumber,
|
||||
creditTypeCode: credit.credittypecode,
|
||||
courseName: courseCodeToDetails.get(credit.course_code)!.name,
|
||||
groupId: courseCodeToDetails.get(credit.course_code)!.groupId,
|
||||
isStudyModule: credit.isStudyModule,
|
||||
attainmentDate: credit.attainment_date,
|
||||
}))
|
||||
|
||||
const passedByCourseCodes = new Map<string, Set<string>>()
|
||||
const failedByCourseCodes = new Map<string, Set<string>>()
|
||||
const notCompletedByCourseCodes = new Map<string, Set<string>>()
|
||||
const courses = new Map<string, { name: Name; groupId: string; isStudyModule: boolean }>()
|
||||
|
||||
const studentHasPassedCourse = (studentNumber: string, courseCode: string): boolean =>
|
||||
(passedByCourseCodes.get(courseCode)?.has(studentNumber) ?? false) ||
|
||||
!!credits.find(
|
||||
credit =>
|
||||
credit.student_studentnumber === studentNumber &&
|
||||
credit.course_code === courseCode &&
|
||||
CreditModel.passed(credit)
|
||||
)
|
||||
|
||||
/** Student has already been counted as failed or there is a newer failed credit */
|
||||
const studentHasFailedCourse = (
|
||||
studentNumber: string,
|
||||
courseCode: string,
|
||||
attainmentDate: Date = new Date(0)
|
||||
): boolean =>
|
||||
(failedByCourseCodes.get(courseCode)?.has(studentNumber) ?? false) ||
|
||||
!!credits.find(
|
||||
credit =>
|
||||
credit.student_studentnumber === studentNumber &&
|
||||
credit.course_code === courseCode &&
|
||||
CreditModel.failed(credit) &&
|
||||
credit.attainment_date > attainmentDate
|
||||
)
|
||||
|
||||
const studentHasCredit = (studentNumber: string, courseCode: string): boolean =>
|
||||
studentHasPassedCourse(studentNumber, courseCode) || studentHasFailedCourse(studentNumber, courseCode)
|
||||
|
||||
for (const {
|
||||
code: courseCode,
|
||||
courseName,
|
||||
groupId,
|
||||
creditTypeCode,
|
||||
isStudyModule,
|
||||
studentNumber,
|
||||
attainmentDate,
|
||||
} of creditCourses) {
|
||||
if (!courses.has(courseCode)) {
|
||||
courses.set(courseCode, {
|
||||
isStudyModule,
|
||||
name: courseName,
|
||||
groupId,
|
||||
})
|
||||
passedByCourseCodes.set(courseCode, new Set())
|
||||
failedByCourseCodes.set(courseCode, new Set())
|
||||
notCompletedByCourseCodes.set(courseCode, new Set())
|
||||
if (CreditModel.passed(credit)) {
|
||||
everPassedByStudentCourse.add(creditKey(credit.student_studentnumber, groupId))
|
||||
}
|
||||
|
||||
if (CreditModel.passed({ credittypecode: creditTypeCode })) {
|
||||
passedByCourseCodes.get(courseCode)!.add(studentNumber)
|
||||
}
|
||||
if (
|
||||
CreditModel.failed({ credittypecode: creditTypeCode }) &&
|
||||
!studentHasPassedCourse(studentNumber, courseCode) &&
|
||||
!studentHasFailedCourse(studentNumber, courseCode, attainmentDate)
|
||||
) {
|
||||
failedByCourseCodes.get(courseCode)!.add(studentNumber)
|
||||
if (CreditModel.failed(credit)) {
|
||||
const mapKey = creditKey(credit.student_studentnumber, groupId)
|
||||
const latestFailedDate = latestFailedDateByStudentCourse.get(mapKey)
|
||||
if (!latestFailedDate || credit.attainment_date > latestFailedDate) {
|
||||
latestFailedDateByStudentCourse.set(mapKey, credit.attainment_date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add course details to courseMap if there are only enrollments
|
||||
for (const { studentnumber: studentNumber, course_code: courseCode, course } of filteredEnrollments) {
|
||||
if (!courses.has(courseCode)) {
|
||||
courses.set(courseCode, {
|
||||
isStudyModule: course.is_study_module,
|
||||
name: courseCodeToDetails.get(courseCode)!.name,
|
||||
groupId: courseCodeToDetails.get(courseCode)!.groupId,
|
||||
})
|
||||
passedByCourseCodes.set(courseCode, new Set())
|
||||
failedByCourseCodes.set(courseCode, new Set())
|
||||
notCompletedByCourseCodes.set(courseCode, new Set())
|
||||
const enrolledStudentsByCourseGroupId = new Map<string, Set<string>>()
|
||||
|
||||
// Only include the latest enrollment for a course per student per given time range.
|
||||
// This assumes that enrollments are sorted in DESC order by enrollment_date_time
|
||||
const dedupedEnrollments = enrollments.filter(({ course, studentnumber: studentNumber }) => {
|
||||
const groupId = course.groupId
|
||||
const courseEnrollments = enrolledStudentsByCourseGroupId.get(groupId)
|
||||
const hasEnrolled = !!courseEnrollments?.has(studentNumber)
|
||||
|
||||
if (!courseEnrollments) enrolledStudentsByCourseGroupId.set(groupId, new Set<string>())
|
||||
if (!hasEnrolled) enrolledStudentsByCourseGroupId.get(groupId)!.add(studentNumber)
|
||||
|
||||
return !hasEnrolled
|
||||
})
|
||||
|
||||
const result = new Map<number, NotCompletedCourseRow[]>()
|
||||
|
||||
for (const { year, from, to } of yearRanges) {
|
||||
const filteredCredits = credits.filter(credit => dateIsBetween(credit.attainment_date, from, to))
|
||||
const filteredEnrollments = dedupedEnrollments.filter(enrollment =>
|
||||
dateIsBetween(enrollment.enrollment_date_time, from, to)
|
||||
)
|
||||
|
||||
// Keys are groupIds
|
||||
const failedByCourse = new Map<string, Set<string>>()
|
||||
const notCompletedByCourse = new Map<string, Set<string>>()
|
||||
const courses = new Map<string, { isStudyModule: boolean }>()
|
||||
|
||||
const studentHasPassedCourse = (studentNumber: string, courseGroupId: string): boolean =>
|
||||
everPassedByStudentCourse.has(creditKey(studentNumber, courseGroupId))
|
||||
|
||||
// Whether there is a newer failed credit
|
||||
const studentHasFailedCourse = (
|
||||
studentNumber: string,
|
||||
courseGroupId: string,
|
||||
attainmentDate: Date = new Date(0)
|
||||
): boolean => {
|
||||
const latestFailedDate = latestFailedDateByStudentCourse.get(creditKey(studentNumber, courseGroupId))
|
||||
return !!latestFailedDate && latestFailedDate > attainmentDate
|
||||
}
|
||||
|
||||
if (!studentHasCredit(studentNumber, courseCode)) {
|
||||
notCompletedByCourseCodes.get(courseCode)!.add(studentNumber)
|
||||
}
|
||||
}
|
||||
const studentHasCredit = (studentNumber: string, courseGroupId: string): boolean =>
|
||||
studentHasPassedCourse(studentNumber, courseGroupId) || studentHasFailedCourse(studentNumber, courseGroupId)
|
||||
|
||||
return [...courses.entries()].map(([code, { isStudyModule, name, groupId }]) => {
|
||||
return {
|
||||
code,
|
||||
name,
|
||||
groupId,
|
||||
const ensureCourseExists = (groupId: string, isStudyModule: boolean) => {
|
||||
if (!courses.has(groupId)) {
|
||||
courses.set(groupId, {
|
||||
isStudyModule,
|
||||
})
|
||||
failedByCourse.set(groupId, new Set())
|
||||
notCompletedByCourse.set(groupId, new Set())
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
credittypecode: creditTypeCode,
|
||||
isStudyModule,
|
||||
allNotPassed: (notCompletedByCourseCodes.get(code)?.size ?? 0) + (failedByCourseCodes.get(code)?.size ?? 0),
|
||||
student_studentnumber: studentNumber,
|
||||
attainment_date: attainmentDate,
|
||||
course,
|
||||
} of filteredCredits) {
|
||||
const groupId = course.groupId
|
||||
ensureCourseExists(groupId, isStudyModule)
|
||||
|
||||
if (
|
||||
CreditModel.failed({ credittypecode: creditTypeCode }) &&
|
||||
!studentHasPassedCourse(studentNumber, groupId) &&
|
||||
!studentHasFailedCourse(studentNumber, groupId, attainmentDate)
|
||||
) {
|
||||
failedByCourse.get(groupId)!.add(studentNumber)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add course details to courseMap if there are only enrollments
|
||||
for (const { studentnumber: studentNumber, course } of filteredEnrollments) {
|
||||
const groupId = course.groupId
|
||||
ensureCourseExists(groupId, course.isStudyModule)
|
||||
|
||||
if (!studentHasCredit(studentNumber, groupId)) {
|
||||
notCompletedByCourse.get(groupId)!.add(studentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
result.set(
|
||||
year,
|
||||
[...courses.entries()].map(([groupId, { isStudyModule }]) => ({
|
||||
groupId,
|
||||
isStudyModule,
|
||||
allNotPassed: (notCompletedByCourse.get(groupId)?.size ?? 0) + (failedByCourse.get(groupId)?.size ?? 0),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error(`getNotCompletedForProgrammeCourses failed ${error}`)
|
||||
return []
|
||||
return emptyResult
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ const { sequelize } = dbConnections
|
||||
/**
|
||||
NOTE:
|
||||
1. There are some attainments that are linked to a valid study right, but the attainmentdate
|
||||
is outside the study right's validity period. (maybe human error when the credits were registered)
|
||||
is outside the study right's validity period.
|
||||
2. There are some attainments with no attached study right at all.
|
||||
3. Possibly some other edge cases.
|
||||
|
||||
@@ -69,24 +69,18 @@ NOTE:
|
||||
degree student -> has any degree (bsc / msc) study right in the university
|
||||
openuni -> only if no other study rights
|
||||
**/
|
||||
export const getProgrammeCourseAggregates = async (params: {
|
||||
courseCodes: string[]
|
||||
from: Date
|
||||
export const getProgrammeCourseAggregates = async (
|
||||
courseIds: string[],
|
||||
from: Date,
|
||||
to: Date
|
||||
}): Promise<ProgrammeCourseCreditRow[]> => {
|
||||
const { courseCodes, from, to } = params
|
||||
|
||||
if (courseCodes.length === 0) {
|
||||
return []
|
||||
}
|
||||
): Promise<ProgrammeCourseCreditRow[]> => {
|
||||
if (!courseIds.length) return []
|
||||
|
||||
try {
|
||||
return await sequelize.query<ProgrammeCourseCreditRow>(
|
||||
`
|
||||
WITH filtered_credits AS (
|
||||
SELECT
|
||||
cr.course_code AS "courseCode",
|
||||
co.name AS "courseName",
|
||||
co.group_id AS "groupId",
|
||||
cr.student_studentnumber AS "studentNumber",
|
||||
cr.attainment_date AS "attainmentDate",
|
||||
@@ -94,15 +88,14 @@ export const getProgrammeCourseAggregates = async (params: {
|
||||
cr."isStudyModule" AS "isStudyModule"
|
||||
FROM
|
||||
credit cr
|
||||
INNER JOIN course co ON co.code = cr.course_code
|
||||
INNER JOIN course co ON co.id = cr.course_id
|
||||
WHERE
|
||||
cr.course_code IN (:courseCodes)
|
||||
cr.course_id IN (:courseIds)
|
||||
AND co.group_id is NOT NULL
|
||||
AND cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.credittypecode IN (:creditTypes)
|
||||
)
|
||||
SELECT
|
||||
fc."courseCode",
|
||||
fc."courseName",
|
||||
fc."groupId",
|
||||
fc."studentNumber",
|
||||
fc."attainmentDate",
|
||||
@@ -177,7 +170,7 @@ export const getProgrammeCourseAggregates = async (params: {
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
courseCodes,
|
||||
courseIds,
|
||||
from,
|
||||
to,
|
||||
creditTypes: [CreditTypeCode.PASSED, CreditTypeCode.APPROVED],
|
||||
@@ -196,23 +189,17 @@ export const getProgrammeCourseAggregates = async (params: {
|
||||
}
|
||||
}
|
||||
|
||||
export const getTransferCourseAggregates = async (params: {
|
||||
courseCodes: string[]
|
||||
from: Date
|
||||
export const getTransferCourseAggregates = async (
|
||||
courseIds: string[],
|
||||
from: Date,
|
||||
to: Date
|
||||
}): Promise<TransferCourseCreditRow[]> => {
|
||||
const { courseCodes, from, to } = params
|
||||
|
||||
if (courseCodes.length === 0) {
|
||||
return []
|
||||
}
|
||||
): Promise<TransferCourseCreditRow[]> => {
|
||||
if (!courseIds.length) return []
|
||||
|
||||
try {
|
||||
return await sequelize.query<TransferCourseCreditRow>(
|
||||
`
|
||||
SELECT
|
||||
cr.course_code AS "courseCode",
|
||||
co.name AS "courseName",
|
||||
co.group_id AS "groupId",
|
||||
cr.student_studentnumber AS "studentNumber",
|
||||
cr.attainment_date AS "attainmentDate",
|
||||
@@ -220,16 +207,17 @@ export const getTransferCourseAggregates = async (params: {
|
||||
cr."isStudyModule" AS "isStudyModule"
|
||||
FROM
|
||||
credit cr
|
||||
INNER JOIN course co ON co.code = cr.course_code
|
||||
INNER JOIN course co ON co.id = cr.course_id
|
||||
WHERE
|
||||
cr.course_code IN (:courseCodes)
|
||||
cr.course_id IN (:courseIds)
|
||||
AND co.group_id IS NOT NULL
|
||||
AND cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.credittypecode = :creditType
|
||||
`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
courseCodes,
|
||||
courseIds,
|
||||
from,
|
||||
to,
|
||||
creditType: CreditTypeCode.APPROVED,
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Name, StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
|
||||
import { StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
|
||||
import { mapToProviders, range, yearCodeToYear } from '@oodikone/shared/util'
|
||||
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
|
||||
import { now } from '../../util/clock'
|
||||
|
||||
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
|
||||
import {
|
||||
getAllProgrammeCourses,
|
||||
getCoursesByGroupIds,
|
||||
getCurrentStudyYearStartDate,
|
||||
getNotCompletedForProgrammeCourses,
|
||||
getPrimaryCoursesByGroupIds,
|
||||
} from '.'
|
||||
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
|
||||
import logger from '../../util/logger'
|
||||
|
||||
const START_YEAR = 2017
|
||||
const JULY = 6
|
||||
@@ -18,22 +25,12 @@ const getFrom = (yearType: YearType, year: number) =>
|
||||
const getTo = (yearType: YearType, year: number) =>
|
||||
yearType === 'ACADEMIC_YEAR' ? new Date(year + 1, JULY, 31, 23, 59, 59) : new Date(year, 11, 31, 23, 59, 59)
|
||||
|
||||
const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
|
||||
const getAllStudyProgrammeCourseGroupIds = async (studyProgramme: string): Promise<string[]> => {
|
||||
const providerCode = mapToProviders([studyProgramme])[0]
|
||||
if (!providerCode) return []
|
||||
|
||||
const normalCourses = await getAllProgrammeCourses(providerCode)
|
||||
const courseCodes = new Set<string>()
|
||||
|
||||
for (const course of normalCourses) {
|
||||
courseCodes.add(course.code)
|
||||
// We want all AY course codes variants, even from partially completed susbtitution groups
|
||||
for (const code of course?.substitution_groups?.flat() ?? []) {
|
||||
if (code === 'AY' + course.code) courseCodes.add(code)
|
||||
}
|
||||
}
|
||||
|
||||
return [...courseCodes]
|
||||
return [...new Set(normalCourses.map(course => course.groupId))]
|
||||
}
|
||||
|
||||
const getYearKey = (date: Date, isAcademicYear: boolean) => {
|
||||
@@ -58,9 +55,7 @@ type YearAccumulator = {
|
||||
}
|
||||
|
||||
type CourseAccumulator = {
|
||||
code: string
|
||||
groupId: string
|
||||
name: Name
|
||||
isStudyModule: boolean
|
||||
years: Map<number, YearAccumulator>
|
||||
}
|
||||
@@ -81,26 +76,18 @@ const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
|
||||
|
||||
const ensureCourse = (
|
||||
courseMap: Map<string, CourseAccumulator>,
|
||||
code: string,
|
||||
groupId: string,
|
||||
name: Name,
|
||||
isStudyModule: boolean
|
||||
): CourseAccumulator => {
|
||||
if (!courseMap.has(code)) {
|
||||
courseMap.set(code, {
|
||||
code,
|
||||
if (!courseMap.has(groupId)) {
|
||||
courseMap.set(groupId, {
|
||||
groupId,
|
||||
name,
|
||||
isStudyModule,
|
||||
years: new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
const course = courseMap.get(code)!
|
||||
course.isStudyModule = course.isStudyModule || isStudyModule
|
||||
course.name = course.name ?? name
|
||||
course.groupId = course.groupId ?? groupId
|
||||
return course
|
||||
return courseMap.get(groupId)!
|
||||
}
|
||||
|
||||
const ensureYear = (course: CourseAccumulator, year: number, isStudyModule: boolean) => {
|
||||
@@ -158,30 +145,35 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
return []
|
||||
}
|
||||
|
||||
const mainProgrammeCourses = await getAllStudyProgrammeCourses(studyProgramme)
|
||||
const secondProgrammeCourses = combinedProgramme ? await getAllStudyProgrammeCourses(combinedProgramme) : []
|
||||
const programmeCourses = [...new Set([...mainProgrammeCourses, ...secondProgrammeCourses])]
|
||||
const [mainProgrammeGroupIds, secondProgrammeGroupIds] = await Promise.all([
|
||||
getAllStudyProgrammeCourseGroupIds(studyProgramme),
|
||||
combinedProgramme ? getAllStudyProgrammeCourseGroupIds(combinedProgramme) : [],
|
||||
])
|
||||
|
||||
if (programmeCourses.length === 0) {
|
||||
return []
|
||||
}
|
||||
const programmeGroupIds = [...new Set([...mainProgrammeGroupIds, ...secondProgrammeGroupIds])]
|
||||
|
||||
if (!programmeGroupIds.length) return []
|
||||
|
||||
const programmeCourseIds = [...new Set(await getCoursesByGroupIds(programmeGroupIds))]
|
||||
|
||||
const firstYear = yearRange[0]
|
||||
const lastYear = yearRange[yearRange.length - 1]
|
||||
const from = getFrom(academicYear, firstYear)
|
||||
const to = getTo(academicYear, lastYear)
|
||||
|
||||
const notCompletedByYearPromise = getNotCompletedForProgrammeCourses(
|
||||
programmeCourseIds,
|
||||
yearRange.map(year => ({ year, from: getFrom(academicYear, year), to: getTo(academicYear, year) })),
|
||||
from,
|
||||
to
|
||||
)
|
||||
const primaryByGroupIdPromise = getPrimaryCoursesByGroupIds(programmeGroupIds).then(
|
||||
rows => new Map(rows.map(({ groupId, code, name }) => [groupId, { code, name }]))
|
||||
)
|
||||
|
||||
const [programmeCredits, transferCredits] = await Promise.all([
|
||||
getProgrammeCourseAggregates({
|
||||
courseCodes: programmeCourses,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
getTransferCourseAggregates({
|
||||
courseCodes: programmeCourses,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
getProgrammeCourseAggregates(programmeCourseIds, from, to),
|
||||
getTransferCourseAggregates(programmeCourseIds, from, to),
|
||||
])
|
||||
|
||||
const openUniStudentHetuMap = await getStudentHetuStateMap(
|
||||
@@ -198,7 +190,8 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
if (!yearRange.includes(year)) continue
|
||||
maxYear = Math.max(maxYear, year)
|
||||
|
||||
const course = ensureCourse(courseMap, row.courseCode, row.groupId, row.courseName, row.isStudyModule)
|
||||
const course = ensureCourse(courseMap, row.groupId, row.isStudyModule)
|
||||
|
||||
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
|
||||
const { stats } = yearAccumulator
|
||||
|
||||
@@ -246,54 +239,56 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
if (!yearRange.includes(year)) continue
|
||||
maxYear = Math.max(maxYear, year)
|
||||
|
||||
const course = ensureCourse(courseMap, row.courseCode, row.groupId, row.courseName, row.isStudyModule)
|
||||
const course = ensureCourse(courseMap, row.groupId, row.isStudyModule)
|
||||
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
|
||||
yearAccumulator.transferStudents.add(row.studentNumber)
|
||||
yearAccumulator.stats.transferStudentsCredits += row.credits
|
||||
}
|
||||
|
||||
const notCompletedResults = await Promise.all(
|
||||
yearRange.map(year => {
|
||||
const yearFrom = getFrom(academicYear, year)
|
||||
const yearTo = getTo(academicYear, year)
|
||||
return getNotCompletedForProgrammeCourses(yearFrom, yearTo, programmeCourses, from, to)
|
||||
})
|
||||
)
|
||||
const notCompletedByYear = await notCompletedByYearPromise
|
||||
|
||||
notCompletedResults.forEach((results, index) => {
|
||||
const year = yearRange[index]
|
||||
if (!results) return
|
||||
for (const year of yearRange) {
|
||||
const results = notCompletedByYear.get(year)
|
||||
if (!results) continue
|
||||
if (results.length > 0) {
|
||||
maxYear = Math.max(maxYear, year)
|
||||
}
|
||||
for (const result of results) {
|
||||
const course = ensureCourse(courseMap, result.code, result.groupId, result.name, result.isStudyModule ?? false)
|
||||
const course = ensureCourse(courseMap, result.groupId, result.isStudyModule ?? false)
|
||||
const yearAccumulator = ensureYear(course, year, result.isStudyModule ?? false)
|
||||
yearAccumulator.stats.allNotPassed += result.allNotPassed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (maxYear === 0) {
|
||||
maxYear = lastYear
|
||||
}
|
||||
|
||||
const primaryByGroupId = await primaryByGroupIdPromise
|
||||
|
||||
const coursesRecord: Record<string, StudyProgrammeCourse> = {}
|
||||
|
||||
for (const [code, course] of courseMap.entries()) {
|
||||
// Code and name for each CU group is fetched from the "primary" instance
|
||||
for (const [groupId, course] of courseMap.entries()) {
|
||||
const years: Record<number, YearStats> = {}
|
||||
for (const [year, yearAccumulator] of course.years.entries()) {
|
||||
if (year > maxYear) continue
|
||||
years[year] = finalizeYearStats(yearAccumulator)
|
||||
}
|
||||
|
||||
if (Object.keys(years).length === 0) {
|
||||
if (!Object.keys(years).length) continue
|
||||
|
||||
const primary = primaryByGroupId.get(groupId)
|
||||
|
||||
if (!primary?.code || !primary?.name) {
|
||||
logger.warn(`Warning: Programme courses: No primary course for groupId: ${groupId ?? '(groupId is undefined)'}`)
|
||||
continue
|
||||
}
|
||||
|
||||
coursesRecord[code] = {
|
||||
code,
|
||||
groupId: course.groupId,
|
||||
name: course.name,
|
||||
coursesRecord[groupId] = {
|
||||
code: primary.code,
|
||||
groupId,
|
||||
name: primary.name,
|
||||
isStudyModule: course.isStudyModule,
|
||||
years,
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ const getActiveTeachers = async (providers: string[], startSemester: number, end
|
||||
JOIN credit_teachers ON teacher.id = credit_teachers.teacher_id
|
||||
JOIN credit ON credit.id = credit_teachers.credit_id
|
||||
JOIN course ON credit.course_id = course.id
|
||||
JOIN course_providers ON course.id = course_providers.coursecode
|
||||
JOIN course_providers ON course.group_id = course_providers.coursecode
|
||||
JOIN organization ON organization.id = course_providers.organizationcode
|
||||
WHERE credit.semestercode BETWEEN :startSemester AND :endSemester
|
||||
AND organization.code IN (:providers)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type CourseProvider = {
|
||||
// TODO: RENAME: this is courseGroupId, not code
|
||||
coursecode: string
|
||||
shares: Array<{ share: number; startDate?: string; endDate?: string }> | null
|
||||
// TODO: RENAME: this is organizationId, not code
|
||||
organizationcode: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
|
||||
Reference in New Issue
Block a user