mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[WIP][Course refactor] backup working tree
This commit is contained in:
committed by
Aaro Koivukangas
parent
c908a61c0f
commit
96f8b43732
@@ -17,6 +17,7 @@ import {
|
||||
hasFullAccessToStudentData,
|
||||
validateParamLength,
|
||||
} from '../util'
|
||||
import { CourseYearlyStats } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -33,16 +34,16 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
|
||||
}
|
||||
)
|
||||
|
||||
export type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
export type CourseYearlyStatsResBody = CourseYearlyStats[]
|
||||
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { codes, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
|
||||
const { courses, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
|
||||
|
||||
const courseCodes = handleQueryArrays(codes)
|
||||
const courseGroupIds = handleQueryArrays(courses)
|
||||
|
||||
if (!courseCodes?.length) {
|
||||
if (!courseGroupIds.length) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(
|
||||
courseCodes,
|
||||
courseGroupIds,
|
||||
useSeparate,
|
||||
anonymizationSalt,
|
||||
useCombineSubstitutions,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { CreditTypeCode, type Name } from '@oodikone/shared/types'
|
||||
import type { Programme, FacultyYearStats, Grades, Group, Student } from '@oodikone/shared/types/courseYearlyStats'
|
||||
import type {
|
||||
Programme,
|
||||
FacultyYearStats,
|
||||
Grades,
|
||||
Group,
|
||||
Student,
|
||||
CourseStat,
|
||||
Realisation,
|
||||
} from '@oodikone/shared/types/courseYearlyStats'
|
||||
import { getSemesterNamesByCode } from '../semesters'
|
||||
import type { OrganizationDetails } from './helpers'
|
||||
import { yearCodeToYear } from '@oodikone/shared/util'
|
||||
@@ -211,11 +219,12 @@ export class CourseYearlyStatsCounter {
|
||||
const previous = this.groups[i - 1]
|
||||
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semesters[i]
|
||||
|
||||
console.log('INIT GROUP', i)
|
||||
this.initGroup(i, name, previous.coursecode, i)
|
||||
})
|
||||
}
|
||||
|
||||
private async parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
private async parseGroupStatistics(anonymizationSalt: string | null): Promise<Realisation[]> {
|
||||
await this.insertEmptyRows()
|
||||
for (const [studentNumber, data] of this.students) {
|
||||
this.groups[data.code].students.studentNumbers.push(studentNumber)
|
||||
@@ -230,8 +239,8 @@ export class CourseYearlyStatsCounter {
|
||||
coursecode: group.coursecode,
|
||||
attempts: {
|
||||
categories: {
|
||||
failed: [] as string[],
|
||||
passed: [] as string[],
|
||||
failed: [],
|
||||
passed: [],
|
||||
},
|
||||
grades: Object.keys(group.attempts.grades).reduce<Grades>((grades, grade) => {
|
||||
grades[grade] = []
|
||||
@@ -240,8 +249,11 @@ export class CourseYearlyStatsCounter {
|
||||
},
|
||||
yearCode: group.yearCode,
|
||||
students: {
|
||||
studentNumbers: [] as string[],
|
||||
studentNumbers: [],
|
||||
grades: {},
|
||||
},
|
||||
allEnrollments: [],
|
||||
enrollments: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +305,9 @@ export class CourseYearlyStatsCounter {
|
||||
return this.facultyStats
|
||||
}
|
||||
|
||||
public async getFinalStatistics(anonymizationSalt: string | null) {
|
||||
public async getFinalStatistics(
|
||||
anonymizationSalt: string | null
|
||||
): Promise<Pick<CourseStat, 'obfuscated' | 'statistics' | 'programmes' | 'facultyStats'>> {
|
||||
const statistics = await this.parseGroupStatistics(anonymizationSalt)
|
||||
const obfuscated = statistics.some(row => row.obfuscated)
|
||||
|
||||
|
||||
@@ -4,32 +4,33 @@ import { Credit, Enrollment } from '@oodikone/shared/models'
|
||||
import { EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
|
||||
import {
|
||||
StudentModel,
|
||||
CreditModel,
|
||||
SemesterModel,
|
||||
OrganizationModel,
|
||||
EnrollmentModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
CourseModel,
|
||||
} from '../../models'
|
||||
import { now } from '../../util/clock'
|
||||
import { getIsOpen } from './helpers'
|
||||
|
||||
export const getCreditsForCourses = async (codeGroups: string[][], unification: Unification, from: Date, to: Date) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
export const getCreditsForCourses = async (
|
||||
groupIdGroups: string[][],
|
||||
allCourseIds: string[],
|
||||
unification: Unification,
|
||||
from: Date,
|
||||
to: Date
|
||||
) => {
|
||||
// We need the credits grouped by student numbers so that we can check if a student has
|
||||
// completed a substitution group
|
||||
const students = await StudentModel.findAll({
|
||||
const attainments = await CreditModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: CreditModel,
|
||||
attributes: [
|
||||
'grade',
|
||||
'course_code',
|
||||
'course_id',
|
||||
'credits',
|
||||
'attainment_date',
|
||||
'student_studentnumber',
|
||||
@@ -37,10 +38,11 @@ export const getCreditsForCourses = async (codeGroups: string[][], unification:
|
||||
'credittypecode',
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
course_id: {
|
||||
[Op.in]: allCourseIds,
|
||||
},
|
||||
is_open: getIsOpen(unification),
|
||||
credittypecode: { [Op.not]: CreditTypeCode.IMPROVED }, // We do not care about improved grades
|
||||
attainment_date: { [Op.between]: [from, to] },
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
@@ -55,35 +57,33 @@ export const getCreditsForCourses = async (codeGroups: string[][], unification:
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const studentNumberToCredits = students.reduce<Record<string, Credit[]>>((acc, student) => {
|
||||
acc[student.studentnumber] ??= []
|
||||
acc[student.studentnumber] = acc[student.studentnumber].concat(student.credits)
|
||||
|
||||
const studentNumberToCredits = attainments.reduce<Record<string, Credit[]>>((acc, att) => {
|
||||
acc[att.student_studentnumber] ??= []
|
||||
acc[att.student_studentnumber].push(att)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const completedGroups: Credit[][] = []
|
||||
for (const studentCredits of Object.values(studentNumberToCredits)) {
|
||||
const codesOfPassedCredits = studentCredits
|
||||
const groupIdsOfPassedCredits = studentCredits
|
||||
.filter(credit => CreditModel.passed(credit))
|
||||
.map(credit => credit.course_code)
|
||||
for (const group of codeGroups) {
|
||||
.map(credit => credit.course.groupId)
|
||||
|
||||
for (const group of groupIdGroups) {
|
||||
if (group.length === 1) {
|
||||
// Failed courses are only calculated for the original course and 1-to-1 substitutions
|
||||
const credits = studentCredits.filter(credit => credit.course_code === group.at(0))
|
||||
const credits = studentCredits.filter(credit => credit.course.groupId === group[0])
|
||||
if (credits.length) {
|
||||
completedGroups.push(credits)
|
||||
}
|
||||
}
|
||||
// For substitution groups we must only get passed groups
|
||||
else if (group.every(code => codesOfPassedCredits.includes(code))) {
|
||||
else if (group.every(groupId => groupIdsOfPassedCredits.includes(groupId))) {
|
||||
// The credit in question should always exist because we just checked that it does
|
||||
completedGroups.push(
|
||||
studentCredits.filter(credit => group.includes(credit.course_code) && CreditModel.passed(credit))
|
||||
studentCredits.filter(credit => group.includes(credit.course_id) && CreditModel.passed(credit))
|
||||
)
|
||||
} else {
|
||||
// "Partially completed substitution, do not count towards anything"
|
||||
@@ -136,68 +136,60 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
}
|
||||
|
||||
export const getEnrollmentsForCourses = async (
|
||||
codeGroups: string[][],
|
||||
groupIdGroups: string[][],
|
||||
allCourseIds: string[],
|
||||
unification: Unification,
|
||||
from: Date,
|
||||
to: Date
|
||||
) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
const students = await StudentModel.findAll({
|
||||
const enrollments = await EnrollmentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber'],
|
||||
attributes: ['studentnumber', 'enrollment_date_time', 'course_code', 'course_id', 'studyright_id'],
|
||||
where: {
|
||||
course_id: {
|
||||
[Op.in]: allCourseIds,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.between]: [from, to],
|
||||
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: EnrollmentModel,
|
||||
attributes: ['studentnumber', 'enrollment_date_time', 'course_code', 'studyright_id'],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.between]: [from, to],
|
||||
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
{
|
||||
model: CourseModel,
|
||||
attributes: ['groupId'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const studentNumberToEnrollments = students.reduce<Record<string, Enrollment[]>>((acc, student) => {
|
||||
acc[student.studentnumber] ??= []
|
||||
acc[student.studentnumber] = acc[student.studentnumber].concat(student.enrollments)
|
||||
|
||||
const studentNumberToEnrollments = enrollments.reduce<Record<string, Enrollment[]>>((acc, enrollment) => {
|
||||
acc[enrollment.studentnumber] ??= []
|
||||
acc[enrollment.studentnumber].push(enrollment)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Calculate all code groups that have enrollments for all of the courses in the group
|
||||
const completedEnrollments: Enrollment[][] = []
|
||||
for (const studentEnrollments of Object.values(studentNumberToEnrollments)) {
|
||||
const codesOfEnrollments = studentEnrollments.map(enrollment => enrollment.course_code)
|
||||
const groupIdsOfEnrollments = studentEnrollments.map(enrollment => enrollment.course.groupId)
|
||||
|
||||
for (const group of codeGroups) {
|
||||
if (group.every(code => codesOfEnrollments.includes(code))) {
|
||||
for (const group of groupIdGroups) {
|
||||
if (group.every(groupId => groupIdsOfEnrollments.includes(groupId))) {
|
||||
completedEnrollments.push(
|
||||
studentEnrollments
|
||||
.filter(enrollment => group.includes(enrollment.course_code))
|
||||
.sort((a, b) => new Date(b.enrollment_date_time).getTime() - new Date(a.enrollment_date_time).getTime())
|
||||
.filter(enrollment => group.includes(enrollment.course.groupId))
|
||||
.sort((a, b) => b.enrollment_date_time.getTime() - a.enrollment_date_time.getTime())
|
||||
)
|
||||
} else {
|
||||
// Partially completed enrollments for a group, skipping...
|
||||
@@ -205,5 +197,5 @@ export const getEnrollmentsForCourses = async (
|
||||
}
|
||||
}
|
||||
|
||||
return completedEnrollments
|
||||
return completedEnrollments.filter(enrollmentGroup => enrollmentGroup.length)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getStudentNumberToSrElementsMap,
|
||||
} from './creditsAndEnrollmentsOfCourse'
|
||||
import { FormattedProgramme, getIsOpen } from './helpers'
|
||||
import { CourseYearlyStats } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
|
||||
code: studyRightElement.code,
|
||||
@@ -45,6 +46,7 @@ type FormattedCredit = {
|
||||
}
|
||||
|
||||
// Is group in question for a single course (original or 1-to-1 substitution) or a substitution group with multiple courses
|
||||
// TODO: Change from course_code to group_id
|
||||
const isSingleCourse = (group: Credit[] | Enrollment[]): boolean =>
|
||||
group?.length === 1 || [...new Set(group?.map((course: Credit | Enrollment) => course.course_code))].length === 1
|
||||
|
||||
@@ -153,17 +155,20 @@ const parseEnrollment = (
|
||||
}
|
||||
}
|
||||
|
||||
const getSubstitutionGroupDetails = async (codeGroups: string[][]) => {
|
||||
const getSubstitutionGroupDetails = async (
|
||||
groupIdGroups: string[][]
|
||||
): Promise<Pick<CourseModel, 'code' | 'name' | 'groupId'>[][]> => {
|
||||
const substitutionGroupDetails = await CourseModel.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
attributes: ['code', 'name', 'groupId'],
|
||||
where: {
|
||||
code: { [Op.in]: codeGroups.flatMap(group => group) },
|
||||
groupId: { [Op.in]: groupIdGroups.flatMap(group => group) },
|
||||
isPrimary: true,
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
return codeGroups.map(group =>
|
||||
group.map(code => substitutionGroupDetails.find(subCourse => subCourse.code === code)!)
|
||||
return groupIdGroups.map(group =>
|
||||
group.map(groupId => substitutionGroupDetails.find(subCourse => subCourse.groupId === groupId)!)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,42 +185,43 @@ const getSemesterAndYearByDate = (
|
||||
return { year, semester }
|
||||
}
|
||||
|
||||
export const getYearlyStatsOfNew = async (
|
||||
course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null,
|
||||
courseCode: string,
|
||||
export const getYearlyStatsOfNew =
|
||||
(
|
||||
primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'>,
|
||||
separate: boolean,
|
||||
unification: Unification,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>,
|
||||
from: Date,
|
||||
to: Date
|
||||
) => {
|
||||
// Includes main course code and substitutions (if enabled)
|
||||
const creditGroupCodes =
|
||||
combineSubstitutions && course?.substitution_groups
|
||||
? [[courseCode]].concat(course.substitution_groups)
|
||||
: [[courseCode]]
|
||||
) =>
|
||||
async (unification: Unification): Promise<CourseYearlyStats['openStats']> => {
|
||||
const { id, groupId, name, code: courseCode } = primaryCourse
|
||||
|
||||
let filteredCreditGroupCodes: string[][]
|
||||
switch (unification) {
|
||||
// Include only course codes / group of course codes WITHOUT ANY AY prefix
|
||||
case Unification.REGULAR:
|
||||
filteredCreditGroupCodes = creditGroupCodes.filter(group => group.every(course => !isOpenUniCourseCode(course)))
|
||||
break
|
||||
// Include all courses / group of courses
|
||||
case Unification.OPEN:
|
||||
case Unification.UNIFY:
|
||||
default:
|
||||
filteredCreditGroupCodes = creditGroupCodes
|
||||
break
|
||||
}
|
||||
// Includes main course code and substitutions (if enabled)
|
||||
const creditGroupIds = combineSubstitutions
|
||||
? [[groupId]].concat(primaryCourse.substitutionGroups ?? [])
|
||||
: [[groupId]]
|
||||
|
||||
const filteredCreditGroupIds =
|
||||
unification === Unification.REGULAR
|
||||
? // FIXME: isOpenUniCourseCode does not work yet with groupIds
|
||||
creditGroupIds.filter(group => group.every(course => !isOpenUniCourseCode(course)))
|
||||
: creditGroupIds
|
||||
|
||||
const allCourseIds = (
|
||||
await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['id'],
|
||||
where: { groupId: { [Op.in]: filteredCreditGroupIds.flat() } },
|
||||
})
|
||||
).map(({ id }) => id)
|
||||
|
||||
const { semesters, years } = await getSemestersAndYears()
|
||||
|
||||
const [creditGroups, enrollmentGroups] = await Promise.all([
|
||||
getCreditsForCourses(filteredCreditGroupCodes, unification, from, to),
|
||||
getEnrollmentsForCourses(filteredCreditGroupCodes, unification, from, to),
|
||||
getCreditsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
|
||||
getEnrollmentsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
|
||||
])
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
@@ -298,19 +304,15 @@ export const getYearlyStatsOfNew = async (
|
||||
}
|
||||
|
||||
for (const enrollments of enrollmentGroups) {
|
||||
if (!enrollments?.length) continue
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
const studyRightElements = studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
|
||||
|
||||
const {
|
||||
studentNumber,
|
||||
courseCode: enrollmentCourseCode,
|
||||
enrollmentDateTime,
|
||||
programme,
|
||||
} = parseEnrollment(
|
||||
enrollment,
|
||||
anonymizationSalt,
|
||||
studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
|
||||
)
|
||||
} = parseEnrollment(enrollment, anonymizationSalt, studyRightElements)
|
||||
|
||||
// Enrollments can have conflicting enrollment_date_time and semestercode, so we need to manually select
|
||||
// semestercode matching enrollment_date_time
|
||||
@@ -343,23 +345,23 @@ export const getYearlyStatsOfNew = async (
|
||||
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
const substitutionGroups: Pick<CourseModel, 'code' | 'name'>[][] =
|
||||
combineSubstitutions && course?.substitution_groups?.length
|
||||
? await getSubstitutionGroupDetails(course.substitution_groups)
|
||||
: [[{ code: course!.code, name: course!.name }]]
|
||||
const substitutionGroups =
|
||||
combineSubstitutions && primaryCourse.substitutionGroups?.length
|
||||
? await getSubstitutionGroupDetails(primaryCourse.substitutionGroups)
|
||||
: [[{ code: courseCode, name, groupId }]]
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode: courseCode,
|
||||
alternatives: substitutionGroups,
|
||||
name: course?.name,
|
||||
courseCode,
|
||||
groupId,
|
||||
substitutionGroups,
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Mostly-legacy function that prevents user from opening too big of a populations.
|
||||
At the moment the threshold to prevent a population is so high it will never trigger.
|
||||
|
||||
*/
|
||||
export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unification: Unification) => {
|
||||
const lastAttainmentDate = (await CourseModel.findOne({
|
||||
@@ -393,7 +395,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
}
|
||||
|
||||
export const getCourseYearlyStats = async (
|
||||
courseCodes: string[],
|
||||
courseGroupIds: string[],
|
||||
separate: boolean,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
@@ -403,32 +405,51 @@ export const getCourseYearlyStats = async (
|
||||
// Default to 1900 - currentYear+1 so that without parameters the api returns stats for all years
|
||||
const from = new Date(`${yearCodeToYear(fromYearCode)}-08-01`) // FALL
|
||||
const to = new Date(`${yearCodeToYear(toYearCode) + 1}-07-31`) // SPRING next year
|
||||
console.log('FUNCTIONARGUMENTS:')
|
||||
console.log('groupIds', courseGroupIds)
|
||||
console.log('separate', separate)
|
||||
console.log('anonymizationSalt', anonymizationSalt)
|
||||
console.log('combineSubstitutions', combineSubstitutions)
|
||||
console.log('fromYearCode ', fromYearCode)
|
||||
console.log('toYearCode', toYearCode)
|
||||
|
||||
const relevantCourseIds = (
|
||||
await CourseModel.findAll({
|
||||
attributes: ['id'],
|
||||
raw: true,
|
||||
where: {
|
||||
groupId: { [Op.in]: courseGroupIds },
|
||||
},
|
||||
})
|
||||
).map(({ id }) => id)
|
||||
|
||||
const [credits, enrollments] = await Promise.all([
|
||||
CreditModel.findAll({
|
||||
attributes: ['student_studentnumber'],
|
||||
raw: true,
|
||||
where: {
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
course_id: { [Op.in]: relevantCourseIds },
|
||||
attainment_date: { [Op.between]: [from, to] },
|
||||
},
|
||||
}),
|
||||
EnrollmentModel.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
raw: true,
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
course_id: {
|
||||
[Op.in]: relevantCourseIds,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.between]: [from, to],
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
console.log(from, to, credits.length, enrollments.length)
|
||||
|
||||
const studentNumbers = new Set<string>()
|
||||
|
||||
credits.forEach(credit => {
|
||||
@@ -441,60 +462,43 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap([...studentNumbers])
|
||||
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null = await CourseModel.findOne({
|
||||
console.log('studentnumbers', [...studentNumbers].join(','))
|
||||
|
||||
const stats = await Promise.all(
|
||||
courseGroupIds.map(async groupId => {
|
||||
const primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'> | null =
|
||||
await CourseModel.findOne({
|
||||
raw: true,
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: { code: courseCode },
|
||||
attributes: ['groupId', 'id', 'code', 'name', 'substitutionGroups'],
|
||||
where: { groupId, isPrimary: true },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
logger.error('Course for course stats not found with code' + courseCode)
|
||||
if (!primaryCourse) {
|
||||
logger.error(`Primary course for course stats not found with groupId: ${groupId}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
const getYearlyStatsOfUnification = getYearlyStatsOfNew(
|
||||
primaryCourse,
|
||||
separate,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
)
|
||||
|
||||
const [openStats, regularStats, unifyStats] = await Promise.all([
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.OPEN,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.REGULAR,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.UNIFY,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
getYearlyStatsOfUnification(Unification.OPEN),
|
||||
getYearlyStatsOfUnification(Unification.REGULAR),
|
||||
getYearlyStatsOfUnification(Unification.UNIFY),
|
||||
])
|
||||
|
||||
return { unifyStats, regularStats, openStats }
|
||||
})
|
||||
)
|
||||
|
||||
return statsRegular
|
||||
return stats as CourseYearlyStats[]
|
||||
}
|
||||
|
||||
export const getCourseProvidersForCourses = async (codes: string[]) =>
|
||||
@@ -533,7 +537,7 @@ export const searchAndCombineSubstitutionGroupsToCodes = async (coursecodes: str
|
||||
return [
|
||||
...new Set(
|
||||
coursecodes.concat(
|
||||
substitutionGroups.flatMap(({ substitution_groups }) => substitution_groups.flatMap(code => code))
|
||||
substitutionGroups.flatMap(({ substitutionGroups }) => substitutionGroups.flatMap(code => code))
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ export const CourseSelector = ({
|
||||
selected,
|
||||
setSelected,
|
||||
}: {
|
||||
courses: { key: string; code: string; name: string }[]
|
||||
courses: { key: string; code: string; name?: string | null; groupId: string }[]
|
||||
selected: string
|
||||
setSelected: (courseCode: string) => void
|
||||
}) => {
|
||||
@@ -26,12 +26,14 @@ export const CourseSelector = ({
|
||||
dispatch(setSelectedCourse(selectedCourse))
|
||||
}
|
||||
|
||||
console.log(courses, selected)
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select course</InputLabel>
|
||||
<Select data-cy="CourseSelector" label="Select course" onChange={onCourseChange} value={selected}>
|
||||
{courses.map(({ key, code, name }) => (
|
||||
<MenuItem data-cy={`CourseSelectorOption${code}`} key={key} value={code}>
|
||||
{courses.map(({ key, code, name, groupId }) => (
|
||||
<MenuItem data-cy={`CourseSelectorOption${code}`} key={key} value={groupId}>
|
||||
<Box display="flex" justifyContent="space-between" width="100%">
|
||||
<Typography color="text.primary" component="span" variant="body1">
|
||||
{name}
|
||||
|
||||
+3
-3
@@ -46,13 +46,13 @@ export const ResultTabs = ({
|
||||
return
|
||||
}
|
||||
|
||||
const { courseCodes, ...params } = parseQueryParams(location.search)
|
||||
const { courses, ...params } = parseQueryParams(location.search)
|
||||
const query = {
|
||||
...params,
|
||||
courseCodes: JSON.parse(courseCodes as string),
|
||||
courses: JSON.parse(courses as string),
|
||||
separate,
|
||||
}
|
||||
const queryToString = { ...query, courseCodes: JSON.stringify(query.courseCodes) }
|
||||
const queryToString = { ...query, courses: JSON.stringify(query.courses) }
|
||||
void navigate({ search: queryParamsToString(queryToString) }, { replace: true })
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ export const ResultTab = ({
|
||||
combineSubstutitions: boolean
|
||||
courseCodes: string[]
|
||||
}) => {
|
||||
'use memo'
|
||||
const [settings, setSettings] = useState<ResultTabSettings>({
|
||||
isRelative: false,
|
||||
separate: initialSettings.separate,
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const TableSettings = ({
|
||||
openOrRegular: CourseSearchState
|
||||
}) => {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Setting
|
||||
control={<Switch checked={showGrades} data-cy="gradeToggle" onChange={() => onShowGradesChange(!showGrades)} />}
|
||||
labelText="Show grades"
|
||||
|
||||
+24
-29
@@ -22,19 +22,12 @@ import { useAppDispatch } from '@/redux/hooks'
|
||||
import { useGetMaxYearsToCreatePopulationFromQuery } from '@/redux/populations'
|
||||
import { setSelectedCourse, clearSelectedCourse } from '@/redux/selectedCourse'
|
||||
import { DoNotDisturbIcon } from '@/theme'
|
||||
import {
|
||||
Attempts,
|
||||
AvailableStats,
|
||||
CourseStat,
|
||||
Enrollment,
|
||||
FormattedStats,
|
||||
ProgrammeStats,
|
||||
Students,
|
||||
} from '@/types/courseStat'
|
||||
import { AvailableStats, FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
import { DropdownOption } from '@/types/dropdownOption'
|
||||
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThresholdYearCode, yearToYearCode } from '@oodikone/shared/util'
|
||||
import { Attempts, Enrollment, Students } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
const countFilteredStudents = (stat: Record<string, string[]>, filter: (studentNumber: string) => boolean) => {
|
||||
if (!stat) {
|
||||
@@ -53,7 +46,7 @@ const countFilteredStudents = (stat: Record<string, string[]>, filter: (studentN
|
||||
}
|
||||
|
||||
export const SingleCourseStats = ({
|
||||
coursecode,
|
||||
courseGroupId,
|
||||
availableStats,
|
||||
userHasAccessToAllStats,
|
||||
|
||||
@@ -63,7 +56,7 @@ export const SingleCourseStats = ({
|
||||
programmes,
|
||||
combineSubstitutions,
|
||||
}: {
|
||||
coursecode: string
|
||||
courseGroupId: string
|
||||
availableStats: AvailableStats
|
||||
userHasAccessToAllStats: boolean
|
||||
|
||||
@@ -73,6 +66,7 @@ export const SingleCourseStats = ({
|
||||
programmes: CourseStudyProgramme[]
|
||||
combineSubstitutions: boolean
|
||||
}) => {
|
||||
'use memo'
|
||||
const [primary, setPrimary] = useState<string[]>([ALL.value])
|
||||
const [comparison, setComparison] = useState<string[]>([])
|
||||
|
||||
@@ -90,12 +84,12 @@ export const SingleCourseStats = ({
|
||||
const { getTextIn } = useLanguage()
|
||||
const { semesters, years: semesterYears } = useSemesters()
|
||||
const {
|
||||
data: courseStatistics = {},
|
||||
data: courseStatistics,
|
||||
isFetching: isLoading,
|
||||
isSuccess,
|
||||
} = useGetCourseStatsQuery(
|
||||
{
|
||||
codes: [coursecode],
|
||||
courses: [courseGroupId],
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
fromYearCode: fromYearCode.toString(),
|
||||
@@ -103,10 +97,11 @@ export const SingleCourseStats = ({
|
||||
},
|
||||
{ skip: loading }
|
||||
)
|
||||
|
||||
const uniqueCourseCodes = [
|
||||
...new Set(
|
||||
[coursecode].concat(
|
||||
courseStatistics?.[coursecode]?.[openOrRegular]?.alternatives.flatMap(group =>
|
||||
[courseGroupId].concat(
|
||||
courseStatistics?.[courseGroupId]?.[openOrRegular]?.substitutionGroups.flatMap(group =>
|
||||
group.flatMap(({ code }) => code)
|
||||
) ?? []
|
||||
)
|
||||
@@ -140,8 +135,8 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Can be undefined if query is still querying
|
||||
const stats: CourseStat | undefined = courseStatistics[coursecode]?.[openOrRegular]
|
||||
// Undefined if query is still querying
|
||||
const stats = courseStatistics?.[courseGroupId]?.[openOrRegular]
|
||||
|
||||
let maxYearsToCreatePopulationFrom = 0
|
||||
if (maxYears) {
|
||||
@@ -162,7 +157,7 @@ export const SingleCourseStats = ({
|
||||
const { separate } = parseQueryFromUrl()
|
||||
setSeparate([true, false].includes(separate) ? separate : false)
|
||||
}
|
||||
dispatch(setSelectedCourse(coursecode))
|
||||
dispatch(setSelectedCourse(courseGroupId))
|
||||
|
||||
const yearCodes = stats?.statistics.map(stat => stat.yearCode)
|
||||
const initFromYear = min(yearCodes) ?? yearToYearCode(1950)
|
||||
@@ -200,7 +195,7 @@ export const SingleCourseStats = ({
|
||||
if (programmeCode === 'EXCLUDED') {
|
||||
return 'Excluded'
|
||||
}
|
||||
const { name } = stats.programmes[programmeCode]
|
||||
const name = stats?.programmes[programmeCode]['name']
|
||||
return getTextIn(name)!
|
||||
}
|
||||
|
||||
@@ -221,10 +216,10 @@ export const SingleCourseStats = ({
|
||||
return () => true
|
||||
}
|
||||
|
||||
const { programmes } = stats
|
||||
const programmes = stats?.programmes
|
||||
const studentNumbers = new Set()
|
||||
codes.forEach(code => {
|
||||
if (programmes[code]) {
|
||||
if (programmes?.[code]) {
|
||||
const students = Object.values(programmes[code].students).flat()
|
||||
students.forEach(student => studentNumbers.add(student))
|
||||
}
|
||||
@@ -234,7 +229,7 @@ export const SingleCourseStats = ({
|
||||
}
|
||||
|
||||
const isValidProgrammeCode = (code: string) => {
|
||||
return stats.programmes[code] || code === ALL.value || code === 'EXCLUDED'
|
||||
return stats?.programmes[code] || code === ALL.value || code === 'EXCLUDED'
|
||||
}
|
||||
|
||||
const filteredYearsAndSemesters = () => {
|
||||
@@ -488,7 +483,7 @@ export const SingleCourseStats = ({
|
||||
const queryObject = {
|
||||
from: fromYearCode,
|
||||
to: toYearCode,
|
||||
coursecodes: JSON.stringify([coursecode]),
|
||||
coursecodes: JSON.stringify([courseGroupId]),
|
||||
separate,
|
||||
unifyCourses: openOrRegular,
|
||||
includeSubstitutions: combineSubstitutions,
|
||||
@@ -519,7 +514,7 @@ export const SingleCourseStats = ({
|
||||
})
|
||||
.filter(programme => programme.size > 0)
|
||||
|
||||
if (stats?.statistics.length < 1) {
|
||||
if (!stats?.statistics || stats.statistics.length < 1) {
|
||||
return <Section>No data for selected course</Section>
|
||||
}
|
||||
|
||||
@@ -538,9 +533,9 @@ export const SingleCourseStats = ({
|
||||
)} ${maxYearsToCreatePopulationFrom === 1 ? 'year' : 'years'}`
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Stack spacing={2}>
|
||||
<Section title="Statistics by time range">
|
||||
<Stack direction="row" gap={2}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<YearFilter
|
||||
fromYear={fromYearCode}
|
||||
handleFromYearChange={handleFromYearChange}
|
||||
@@ -559,7 +554,7 @@ export const SingleCourseStats = ({
|
||||
</Section>
|
||||
{userHasAccessToAllStats ? (
|
||||
<Section title="Filter statistics by degree programme">
|
||||
<Stack direction="row" gap={2}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box width="50%">
|
||||
<ProgrammeDropdown
|
||||
label="Primary group"
|
||||
@@ -571,7 +566,7 @@ export const SingleCourseStats = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box width="50%">
|
||||
<Stack gap={1}>
|
||||
<Stack spacing={1}>
|
||||
<ProgrammeDropdown
|
||||
label="Comparison group"
|
||||
name="comparison"
|
||||
@@ -600,7 +595,7 @@ export const SingleCourseStats = ({
|
||||
availableStats={availableStats}
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
comparison={statistics.comparison}
|
||||
courseCodes={[coursecode]}
|
||||
courseCodes={[courseGroupId]}
|
||||
loading={loading}
|
||||
openOrRegular={openOrRegular}
|
||||
primary={statistics.primary}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { PrimaryCourseLabel, SecondaryCourseLabel } from '@/pages/CourseStatisti
|
||||
import { CourseSelector } from '@/pages/CourseStatistics/CourseTab/CourseSelector'
|
||||
import { SingleCourseStats } from '@/pages/CourseStatistics/CourseTab/SingleCourseStats'
|
||||
import { CourseStudyProgramme } from '@/pages/CourseStatistics/util'
|
||||
import { AvailableStats, CourseStat } from '@/types/courseStat'
|
||||
import { AvailableStats } from '@/types/courseStat'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseStat } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
export const CourseTab = ({
|
||||
selected,
|
||||
@@ -24,7 +25,7 @@ export const CourseTab = ({
|
||||
stats,
|
||||
availableStats,
|
||||
combineSubstitutions,
|
||||
alternatives,
|
||||
substitutionGroups,
|
||||
programmes,
|
||||
}: {
|
||||
selected: string | undefined
|
||||
@@ -37,37 +38,43 @@ export const CourseTab = ({
|
||||
stats: Record<string, CourseStat>
|
||||
availableStats: AvailableStats
|
||||
combineSubstitutions: boolean
|
||||
alternatives: { code: string; name: Name }[][]
|
||||
substitutionGroups: { code: string; name: Name; groupId: string }[][]
|
||||
programmes: CourseStudyProgramme[]
|
||||
}) => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
if (!selected || !stats[selected]) return null
|
||||
|
||||
const courses = Object.values(stats).map(({ name, coursecode: code }) => ({
|
||||
const courses = Object.values(stats).map(({ name, courseCode: code, groupId }) => ({
|
||||
key: code,
|
||||
code,
|
||||
name: getTextIn(name)!,
|
||||
name: getTextIn(name),
|
||||
groupId,
|
||||
}))
|
||||
|
||||
if (courses.length === 0) return null
|
||||
const multipleCourses = courses.length > 1
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack spacing={2}>
|
||||
<Section title={multipleCourses ? 'Selected courses' : 'Selected course'}>
|
||||
<Stack gap={2}>
|
||||
<Stack spacing={2}>
|
||||
{multipleCourses ? <CourseSelector courses={courses} selected={selected} setSelected={setSelected} /> : null}
|
||||
<Stack gap={1}>
|
||||
<Stack spacing={1}>
|
||||
<Box>
|
||||
<PrimaryCourseLabel code={selected} key={selected} name={getTextIn(stats[selected].name)!} />
|
||||
<PrimaryCourseLabel
|
||||
code={stats[selected].courseCode}
|
||||
key={selected}
|
||||
name={getTextIn(stats[selected].name)!}
|
||||
/>
|
||||
</Box>
|
||||
{alternatives.length ? (
|
||||
{substitutionGroups.length ? (
|
||||
<>
|
||||
<Typography component="h6" variant="subtitle2">
|
||||
Substitution groups
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{alternatives.map(group => (
|
||||
{substitutionGroups.map(group => (
|
||||
<SecondaryCourseLabel getTextIn={getTextIn} group={group} key={JSON.stringify(group)} />
|
||||
))}
|
||||
</Grid>
|
||||
@@ -79,7 +86,7 @@ export const CourseTab = ({
|
||||
<SingleCourseStats
|
||||
availableStats={availableStats}
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
coursecode={selected}
|
||||
courseGroupId={selected}
|
||||
loading={loading}
|
||||
openOrRegular={openOrRegular}
|
||||
programmes={programmes}
|
||||
|
||||
@@ -8,9 +8,10 @@ import Typography from '@mui/material/Typography'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
import { CourseStat, FacultyStat } from '@/types/courseStat'
|
||||
import { CourseStat, FacultyStat } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
export const CourseTable = ({ course, courseInstance }: { course: CourseStat; courseInstance: FacultyStat }) => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const rows = courseInstance ? (
|
||||
@@ -18,7 +19,7 @@ export const CourseTable = ({ course, courseInstance }: { course: CourseStat; co
|
||||
...Object.entries(courseInstance.faculties)
|
||||
.sort(([facultyCodeA], [facultyCodeB]) => facultyCodeA.localeCompare(facultyCodeB))
|
||||
.map(([facultyCode, instanceFaculty]) => (
|
||||
<TableRow key={`${course.coursecode}-${facultyCode}`}>
|
||||
<TableRow key={`${course.courseCode}-${facultyCode}`}>
|
||||
<TableCell align="left">
|
||||
{facultyCode} • {getTextIn(instanceFaculty.name)}
|
||||
</TableCell>
|
||||
@@ -26,7 +27,7 @@ export const CourseTable = ({ course, courseInstance }: { course: CourseStat; co
|
||||
<TableCell align="right">{instanceFaculty.credits}</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
<TableRow key={`${course.coursecode}-total`} sx={{ backgroundColor: theme => theme.palette.grey[50] }}>
|
||||
<TableRow key={`${course.courseCode}-total`} sx={{ backgroundColor: theme => theme.palette.grey[50] }}>
|
||||
<TableCell align="left" sx={{ fontWeight: 'bold' }}>
|
||||
Total
|
||||
</TableCell>
|
||||
@@ -49,7 +50,7 @@ export const CourseTable = ({ course, courseInstance }: { course: CourseStat; co
|
||||
)
|
||||
|
||||
return (
|
||||
<Section title={`${getTextIn(course.name)} (${course.coursecode})`}>
|
||||
<Section title={`${getTextIn(course.name)} (${course.courseCode})`}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
|
||||
@@ -8,15 +8,16 @@ import { flatten, uniq } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
import type { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { CourseTable } from '@/pages/CourseStatistics/FacultyStatisticsTab/CourseTable'
|
||||
import { CourseStats } from '@/pages/CourseStatistics/util'
|
||||
import { YearlyStatsByCourse } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
export const FacultyStatisticsTab = ({
|
||||
courseStats,
|
||||
openOrRegular,
|
||||
}: {
|
||||
courseStats: CourseStats
|
||||
courseStats: YearlyStatsByCourse
|
||||
openOrRegular: CourseSearchState
|
||||
}) => {
|
||||
'use memo'
|
||||
const yearCodes = uniq(
|
||||
flatten(Object.values(courseStats).map(course => Object.keys(course[openOrRegular].facultyStats)))
|
||||
)
|
||||
@@ -39,11 +40,11 @@ export const FacultyStatisticsTab = ({
|
||||
.sort((a, b) => Number(a.courseInstance == null) - Number(b.courseInstance == null))
|
||||
|
||||
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
|
||||
<CourseTable course={course[openOrRegular]} courseInstance={courseInstance} key={course.unifyStats.coursecode} />
|
||||
<CourseTable course={course[openOrRegular]} courseInstance={courseInstance} key={course.unifyStats.courseCode} />
|
||||
))
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Stack spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="academic-year-select-label">Select academic year</InputLabel>
|
||||
<Select
|
||||
|
||||
@@ -32,6 +32,7 @@ const CourseRow = ({
|
||||
onSelectCourse: (course: SearchResultCourse) => void
|
||||
combineSubstitutions: boolean
|
||||
}) => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
return (
|
||||
@@ -86,14 +87,11 @@ export const CourseTable = ({
|
||||
onSelectCourse: (course: SearchResultCourse) => void
|
||||
title: string
|
||||
}) => {
|
||||
// TODO: Figure a better type for this
|
||||
'use memo'
|
||||
if (hidden) return null
|
||||
|
||||
const noContent = courses.length === 0
|
||||
|
||||
if (hidden) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTable>
|
||||
<TableHead>
|
||||
@@ -108,11 +106,11 @@ export const CourseTable = ({
|
||||
{noContent ? (
|
||||
<EmptyListRow />
|
||||
) : (
|
||||
courses?.map(course => (
|
||||
courses.map(course => (
|
||||
<CourseRow
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
course={course}
|
||||
key={course.code}
|
||||
key={`${course.groupId}-${course.code}`}
|
||||
onSelectCourse={onSelectCourse}
|
||||
title={title}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { queryParamsToString } from '@/util/queryparams'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
|
||||
export const SearchForm = () => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
|
||||
@@ -68,23 +69,23 @@ export const SearchForm = () => {
|
||||
}
|
||||
|
||||
const onSelectCourse = (course: SearchResultCourse) => {
|
||||
const isSelected = !!selectedCourses[course.code]
|
||||
const isSelected = !!selectedCourses[course.groupId]
|
||||
|
||||
if (isSelected) {
|
||||
setSelectedCourses(previousSelectedCourses => omitKeys(previousSelectedCourses, [course.code]))
|
||||
setSelectedCourses(previousSelectedCourses => omitKeys(previousSelectedCourses, [course.groupId]))
|
||||
} else {
|
||||
setSelectedCourses(previousSelectedCourses => ({
|
||||
...previousSelectedCourses,
|
||||
[course.code]: { ...course, selected: true },
|
||||
[course.groupId]: { ...course, selected: true },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const { courseCodes, ...rest } = query
|
||||
const { courses, ...rest } = query
|
||||
const queryObject = {
|
||||
...rest,
|
||||
courseCodes: JSON.stringify(courseCodes),
|
||||
courses: JSON.stringify(courses),
|
||||
combineSubstitutions: JSON.stringify(combineSubstitutions),
|
||||
}
|
||||
const searchString = queryParamsToString(queryObject)
|
||||
@@ -96,13 +97,15 @@ export const SearchForm = () => {
|
||||
}
|
||||
|
||||
const onSubmitFormClick = () => {
|
||||
const codes = sortBy(Object.keys(selectedCourses))
|
||||
const groupIds = sortBy(Object.keys(selectedCourses))
|
||||
const params = {
|
||||
courseCodes: codes,
|
||||
courses: groupIds,
|
||||
separate: false,
|
||||
combineSubstitutions,
|
||||
}
|
||||
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
|
||||
const searchHistoryText = groupIds.map(
|
||||
groupId => `${getTextIn(selectedCourses[groupId].name)} ${selectedCourses[groupId].code}`
|
||||
)
|
||||
addItemToSearchHistory({
|
||||
text: searchHistoryText.join(', '),
|
||||
params,
|
||||
@@ -110,10 +113,7 @@ export const SearchForm = () => {
|
||||
pushQueryToUrl(params)
|
||||
}
|
||||
|
||||
const clearSelectedCourses = () => {
|
||||
setSelectedCourses({})
|
||||
}
|
||||
|
||||
// If course clicked on single course mode, call submit hook
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedCourses).length === 0 || selectMultipleCourses) {
|
||||
return
|
||||
@@ -121,19 +121,13 @@ export const SearchForm = () => {
|
||||
onSubmitFormClick()
|
||||
}, [selectedCourses])
|
||||
|
||||
// Reset state after toggle
|
||||
useEffect(() => {
|
||||
clearSelectedCourses()
|
||||
}, [combineSubstitutions])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectMultipleCourses) {
|
||||
return
|
||||
}
|
||||
clearSelectedCourses()
|
||||
}, [selectMultipleCourses])
|
||||
setSelectedCourses({})
|
||||
}, [combineSubstitutions, selectMultipleCourses])
|
||||
|
||||
const courses = matchingCourses
|
||||
.filter(course => !selectedCourses[course.code])
|
||||
.filter(course => !selectedCourses[course.groupId])
|
||||
.sort((a, b) => {
|
||||
const yearA = dayjs(a.maxAttainmentDate).year()
|
||||
const yearB = dayjs(b.maxAttainmentDate).year()
|
||||
@@ -243,8 +237,8 @@ export const SearchForm = () => {
|
||||
</Alert>
|
||||
) : (
|
||||
<Section isLoading={isFetching || debouncedChanged ? isInputValid : false}>
|
||||
{selected.length > 0 && selectMultipleCourses ? (
|
||||
<Stack gap={2}>
|
||||
{selectMultipleCourses ? (
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
courses={selected}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const exportToExcel = (data: AttemptData[]) => {
|
||||
'Pass rate': formatPassRate(course.passRate),
|
||||
}
|
||||
const years: Row[] = course.realisations.map(realisation => ({
|
||||
Title: realisation.realisation,
|
||||
Title: realisation.realisation as string,
|
||||
Passed: realisation.passed.toString(),
|
||||
Failed: realisation.failed.toString(),
|
||||
'Pass rate': formatPassRate(realisation.passRate),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AttemptsTable } from '@/pages/CourseStatistics/SummaryTab/AttemptsTable
|
||||
import { exportToExcel } from '@/pages/CourseStatistics/SummaryTab/export'
|
||||
import { ALL, CourseStatisticsSummary, CourseStudyProgramme } from '@/pages/CourseStatistics/util'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { AttemptData } from '@/types/attemptData'
|
||||
import { DropdownOption } from '@/types/dropdownOption'
|
||||
import { getFullStudyProgrammeRights, hasAccessToAllCourseStats } from '@/util/access'
|
||||
|
||||
@@ -45,13 +44,13 @@ export const SummaryTab = ({
|
||||
setCourseSummaryFormProgrammes(selected)
|
||||
}
|
||||
|
||||
const data: AttemptData[] = statistics.map(stat => {
|
||||
const data = statistics.map(stat => {
|
||||
const { coursecode, name, realisations, summary } = stat
|
||||
const { passed, failed, passRate } = summary
|
||||
|
||||
return {
|
||||
id: coursecode,
|
||||
category: getTextIn(name)!,
|
||||
category: getTextIn(name),
|
||||
passed,
|
||||
failed,
|
||||
passRate,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { yearToYearCode } from '@oodikone/shared/util'
|
||||
export type CourseSearchState = 'openStats' | 'regularStats' | 'unifyStats'
|
||||
|
||||
export const CourseStatistics = () => {
|
||||
'use memo'
|
||||
const { programmeRights, roles } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const userHasAccessToAllStats = hasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
@@ -42,40 +43,46 @@ export const CourseStatistics = () => {
|
||||
const [tab, setTab] = useTabs(/* max tabs */ 3)
|
||||
|
||||
const location = useLocation()
|
||||
const { courseCodes, separate, combineSubstitutions } = parseQueryParams(location.search)
|
||||
const { courses, separate, combineSubstitutions } = parseQueryParams(location.search)
|
||||
|
||||
const codes = JSON.parse(courseCodes ?? '[]') as string[]
|
||||
const [initialCourseCode] = codes
|
||||
const singleCourseStats = codes.length === 1
|
||||
const courseGroupIds: string[] = JSON.parse(courses ?? '[]')
|
||||
const [initialCourseCode] = courseGroupIds
|
||||
const singleCourseStats = courseGroupIds.length === 1
|
||||
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
useTitle(selected ? `${selected} - Course statistics` : 'Course statistics')
|
||||
|
||||
const skipQuery = !initialCourseCode
|
||||
const shouldShowSearchForm = !initialCourseCode
|
||||
|
||||
const {
|
||||
data: courseStatsData = {},
|
||||
isFetching: isLoading,
|
||||
isSuccess,
|
||||
} = useGetCourseStatsQuery(
|
||||
{
|
||||
codes,
|
||||
courses: courseGroupIds,
|
||||
separate: separate === 'true',
|
||||
combineSubstitutions: combineSubstitutions === 'true',
|
||||
fromYearCode: '1',
|
||||
toYearCode: yearToYearCode(new Date().getFullYear()).toString(),
|
||||
},
|
||||
{ skip: skipQuery }
|
||||
{ skip: shouldShowSearchForm }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initialCourseCode)
|
||||
}, [initialCourseCode])
|
||||
|
||||
useTitle(
|
||||
selected && !isLoading
|
||||
? `${courseStatsData[selected]?.regularStats.courseCode} - Course statistics`
|
||||
: 'Course statistics'
|
||||
)
|
||||
|
||||
if (!checkUserAccess(['courseStatistics', 'admin', 'fullSisuAccess'], roles) && !fullStudyProgrammeRights.length) {
|
||||
return <AccessDeniedMessage />
|
||||
}
|
||||
|
||||
if (skipQuery)
|
||||
if (shouldShowSearchForm)
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<PageTitle title="Course statistics" />
|
||||
@@ -84,7 +91,7 @@ export const CourseStatistics = () => {
|
||||
)
|
||||
|
||||
const stats = getCourseStats(courseStatsData, openOrRegular)
|
||||
const alternatives = stats[selected]?.alternatives
|
||||
const substitutionGroups = stats[selected]?.substitutionGroups
|
||||
const allProgrammes = getAllStudyProgrammes(stats, undefined)
|
||||
const programmes = getAllStudyProgrammes(stats, selected)
|
||||
const summaryStatistics = getSummaryStatistics(
|
||||
@@ -96,8 +103,8 @@ export const CourseStatistics = () => {
|
||||
|
||||
const availableStats = getAvailableStats(courseStatsData)
|
||||
|
||||
const switchToCourse = (courseCode: string) => {
|
||||
setSelected(courseCode)
|
||||
const switchToCourse = (groupId: string) => {
|
||||
setSelected(groupId)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -119,7 +126,7 @@ export const CourseStatistics = () => {
|
||||
</Box>
|
||||
{tab === 0 && (
|
||||
<CourseTab
|
||||
alternatives={alternatives}
|
||||
substitutionGroups={substitutionGroups}
|
||||
availableStats={availableStats[selected]}
|
||||
combineSubstitutions={combineSubstitutions === 'true'}
|
||||
loading={isLoading || !isSuccess}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { AvailableStats, CourseStat, Realisation } from '@/types/courseStat'
|
||||
import { AvailableStats } from '@/types/courseStat'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseStat, Realisation, YearlyStatsByCourse } from '@oodikone/shared/types/courseYearlyStats'
|
||||
import { isSpring } from '@oodikone/shared/util'
|
||||
|
||||
export type CourseStats = Record<string, { openStats: CourseStat; regularStats: CourseStat; unifyStats: CourseStat }>
|
||||
export type CourseStudyProgramme = {
|
||||
key: string
|
||||
value: string
|
||||
@@ -16,7 +16,13 @@ export type CourseStatisticsSummary = {
|
||||
coursecode: string
|
||||
name: Name
|
||||
summary: { passed: number; failed: number; passRate: string | null }
|
||||
realisations: { realisation: string; passed: number; failed: number; passRate: string | null; obfuscated?: boolean }[]
|
||||
realisations: {
|
||||
realisation: Name | string
|
||||
passed: number
|
||||
failed: number
|
||||
passRate: string | null
|
||||
obfuscated?: boolean
|
||||
}[]
|
||||
}[]
|
||||
|
||||
export const ALL = {
|
||||
@@ -62,13 +68,10 @@ export const formatPassRate = (passRate: string | null) => {
|
||||
return `${passRate} %`
|
||||
}
|
||||
|
||||
export const getCourseStats = (
|
||||
courseStats: CourseStats,
|
||||
openOrRegular: CourseSearchState
|
||||
): Record<string, CourseStat> =>
|
||||
Object.fromEntries(Object.entries(courseStats).map(([courseCode, value]) => [courseCode, value[openOrRegular]]))
|
||||
export const getCourseStats = (courseStats: YearlyStatsByCourse, openOrRegular: CourseSearchState) =>
|
||||
Object.fromEntries(Object.entries(courseStats).map(([groupId, value]) => [groupId, value[openOrRegular]]))
|
||||
|
||||
export const getAvailableStats = (courseStats: CourseStats): Record<string, AvailableStats> =>
|
||||
export const getAvailableStats = (courseStats: YearlyStatsByCourse): Record<string, AvailableStats> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(courseStats).map(([courseCode, { unifyStats, openStats, regularStats }]) => [
|
||||
courseCode,
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { RTKApi } from '@/apiConnection'
|
||||
import { CourseStat } from '@/types/courseStat'
|
||||
import { CourseYearlyStats } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
const courseStatsApi = RTKApi.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getCourseStats: builder.query({
|
||||
query: ({
|
||||
codes,
|
||||
courses,
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
fromYearCode,
|
||||
toYearCode,
|
||||
}: {
|
||||
codes: string[]
|
||||
courses: string[]
|
||||
separate?: boolean
|
||||
combineSubstitutions?: boolean
|
||||
fromYearCode: string
|
||||
toYearCode: string
|
||||
}) => ({
|
||||
url: '/courseyearlystats',
|
||||
params: { codes, separate, combineSubstitutions, fromYearCode, toYearCode },
|
||||
params: { courses, separate, combineSubstitutions, fromYearCode, toYearCode },
|
||||
}),
|
||||
transformResponse: (
|
||||
courseStats: { openStats: CourseStat; regularStats: CourseStat; unifyStats: CourseStat }[]
|
||||
) => {
|
||||
const data: Record<string, { openStats: CourseStat; regularStats: CourseStat; unifyStats: CourseStat }> = {}
|
||||
transformResponse: (courseStats: CourseYearlyStats[]) => {
|
||||
const data: Record<string, CourseYearlyStats> = {}
|
||||
courseStats.forEach(stat => {
|
||||
data[stat.unifyStats.coursecode] = stat
|
||||
if (stat.unifyStats) {
|
||||
data[stat.unifyStats.groupId] = stat
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
@@ -41,4 +41,3 @@ const courseStatsApi = RTKApi.injectEndpoints({
|
||||
})
|
||||
|
||||
export const { useGetCourseStatsQuery, useGetCourseDetailsQuery } = courseStatsApi
|
||||
// export const clearCourseStats = () => courseStatsApi.util.resetApiState()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
|
||||
export type AttemptData = {
|
||||
category: string | null | undefined
|
||||
failed: number
|
||||
@@ -9,6 +11,6 @@ export type AttemptData = {
|
||||
obfuscated?: boolean
|
||||
passed: number
|
||||
passRate: string | null
|
||||
realisation: string
|
||||
realisation: string | Name
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -1,68 +1,4 @@
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
|
||||
export type FacultyStat = {
|
||||
allCredits: number
|
||||
allPassed: string[]
|
||||
allStudents: string[]
|
||||
faculties: Record<
|
||||
string,
|
||||
{
|
||||
credits: number
|
||||
name: Name
|
||||
passed: string[]
|
||||
students: string[]
|
||||
}
|
||||
>
|
||||
year: string
|
||||
}
|
||||
|
||||
export type Enrollment = {
|
||||
enrollmentDateTime: string
|
||||
studentNumber: string
|
||||
}
|
||||
|
||||
export type Attempts = {
|
||||
categories: {
|
||||
failed: string[]
|
||||
passed: string[]
|
||||
}
|
||||
grades: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type Students = {
|
||||
grades: Record<string, string[]>
|
||||
studentNumbers: string[]
|
||||
}
|
||||
|
||||
export type Realisation = {
|
||||
allEnrollments: Enrollment[]
|
||||
attempts: Attempts
|
||||
code: number
|
||||
coursecode: string
|
||||
enrollments: Enrollment[]
|
||||
name: string
|
||||
obfuscated?: boolean
|
||||
students: Students
|
||||
yearCode: number
|
||||
}
|
||||
|
||||
export type CourseStat = {
|
||||
alternatives: { code: string; name: Name }[][]
|
||||
coursecode: string
|
||||
facultyStats: Record<string, FacultyStat>
|
||||
name: Name
|
||||
obfuscated: boolean
|
||||
programmes: Record<
|
||||
string,
|
||||
{
|
||||
credits: Record<string, number>
|
||||
name: Name
|
||||
passed: Record<string, string[]>
|
||||
students: Record<string, string[]>
|
||||
}
|
||||
>
|
||||
statistics: Realisation[]
|
||||
}
|
||||
import { Enrollment } from '@oodikone/shared/types/courseYearlyStats'
|
||||
|
||||
export type FormattedStats = {
|
||||
attempts: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export type CoursesMultiQuery = {
|
||||
|
||||
export type CourseYearlyStatsReqBody = never
|
||||
export type CourseYearlyStatsQuery = {
|
||||
codes: string[]
|
||||
courses: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
fromYearCode: string
|
||||
|
||||
@@ -8,7 +8,7 @@ export type Programme = {
|
||||
}
|
||||
|
||||
type Faculty = {
|
||||
name: Name | null
|
||||
name: Name
|
||||
students: string[]
|
||||
passed: string[]
|
||||
credits: number
|
||||
@@ -37,6 +37,7 @@ type GroupStudents = {
|
||||
studentNumbers: string[]
|
||||
}
|
||||
|
||||
// TODO: combine with "Realisation" brought here from frontend.
|
||||
export type Group = {
|
||||
code: number
|
||||
// string: Year number as string
|
||||
@@ -54,3 +55,73 @@ export type Student = {
|
||||
code: number
|
||||
earliestAttainment: Date
|
||||
}
|
||||
|
||||
// Fields are undefined only in case of early return due to error
|
||||
export type CourseYearlyStats = { openStats: CourseStat; regularStats: CourseStat; unifyStats: CourseStat }
|
||||
|
||||
export type YearlyStatsByCourse = Record<string, CourseYearlyStats>
|
||||
|
||||
export type Realisation = {
|
||||
allEnrollments: Enrollment[]
|
||||
attempts: Attempts
|
||||
code: number
|
||||
coursecode: string
|
||||
enrollments: Enrollment[]
|
||||
name: string | Name
|
||||
obfuscated?: boolean
|
||||
students: Students
|
||||
yearCode: number
|
||||
}
|
||||
|
||||
export type Enrollment = {
|
||||
enrollmentDateTime: Date
|
||||
studentNumber: string
|
||||
}
|
||||
|
||||
export type FacultyStat = {
|
||||
allCredits: number
|
||||
allPassed: string[]
|
||||
allStudents: string[]
|
||||
faculties: Record<
|
||||
string,
|
||||
{
|
||||
credits: number
|
||||
name: Name
|
||||
passed: string[]
|
||||
students: string[]
|
||||
}
|
||||
>
|
||||
year: string
|
||||
}
|
||||
|
||||
export type Attempts = {
|
||||
categories: {
|
||||
failed: string[]
|
||||
passed: string[]
|
||||
}
|
||||
grades: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type Students = {
|
||||
grades: Record<string, string[]>
|
||||
studentNumbers: string[]
|
||||
}
|
||||
|
||||
export type CourseStat = {
|
||||
substitutionGroups: { code: string; name: Name; groupId: string }[][]
|
||||
courseCode: string
|
||||
groupId: string
|
||||
facultyStats: Record<string, FacultyStat>
|
||||
name: Name
|
||||
obfuscated: boolean
|
||||
programmes: Record<
|
||||
string,
|
||||
{
|
||||
credits: Record<string, number>
|
||||
name: Name
|
||||
passed: Record<string, string[]>
|
||||
students: Record<string, string[]>
|
||||
}
|
||||
>
|
||||
statistics: Realisation[]
|
||||
}
|
||||
|
||||
@@ -133,13 +133,7 @@ export const mapTeacher = person => ({
|
||||
})
|
||||
|
||||
export const creditMapper =
|
||||
(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
moduleGroupIdToModuleCode,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
) =>
|
||||
(personIdToStudentNumber, courseUnitIdToCourseCode, moduleGroupIdToModuleCode, studyRightIdToEducationType) =>
|
||||
attainment => {
|
||||
try {
|
||||
const {
|
||||
@@ -167,16 +161,22 @@ export const creditMapper =
|
||||
|
||||
if (!targetSemester) return null
|
||||
|
||||
const course_code = !isModule(type)
|
||||
? courseGroupIdToCourseCode[courseUnitIdToCourseGroupId[course_unit_id]]
|
||||
// Check if attainment is a module type
|
||||
const isStudyModule = isModule(type)
|
||||
|
||||
const course_code = !isStudyModule
|
||||
? courseUnitIdToCourseCode[course_unit_id]
|
||||
: moduleGroupIdToModuleCode[module_group_id]
|
||||
|
||||
const course_id = !isModule(type) ? courseUnitIdToCourseGroupId[course_unit_id] : module_group_id
|
||||
const course_id = !isStudyModule ? course_unit_id : module_group_id
|
||||
|
||||
// TODO: Some legitimate (pre 2020) CustomAttainments are skipped, because code/id mapping is incomplete.
|
||||
if (!course_id || !course_code) return null
|
||||
|
||||
let is_open = false
|
||||
|
||||
// check if ay code or ay study right or ay responsible organisation
|
||||
if (course_code && !isModule(type)) {
|
||||
if (course_code && !isStudyModule) {
|
||||
if (course_code.match(/^AY?(.+?)(?:en|fi|sv)?$/)) {
|
||||
is_open = true
|
||||
} else if (study_right_id !== null) {
|
||||
@@ -188,15 +188,12 @@ export const creditMapper =
|
||||
} else if (
|
||||
organisations
|
||||
.filter(({ roleUrn }) => roleUrn === 'urn:code:organisation-role:responsible-organisation')
|
||||
.some(org => org.organisationid === 'hy-org-48645785')
|
||||
.some(org => org.organisationid === 'hy-org-48645785') // NB: corresponds to code H930
|
||||
) {
|
||||
is_open = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if attainment is a module type
|
||||
const isStudyModule = isModule(type)
|
||||
|
||||
const gradeObject = getGrade(grade_scale_id, grade_id)
|
||||
const grade = gradeObject.value
|
||||
const credittypecode = getCreditTypeCodeFromAttainment(attainment, gradeObject.passed)
|
||||
@@ -280,32 +277,34 @@ export const mapCurriculumPeriod = curriculumPeriod => ({
|
||||
})
|
||||
|
||||
export const enrollmentMapper =
|
||||
(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
realisationIdToActivityPeriod,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
) =>
|
||||
(personIdToStudentNumber, courseUnitIdToCourseUnit, realisationIdToActivityPeriod, studyRightIdToEducationType) =>
|
||||
enrollment => {
|
||||
const studentnumber = personIdToStudentNumber[enrollment.person_id]
|
||||
if (!studentnumber) return null
|
||||
|
||||
const courseUnit = courseUnitIdToCourseUnit[enrollment.course_unit_id]
|
||||
// This happens if the CU is in draft state (filtered out), while the enrollment is active
|
||||
// for now skip, plan is to bring (non-future) drafts to oodikone at some point.
|
||||
if (!courseUnit) return null
|
||||
|
||||
// TODO: add a separate field to enrollments, so we can have both acual enrollment datetime, and
|
||||
// time when the course instance acually happened
|
||||
const startOfCURActivityPeriod = realisationIdToActivityPeriod[enrollment.course_unit_realisation_id]?.startDate
|
||||
const targetSemester = getSemesterByDate(new Date(startOfCURActivityPeriod ?? enrollment.enrolment_date_time))
|
||||
const course_id = courseUnitIdToCourseGroupId[enrollment.course_unit_id]
|
||||
|
||||
return {
|
||||
id: enrollment.id,
|
||||
studentnumber,
|
||||
state: enrollment.state,
|
||||
course_code: courseGroupIdToCourseCode[course_id],
|
||||
course_code: courseUnit.code,
|
||||
semestercode: targetSemester.semestercode,
|
||||
semester_composite: targetSemester.composite,
|
||||
enrollment_date_time: enrollment.enrolment_date_time,
|
||||
is_open:
|
||||
studyRightIdToEducationType[enrollment.study_right_id] ===
|
||||
'urn:code:education-type:non-degree-education:open-university-studies',
|
||||
course_id,
|
||||
course_id: enrollment.course_unit_id,
|
||||
course_group_id: courseUnit.group_id,
|
||||
studyright_id: enrollment.study_right_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { flatten, sortBy, uniqBy } from 'lodash-es'
|
||||
import { Op } from 'sequelize'
|
||||
import { rootOrgId } from '../../config.js'
|
||||
import { dbConnections } from '../../db/connection.js'
|
||||
import { selectFromByIds, bulkCreate, selectOneById } from '../../db/index.js'
|
||||
@@ -35,25 +34,32 @@ export const updateAttainments = async (
|
||||
studyRightIdToEducationType
|
||||
) => {
|
||||
await updateTeachers(attainments)
|
||||
|
||||
const { cuIds, modGroupIds } = attainments.reduce(
|
||||
(acc, { course_unit_id, module_group_id }) => {
|
||||
if (course_unit_id) {
|
||||
acc.cuIds.push(course_unit_id)
|
||||
}
|
||||
if (module_group_id) {
|
||||
acc.modGroupIds.push(module_group_id)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ cuIds: [], modGroupIds: [] }
|
||||
)
|
||||
|
||||
const [courseUnits, modules] = await Promise.all([
|
||||
selectFromByIds(
|
||||
'course_units',
|
||||
attainments.map(a => a.course_unit_id).filter(id => !!id)
|
||||
),
|
||||
selectFromByIds(
|
||||
'modules',
|
||||
attainments.map(a => a.module_group_id).filter(id => !!id),
|
||||
'group_id'
|
||||
),
|
||||
dbConnections.knex('course_units').select(['id', 'code']).whereIn('id', cuIds),
|
||||
dbConnections.knex('modules').select(['group_id', 'code']).whereIn('group_id', modGroupIds),
|
||||
])
|
||||
|
||||
const courseUnitIdToCourseGroupId = courseUnits.reduce((res, curr) => {
|
||||
res[curr.id] = curr.group_id
|
||||
const courseUnitIdToCourseCode = courseUnits.reduce((res, cu) => {
|
||||
res[cu.id] = cu.code
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const moduleGroupIdToModuleCode = modules.reduce((res, curr) => {
|
||||
res[curr.group_id] = curr.code
|
||||
const moduleGroupIdToModuleCode = modules.reduce((res, module) => {
|
||||
res[module.group_id] = module.code
|
||||
return res
|
||||
}, {})
|
||||
|
||||
@@ -65,19 +71,6 @@ export const updateAttainments = async (
|
||||
)
|
||||
)
|
||||
|
||||
const sisDbCoursesForStudentAttainments = await Course.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: Object.values(courseUnitIdToCourseGroupId),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const courseGroupIdToCourseCode = sisDbCoursesForStudentAttainments.reduce((res, curr) => {
|
||||
res[curr.id] = curr.code
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const creditTeachers = []
|
||||
|
||||
const coursesToBeCreated = new Map()
|
||||
@@ -89,14 +82,15 @@ export const updateAttainments = async (
|
||||
if (att.module_group_id) {
|
||||
const studyModule = await selectOneById('modules', att.module_group_id, 'group_id')
|
||||
|
||||
// Fix attainments with missing modules (see for example issue #4761)
|
||||
// Sometimes attainments point to a module that does not exist in db. likely because
|
||||
// the module document_state is "DRAFT", and thus dropped by importer. (see for example issue #4761)
|
||||
if (!studyModule) {
|
||||
if (att.module_group_id === 'hy-SM-89304486') {
|
||||
const course = await Course.findOne({ where: { code: '71066' } })
|
||||
const course = await Course.findOne({ attributes: ['id'], where: { code: '71066' }, raw: true })
|
||||
return { ...att, module_group_id: course.id }
|
||||
}
|
||||
if (att.module_group_id === 'hy-SM-100017957') {
|
||||
const course = await Course.findOne({ where: { code: '523102' } })
|
||||
const course = await Course.findOne({ attributes: ['id'], where: { code: '523102' }, raw: true })
|
||||
return { ...att, module_group_id: course.id }
|
||||
}
|
||||
const education = await selectOneById('educations', att.module_group_id.replace('DP', 'EDU'))
|
||||
@@ -106,10 +100,9 @@ export const updateAttainments = async (
|
||||
id: att.module_group_id,
|
||||
name: education.name,
|
||||
code: education.code,
|
||||
main_course_code: education.code,
|
||||
coursetypecode: att.study_level_urn,
|
||||
course_unit_type: att.course_unit_type_urn,
|
||||
substitutions: [],
|
||||
substitution_groups: [],
|
||||
})
|
||||
return att
|
||||
}
|
||||
@@ -122,10 +115,9 @@ export const updateAttainments = async (
|
||||
sv: 'Okänd studiehelhet',
|
||||
},
|
||||
code: att.module_group_id,
|
||||
main_course_code: att.module_group_id,
|
||||
coursetypecode: att.study_level_urn,
|
||||
course_unit_type: att.course_unit_type_urn,
|
||||
substitutions: [],
|
||||
substitution_groups: [],
|
||||
})
|
||||
return att
|
||||
}
|
||||
@@ -162,7 +154,7 @@ export const updateAttainments = async (
|
||||
const parsedCourseCode = attIdToCourseCode[att.id]
|
||||
|
||||
// see if course exists
|
||||
course = await Course.findOne({ where: { code: parsedCourseCode, id: parsedCourseCode } })
|
||||
course = await Course.findOne({ where: { code: parsedCourseCode, id: parsedCourseCode }, raw: true })
|
||||
|
||||
// If course doesn't exist, create it
|
||||
if (!course) {
|
||||
@@ -174,7 +166,7 @@ export const updateAttainments = async (
|
||||
coursetypecode: att.study_level_urn,
|
||||
max_attainment_date: att.attainment_date,
|
||||
min_attainment_date: att.attainment_date,
|
||||
substitutions: [],
|
||||
substitution_groups: [],
|
||||
course_unit_type: att.course_unit_type_urn,
|
||||
}
|
||||
|
||||
@@ -207,31 +199,24 @@ export const updateAttainments = async (
|
||||
courseUnit.group_id = courseUnit.id
|
||||
}
|
||||
|
||||
// Add the course to the mapping objects for creditMapper to work properly.
|
||||
courseUnitIdToCourseGroupId[courseUnit.id] = courseUnit.group_id
|
||||
courseGroupIdToCourseCode[courseUnit.group_id] = courseUnit.code
|
||||
// Add the CU to the mapping objects for creditMapper to work properly.
|
||||
courseUnitIdToCourseCode[courseUnit.id] = courseUnit.code
|
||||
|
||||
return { ...att, course_unit_id: courseUnit.id }
|
||||
}
|
||||
|
||||
const findMissingCourseCodes = (attainmentIdCodeMap, att) => {
|
||||
if (!customAttainmentTypes.includes(att.type)) {
|
||||
if (!customAttainmentTypes.includes(att.type) || !att.code) {
|
||||
return attainmentIdCodeMap
|
||||
}
|
||||
if (!att.code) return attainmentIdCodeMap
|
||||
|
||||
const codeParts = att.code.split('-')
|
||||
if (!codeParts.length) return attainmentIdCodeMap
|
||||
|
||||
let parsedCourseCode = ''
|
||||
const parsedCourseCode = codeParts[1]?.length < 7 ? `${codeParts[0]}-${codeParts[1]}` : codeParts[0]
|
||||
|
||||
if (codeParts.length === 1) parsedCourseCode = codeParts[0]
|
||||
else if (codeParts[1].length < 7) {
|
||||
parsedCourseCode = `${codeParts[0]}-${codeParts[1]}`
|
||||
} else {
|
||||
parsedCourseCode = codeParts[0]
|
||||
}
|
||||
return { ...attainmentIdCodeMap, [att.id]: parsedCourseCode }
|
||||
attainmentIdCodeMap[att.id] = parsedCourseCode
|
||||
return attainmentIdCodeMap
|
||||
}
|
||||
|
||||
const attainmentIdCourseCodeMapForCustomCourseUnitAttainments = attainments.reduce(findMissingCourseCodes, {})
|
||||
@@ -267,9 +252,8 @@ export const updateAttainments = async (
|
||||
|
||||
const mapCredit = creditMapper(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
courseUnitIdToCourseCode,
|
||||
moduleGroupIdToModuleCode,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
)
|
||||
|
||||
@@ -277,21 +261,20 @@ export const updateAttainments = async (
|
||||
|
||||
for (const attainment of fixedAttainments) {
|
||||
if (
|
||||
attainment == null ||
|
||||
attainment.id == null ||
|
||||
!attainment?.id ||
|
||||
!validAttainmentTypes.includes(attainment.type) ||
|
||||
attainment.misregistration ||
|
||||
attainmentsToBeExluced.has(attainment.id) ||
|
||||
doubleAttachment(attainment, fixedAttainments)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mappedCredit = mapCredit(attainment)
|
||||
if (mappedCredit) {
|
||||
for (const person of attainment.acceptor_persons) {
|
||||
if (person.roleUrn === 'urn:code:attainment-acceptor-type:approved-by' && person.personId) {
|
||||
const teacher = await Teacher.findOne({ where: { id: person.personId } })
|
||||
if (teacher !== null) {
|
||||
const teacher = await Teacher.findOne({ where: { id: person.personId }, raw: true })
|
||||
if (teacher) {
|
||||
creditTeachers.push({
|
||||
credit_id: attainment.id,
|
||||
teacher_id: person.personId,
|
||||
@@ -306,7 +289,7 @@ export const updateAttainments = async (
|
||||
const courses = Array.from(coursesToBeCreated.values())
|
||||
|
||||
await bulkCreate(Course, courses)
|
||||
await bulkCreate(Credit, credits)
|
||||
await bulkCreate(Credit, credits.filter(Boolean))
|
||||
await bulkCreate(
|
||||
CreditTeacher,
|
||||
uniqBy(creditTeachers, cT => `${cT.credit_id}-${cT.teacher_id}`),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { flatten, groupBy, orderBy } from 'lodash-es'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { bulkCreate, selectFromActiveSnapshotsByIds, selectFromByIds } from '../../db/index.js'
|
||||
import { Course, Enrollment, Student } from '../../db/models/index.js'
|
||||
import { Enrollment, Student } from '../../db/models/index.js'
|
||||
import logger from '../../utils/logger.js'
|
||||
import { studentMapper, enrollmentMapper } from '../mapper.js'
|
||||
import { getEducation, loadMapsIfNeeded } from '../shared.js'
|
||||
@@ -10,6 +9,7 @@ import { updateAttainments } from './attainments.js'
|
||||
import { getAttainmentsToBeExcluded } from './excludedPartialAttainments.js'
|
||||
import { updateSISStudyRights, updateSISStudyRightElements } from './SISStudyRights.js'
|
||||
import { updateStudyplans, findStudentsToReupdate } from './studyPlans.js'
|
||||
import { dbConnections } from '../../db/connection.js'
|
||||
|
||||
// Accepted selection path is not available when degree programme doesn't have
|
||||
// studytrack or major subject. This is a known bug on SIS and has been reported
|
||||
@@ -101,46 +101,39 @@ const updateEnrollments = async (enrollments, personIdToStudentNumber, studyRigh
|
||||
const validEnrollments = enrollments.filter(
|
||||
({ state, document_state }) => document_state === 'ACTIVE' && ['ENROLLED', 'REJECTED'].includes(state)
|
||||
)
|
||||
|
||||
const { curIds, cuIds } = validEnrollments.reduce(
|
||||
(acc, enrollment) => {
|
||||
acc.curIds.push(enrollment.course_unit_realisation_id)
|
||||
acc.cuIds.push(enrollment.course_unit_id)
|
||||
return acc
|
||||
},
|
||||
{ curIds: [], cuIds: [] }
|
||||
)
|
||||
|
||||
const [courseUnitRealisations, courseUnits] = await Promise.all([
|
||||
selectFromByIds(
|
||||
'course_unit_realisations',
|
||||
validEnrollments.map(({ course_unit_realisation_id }) => course_unit_realisation_id).filter(id => !!id)
|
||||
),
|
||||
selectFromByIds(
|
||||
'course_units',
|
||||
validEnrollments.map(({ course_unit_id }) => course_unit_id).filter(id => !!id)
|
||||
),
|
||||
dbConnections
|
||||
.knex('course_unit_realisations')
|
||||
.select(['id', 'activity_period'])
|
||||
.whereIn('id', curIds)
|
||||
.where('document_state', 'ACTIVE'),
|
||||
dbConnections.knex('course_units').select(['id', 'code', 'group_id']).whereIn('id', cuIds),
|
||||
])
|
||||
|
||||
const realisationIdToActivityPeriod = courseUnitRealisations
|
||||
.filter(cur => cur.document_state === 'ACTIVE')
|
||||
.reduce((res, cur) => {
|
||||
const realisationIdToActivityPeriod = courseUnitRealisations.reduce((res, cur) => {
|
||||
res[cur.id] = cur.activity_period
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const courseUnitIdToCourseGroupId = courseUnits.reduce((res, curr) => {
|
||||
res[curr.id] = curr.group_id
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const sisDbCoursesForStudentAttainments = await Course.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: Object.values(courseUnitIdToCourseGroupId),
|
||||
},
|
||||
},
|
||||
})
|
||||
const courseGroupIdToCourseCode = sisDbCoursesForStudentAttainments.reduce((res, curr) => {
|
||||
res[curr.id] = curr.code
|
||||
const courseUnitIdToCourseUnit = courseUnits.reduce((res, { id, ...rest }) => {
|
||||
res[id] = rest
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const mapEnrollment = enrollmentMapper(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
courseUnitIdToCourseUnit,
|
||||
realisationIdToActivityPeriod,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user