[WIP][Course refactor] backup working tree

This commit is contained in:
joonas-a
2026-09-21 15:24:21 +03:00
committed by Aaro Koivukangas
parent c908a61c0f
commit 96f8b43732
26 changed files with 602 additions and 600 deletions
+6 -5
View File
@@ -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,86 +4,86 @@ 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'],
attributes: [
'grade',
'course_code',
'course_id',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
where: {
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']],
include: [
{
model: CreditModel,
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
course_code: {
[Op.in]: allCourseCodes,
startdate: {
[Op.lte]: now(),
},
is_open: getIsOpen(unification),
attainment_date: { [Op.between]: [from, to] },
},
order: [['attainment_date', 'ASC']],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
[Op.lte]: now(),
},
},
},
],
},
],
})
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,
startdate: {
[Op.lte]: now(),
},
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)
}
+209 -205
View File
@@ -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,186 +185,183 @@ const getSemesterAndYearByDate = (
return { year, semester }
}
export const getYearlyStatsOfNew = async (
course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null,
courseCode: string,
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]]
export const getYearlyStatsOfNew =
(
primaryCourse: Pick<CourseModel, 'id' | 'groupId' | 'code' | 'name' | 'substitutionGroups'>,
separate: boolean,
anonymizationSalt: string | null,
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>,
from: Date,
to: Date
) =>
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 { semesters, years } = await getSemestersAndYears()
const filteredCreditGroupIds =
unification === Unification.REGULAR
? // FIXME: isOpenUniCourseCode does not work yet with groupIds
creditGroupIds.filter(group => group.every(course => !isOpenUniCourseCode(course)))
: creditGroupIds
const [creditGroups, enrollmentGroups] = await Promise.all([
getCreditsForCourses(filteredCreditGroupCodes, unification, from, to),
getEnrollmentsForCourses(filteredCreditGroupCodes, unification, from, to),
])
const allCourseIds = (
await CourseModel.findAll({
raw: true,
attributes: ['id'],
where: { groupId: { [Op.in]: filteredCreditGroupIds.flat() } },
})
).map(({ id }) => id)
const counter = new CourseYearlyStatsCounter()
const { semesters, years } = await getSemestersAndYears()
// A single-course credit group can contain more than one real attempt for the same course code
// (e.g. failed once and passed on a retry). parseCredit collapses a group into a single
// attainment for student-stats (no student is counted twice), but attempt-stats (include all attempts for a course)
// need every attempt counted in its own year/semester.
// Substitution groups are left untouched
const splitIntoAttempts = (group: Credit[]): Credit[][] =>
isSingleCourse(group) && group.length > 1 ? group.map(credit => [credit]) : [group]
const [creditGroups, enrollmentGroups] = await Promise.all([
getCreditsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
getEnrollmentsForCourses(filteredCreditGroupIds, allCourseIds, unification, from, to),
])
for (const creditGroup of creditGroups) {
if (!creditGroup?.length) continue
const counter = new CourseYearlyStatsCounter()
const studyRightElements = studentNumberToSrElementsMap[creditGroup.at(0)?.student_studentnumber ?? 0] ?? []
// A single-course credit group can contain more than one real attempt for the same course code
// (e.g. failed once and passed on a retry). parseCredit collapses a group into a single
// attainment for student-stats (no student is counted twice), but attempt-stats (include all attempts for a course)
// need every attempt counted in its own year/semester.
// Substitution groups are left untouched
const splitIntoAttempts = (group: Credit[]): Credit[][] =>
isSingleCourse(group) && group.length > 1 ? group.map(credit => [credit]) : [group]
const {
studentNumber,
grade,
passed,
semesterCode,
semesterName,
yearCode,
yearName,
attainmentDate,
programme,
courseCode: creditCourseCode,
credits,
creditTypeCode,
} = parseCredit(creditGroup, anonymizationSalt, courseCode, studyRightElements)
for (const creditGroup of creditGroups) {
if (!creditGroup?.length) continue
counter.markStudyProgramme(
studentNumber,
yearCode,
passed,
credits,
programme.code,
programme.name,
programme.facultyCode,
programme.organization
)
const studyRightElements = studentNumberToSrElementsMap[creditGroup.at(0)?.student_studentnumber ?? 0] ?? []
// Credits/attainments have quaranteed matching attainment_date and semesters/years
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
counter.markStudentGradeToGroup(
studentNumber,
passed,
grade,
groupCode,
groupName,
creditCourseCode,
yearCode,
creditTypeCode
)
// Don't add students to student stats based on improved grades
if (creditTypeCode !== CreditTypeCode.IMPROVED) {
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
}
// The same group can include a failed and a passed credit in which case the parseCredit
// would return only the passed credit. Hence we split the failed and passed credit into
// two separate groups
for (const attemptCredits of splitIntoAttempts(creditGroup)) {
const attempt = parseCredit(attemptCredits, anonymizationSalt, courseCode, studyRightElements)
const attemptGroupCode = separate ? attempt.semesterCode : attempt.yearCode
const attemptGroupName = separate ? attempt.semesterName : attempt.yearName
counter.markAttemptToGroup(
attempt.studentNumber,
attempt.passed,
attempt.grade,
attemptGroupCode,
attemptGroupName,
attempt.courseCode,
attempt.yearCode
)
}
}
for (const enrollments of enrollmentGroups) {
if (!enrollments?.length) continue
for (const enrollment of enrollments) {
const {
studentNumber,
courseCode: enrollmentCourseCode,
enrollmentDateTime,
grade,
passed,
semesterCode,
semesterName,
yearCode,
yearName,
attainmentDate,
programme,
} = parseEnrollment(
enrollment,
anonymizationSalt,
studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
)
// Enrollments can have conflicting enrollment_date_time and semestercode, so we need to manually select
// semestercode matching enrollment_date_time
const { semester, year } = getSemesterAndYearByDate(enrollment.enrollment_date_time, semesters, years)
courseCode: creditCourseCode,
credits,
creditTypeCode,
} = parseCredit(creditGroup, anonymizationSalt, courseCode, studyRightElements)
counter.markStudyProgramme(
studentNumber,
year.yearcode,
false, // passed
0, // credits
yearCode,
passed,
credits,
programme.code,
programme.name,
programme.facultyCode,
programme.organization
)
const groupCode = separate ? semester.semestercode : year.yearcode
const groupName = separate ? semester.name : year.yearname
counter.markEnrollmentToGroup(
// Credits/attainments have quaranteed matching attainment_date and semesters/years
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
counter.markStudentGradeToGroup(
studentNumber,
enrollmentDateTime,
passed,
grade,
groupCode,
groupName,
enrollmentCourseCode,
year.yearcode
creditCourseCode,
yearCode,
creditTypeCode
)
// Don't add students to student stats based on improved grades
if (creditTypeCode !== CreditTypeCode.IMPROVED) {
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
}
// The same group can include a failed and a passed credit in which case the parseCredit
// would return only the passed credit. Hence we split the failed and passed credit into
// two separate groups
for (const attemptCredits of splitIntoAttempts(creditGroup)) {
const attempt = parseCredit(attemptCredits, anonymizationSalt, courseCode, studyRightElements)
const attemptGroupCode = separate ? attempt.semesterCode : attempt.yearCode
const attemptGroupName = separate ? attempt.semesterName : attempt.yearName
counter.markAttemptToGroup(
attempt.studentNumber,
attempt.passed,
attempt.grade,
attemptGroupCode,
attemptGroupName,
attempt.courseCode,
attempt.yearCode
)
}
}
for (const enrollments of enrollmentGroups) {
for (const enrollment of enrollments) {
const studyRightElements = studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
const {
studentNumber,
courseCode: enrollmentCourseCode,
enrollmentDateTime,
programme,
} = parseEnrollment(enrollment, anonymizationSalt, studyRightElements)
// Enrollments can have conflicting enrollment_date_time and semestercode, so we need to manually select
// semestercode matching enrollment_date_time
const { semester, year } = getSemesterAndYearByDate(enrollment.enrollment_date_time, semesters, years)
counter.markStudyProgramme(
studentNumber,
year.yearcode,
false, // passed
0, // credits
programme.code,
programme.name,
programme.facultyCode,
programme.organization
)
const groupCode = separate ? semester.semestercode : year.yearcode
const groupName = separate ? semester.name : year.yearname
counter.markEnrollmentToGroup(
studentNumber,
enrollmentDateTime,
groupCode,
groupName,
enrollmentCourseCode,
year.yearcode
)
}
}
const statistics = await counter.getFinalStatistics(anonymizationSalt)
const substitutionGroups =
combineSubstitutions && primaryCourse.substitutionGroups?.length
? await getSubstitutionGroupDetails(primaryCourse.substitutionGroups)
: [[{ code: courseCode, name, groupId }]]
return {
...statistics,
courseCode,
groupId,
substitutionGroups,
name,
}
}
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 }]]
return {
...statistics,
coursecode: courseCode,
alternatives: substitutionGroups,
name: course?.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,
},
[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({
raw: true,
attributes: ['code', 'name', 'substitution_groups'],
where: { code: courseCode },
})
console.log('studentnumbers', [...studentNumbers].join(','))
if (!course) {
logger.error('Course for course stats not found with code' + courseCode)
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: ['groupId', 'id', 'code', 'name', 'substitutionGroups'],
where: { groupId, isPrimary: true },
})
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}
@@ -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 })
}
@@ -45,6 +45,7 @@ export const ResultTab = ({
combineSubstutitions: boolean
courseCodes: string[]
}) => {
'use memo'
const [settings, setSettings] = useState<ResultTabSettings>({
isRelative: false,
separate: initialSettings.separate,
@@ -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"
@@ -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,
+9 -10
View File
@@ -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()
+3 -1
View File
@@ -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 -65
View File
@@ -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: {
+1 -1
View File
@@ -11,7 +11,7 @@ export type CoursesMultiQuery = {
export type CourseYearlyStatsReqBody = never
export type CourseYearlyStatsQuery = {
codes: string[]
courses: string[]
separate: string
combineSubstitutions: string
fromYearCode: string
+72 -1
View File
@@ -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[]
}