Merge pull request #4623 from UniversityOfHelsinkiCS/type-bottlenecks

Type bottlenecks
This commit is contained in:
Riku Rauhala
2024-09-04 09:07:12 +03:00
committed by GitHub
7 changed files with 202 additions and 101 deletions
+4 -4
View File
@@ -146,7 +146,7 @@ jobs:
version: ${{ steps.extract_version.outputs.VERSION }} version: ${{ steps.extract_version.outputs.VERSION }}
report_success: report_success:
name: 'Report deployment success to oodikone notifications' name: 'Report deployment success to the "oodikone_notifications" channel'
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- release_frontend - release_frontend
@@ -158,7 +158,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 'Send notification to slack' - name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5 uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with: with:
webhook-url: ${{ secrets.WEBHOOK_URL }} webhook-url: ${{ secrets.WEBHOOK_URL }}
@@ -167,7 +167,7 @@ jobs:
deployment-target: production deployment-target: production
report_failure: report_failure:
name: 'Report deployment failure to oodikone notifications' name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- release_frontend - release_frontend
@@ -179,7 +179,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 'Send notification to slack' - name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5 uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with: with:
webhook-url: ${{ secrets.WEBHOOK_URL }} webhook-url: ${{ secrets.WEBHOOK_URL }}
+7 -7
View File
@@ -5,16 +5,16 @@ on:
branches: branches:
- master - master
paths: paths:
- '.github/workflows/test.yaml'
- '.github/workflows/staging.yaml'
- '.github/actions/**' - '.github/actions/**'
- '.github/workflows/staging.yaml'
- '.github/workflows/test.yaml'
- 'cypress/**' - 'cypress/**'
- 'cypress.config.json' - 'cypress.config.json'
- 'services/**' - 'services/**'
- '!services/backend/src/rapodiff/**' - '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml' - 'docker-compose.ci.yml'
- 'package*.json' - 'package*.json'
- 'updater/sis-updater-worker/src/db/migrations/**'
jobs: jobs:
test: test:
@@ -90,7 +90,7 @@ jobs:
password: ${{ secrets.QUAY_IO_TOKEN }} password: ${{ secrets.QUAY_IO_TOKEN }}
report_success: report_success:
name: 'Report deployment success to oodikone notifications' name: 'Report deployment success to the "oodikone_notifications" channel'
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- release_frontend - release_frontend
@@ -100,7 +100,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 'Send notification to slack' - name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5 uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with: with:
webhook-url: ${{ secrets.WEBHOOK_URL }} webhook-url: ${{ secrets.WEBHOOK_URL }}
@@ -109,7 +109,7 @@ jobs:
deployment-target: staging deployment-target: staging
report_failure: report_failure:
name: 'Report deployment failure to oodikone notifications' name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- release_frontend - release_frontend
@@ -119,7 +119,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 'Send notification to slack' - name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5 uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with: with:
webhook-url: ${{ secrets.WEBHOOK_URL }} webhook-url: ${{ secrets.WEBHOOK_URL }}
+39 -41
View File
@@ -96,7 +96,7 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
: req.body.selectedStudentsByYear[year] : req.body.selectedStudentsByYear[year]
} }
const newMonths = (upperYearBound - Number(year)) * 12 const newMonths = (upperYearBound - Number(year)) * 12
const query = { ...req.body, year, months: newMonths } const query = { ...req.body, year, months: newMonths, selectedStudents: req.body.selectedStudents as string[] }
const coursestatistics = bottlenecksOf(query, null, encrypted) const coursestatistics = bottlenecksOf(query, null, encrypted)
return coursestatistics return coursestatistics
}) })
@@ -114,13 +114,14 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
req.body.selectedStudents = req.body.selectedStudents.map(decrypt) req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
} }
const result = await bottlenecksOf(req.body, null, encrypted) const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
if (result.error) { try {
Sentry.captureException(new Error(result.error)) const result = await bottlenecksOf(query, null, encrypted)
return res.status(400).json(result) return res.json(result)
} catch (error: any) {
Sentry.captureException(new Error(error.message))
return res.status(400).end()
} }
return res.json(result)
}) })
interface PostPopulationStatisticsByTagRequest extends Request { interface PostPopulationStatisticsByTagRequest extends Request {
@@ -135,8 +136,7 @@ router.post(
const { roles, studentsUserCanAccess } = req.user const { roles, studentsUserCanAccess } = req.user
const { tag } = req.body const { tag } = req.body
if (!tag) { if (!tag) {
res.status(400).json({ error: 'The body should have a tag defined' }) return res.status(400).json({ error: 'The body should have a tag defined' })
return
} }
const studentNumbers = await findByTag(tag) const studentNumbers = await findByTag(tag)
@@ -144,24 +144,22 @@ router.post(
? studentNumbers ? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess) : intersection(studentNumbers, studentsUserCanAccess)
const result = await bottlenecksOf( try {
{ const result = await bottlenecksOf(
year: 1900, {
studyRights: [], year: '1900',
semesters: ['FALL', 'SPRING'], studyRights: [],
months: 10000, semesters: ['FALL', 'SPRING'],
tag, months: 10000,
}, tag,
studentNumberList },
) studentNumberList
)
if (result.error) { return res.json(result)
Sentry.captureException(new Error(result.error)) } catch (error: any) {
res.status(400).end() Sentry.captureException(new Error(error.message))
return return res.status(400).end()
} }
res.json(result)
} }
) )
@@ -190,22 +188,22 @@ router.post(
const studentNumbers = hasFullAccessToStudentData(roles) const studentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist ? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess) : intersection(studentnumberlist, studentsUserCanAccess)
const result = await bottlenecksOf(
{
year: query?.year ?? 1900,
studyRights: query?.studyRights ?? [],
semesters: query?.semesters ?? ['FALL', 'SPRING'],
months: query?.months ?? 10000,
},
studentNumbers
)
if (result.error) {
Sentry.captureException(new Error(result.error))
res.status(400).json(result)
return
}
res.json(result) try {
const result = await bottlenecksOf(
{
year: query?.year ?? 1900,
studyRights: query?.studyRights ?? [],
semesters: query?.semesters ?? ['FALL', 'SPRING'],
months: query?.months ?? 10000,
},
studentNumbers
)
return res.json(result)
} catch (error: any) {
Sentry.captureException(new Error(error.message))
return res.status(400).end()
}
} }
) )
@@ -53,11 +53,59 @@ type DynamicEnrollments = {
} }
} }
export type CourseStatistics = {
stats: {
students: number
passed: number
failed: number
failedMany: number
retryPassed: number
attempts: number
improvedPassedGrade: number
percentage: number | undefined
passedOfPopulation: number | undefined
triedOfPopulation: number | undefined
perStudent: number | undefined
passingSemesters: Record<string, number>
passingSemestersCumulative?: Record<string, number>
totalStudents?: number
totalEnrolledNoGrade?: number
percentageWithEnrollments?: number
}
students: {
all: Record<string, boolean>
passed: Record<string, boolean>
failed: Record<string, boolean>
retryPassed: Record<string, boolean>
failedMany: Record<string, boolean>
improvedPassedGrade: Record<string, boolean>
markedToSemester: Record<string, boolean>
enrolledNoGrade: Record<string, boolean>
}
course: {
code: string
name: Name
disciplines: object
coursetypes: Record<string, Name>
substitutions: string[]
}
grades: {
[grade: string]: {
count: number
status: {
passingGrade: boolean
improvedGrade: boolean
failingGrade: boolean
}
}
}
}
export class CourseStatsCounter { export class CourseStatsCounter {
private studentsInTotal: number private studentsInTotal: number
private course: { private course: {
code: string code: string
name: string name: Name
disciplines: object disciplines: object
coursetypes: Record<string, Name> coursetypes: Record<string, Name>
substitutions: string[] substitutions: string[]
@@ -102,7 +150,7 @@ export class CourseStatsCounter {
} }
} }
constructor(code: string, name: string, studentsInTotal: number) { constructor(code: string, name: Name, studentsInTotal: number) {
this.studentsInTotal = studentsInTotal this.studentsInTotal = studentsInTotal
this.course = { this.course = {
code, code,
@@ -289,6 +337,6 @@ export class CourseStatsCounter {
stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade) stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade)
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents) stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
return { stats, students, course: this.course, grades: this.grades } return { stats, students, course: this.course, grades: this.grades } as CourseStatistics
} }
} }
@@ -1,15 +1,29 @@
const { keyBy } = require('lodash') import { keyBy } from 'lodash'
const { getPassingSemester } = require('../../util/semester') import { Name } from '../../types'
const { CourseStatsCounter } = require('../courses/courseStatsCounter') import { getPassingSemester } from '../../util/semester'
const { encrypt } = require('../encrypt') import { CourseStatistics, CourseStatsCounter } from '../courses/courseStatsCounter'
const { dateMonthsFromNow, findCourses, findCourseEnrollments, parseCreditInfo, parseQueryParams } = require('./shared') import { encrypt } from '../encrypt'
const { getStudentNumbersWithAllStudyRightElements } = require('./studentNumbersWithAllElements') import {
dateMonthsFromNow,
findCourses,
findCourseEnrollments,
Params,
parseCreditInfo,
parseQueryParams,
Query,
QueryResult,
} from './shared'
import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements'
const getStudentsAndCourses = async (params, selectedStudents, studentNumbers, courseCodes) => { const getStudentsAndCourses = async (
params: Params,
selectedStudents: string[] | undefined,
studentNumbers: string[] | null,
courseCodes: string[] | undefined
) => {
if (!studentNumbers) { if (!studentNumbers) {
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents, tag } = const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
params
const studentnumbers = const studentnumbers =
selectedStudents || selectedStudents ||
(await getStudentNumbersWithAllStudyRightElements({ (await getStudentNumbersWithAllStudyRightElements({
@@ -19,9 +33,6 @@ const getStudentsAndCourses = async (params, selectedStudents, studentNumbers, c
exchangeStudents, exchangeStudents,
nondegreeStudents, nondegreeStudents,
transferredOutStudents: transferredStudents, transferredOutStudents: transferredStudents,
tag,
transferredToStudents: true,
graduatedStudents: true,
})) }))
const allStudents = studentnumbers.length const allStudents = studentnumbers.length
@@ -48,8 +59,15 @@ const getStudentsAndCourses = async (params, selectedStudents, studentNumbers, c
return [allStudents, courses, courseEnrollments] return [allStudents, courses, courseEnrollments]
} }
const bottlenecksOf = async (query, studentNumbers, encryptData = false) => { type Bottlenecks = {
const encryptStudentNumbers = bottlenecks => { disciplines: Record<string, string>
coursetypes: Record<string, Name>
coursestatistics: CourseStatistics[]
allStudents: number
}
export const bottlenecksOf = async (query: Query, studentNumbers: string[] | null, encryptData: boolean = false) => {
const encryptStudentNumbers = (bottlenecks: Bottlenecks) => {
for (const course of Object.keys(bottlenecks.coursestatistics)) { for (const course of Object.keys(bottlenecks.coursestatistics)) {
const encryptedStudentStats = {} const encryptedStudentStats = {}
for (const data of Object.keys(bottlenecks.coursestatistics[course].students)) { for (const data of Object.keys(bottlenecks.coursestatistics[course].students)) {
@@ -67,22 +85,22 @@ const bottlenecksOf = async (query, studentNumbers, encryptData = false) => {
const params = parseQueryParams(query) const params = parseQueryParams(query)
const allStudentsByYears = query?.selectedStudentsByYear const allStudentsByYears = query?.selectedStudentsByYear
? Object.keys(query.selectedStudentsByYear).reduce( ? Object.keys(query.selectedStudentsByYear).reduce(
(res, year) => [...res, ...query.selectedStudentsByYear[year]], (res, year) => [...res, ...query.selectedStudentsByYear![year]],
[] [] as string[]
) )
: [] : []
// To fix failed and enrolled, no grade filter options some not so clean and nice solutions were added // To fix failed and enrolled, no grade filter options some not so clean and nice solutions were added
// Get the data with actual 1. courses and filtered students. 2. all students by year, if provided. // Get the data with actual 1. courses and filtered students. 2. all students by year, if provided.
const [[allStudents, courses, courseEnrollements], [, allCourses]] = await Promise.all([ const [[allStudents, courses, courseEnrollements], [, allCourses]] = (await Promise.all([
getStudentsAndCourses(params, query.selectedStudents, studentNumbers, query.courses), getStudentsAndCourses(params, query.selectedStudents, studentNumbers, query.courses),
getStudentsAndCourses(params, allStudentsByYears, null, query.courses), getStudentsAndCourses(params, allStudentsByYears, null, query.courses),
]) ])) as [[number, QueryResult, QueryResult], [number, QueryResult]]
// Get the substitution codes for the fetch data by selected students // Get the substitution codes for the fetch data by selected students
const substitutionCodes = Object.entries(courses).reduce( const substitutionCodes = Object.entries(courses).reduce(
(codes, [, course]) => [...codes, ...(course?.substitutions || [])], (codes, [, course]) => [...codes, ...(course?.substitutions || [])],
[] [] as string[]
) )
const codes = Object.keys(keyBy(courses, 'code')).map(code => code) const codes = Object.keys(keyBy(courses, 'code')).map(code => code)
// Filter substitution courses for fetched courses -> by this we avoid the situation in which there // Filter substitution courses for fetched courses -> by this we avoid the situation in which there
@@ -90,12 +108,14 @@ const bottlenecksOf = async (query, studentNumbers, encryptData = false) => {
const substitutionCourses = allCourses.filter( const substitutionCourses = allCourses.filter(
course => substitutionCodes.includes(course.code) && !codes.includes(course.code) course => substitutionCodes.includes(course.code) && !codes.includes(course.code)
) )
const bottlenecks = { const bottlenecks: Bottlenecks = {
disciplines: {}, disciplines: {},
coursetypes: {}, coursetypes: {},
coursestatistics: [],
allStudents: 0,
} }
const stats = {} const stats = {} as Record<string, CourseStatsCounter>
const startYear = parseInt(query.year, 10) const startYear = parseInt(query.year, 10)
let coursesToLoop = courses.concat(substitutionCourses) let coursesToLoop = courses.concat(substitutionCourses)
const courseCodes = coursesToLoop.map(course => course.code) const courseCodes = coursesToLoop.map(course => course.code)
@@ -110,7 +130,6 @@ const bottlenecksOf = async (query, studentNumbers, encryptData = false) => {
const coursesByCode = keyBy(coursesToLoop, 'code') const coursesByCode = keyBy(coursesToLoop, 'code')
for (const course of coursesToLoop) { for (const course of coursesToLoop) {
const { course_type } = course
let mainCourse = course let mainCourse = course
if (course.main_course_code && course.main_course_code !== course.code) { if (course.main_course_code && course.main_course_code !== course.code) {
@@ -123,15 +142,16 @@ const bottlenecksOf = async (query, studentNumbers, encryptData = false) => {
if (!stats[mainCourse.code]) { if (!stats[mainCourse.code]) {
stats[mainCourse.code] = new CourseStatsCounter(mainCourse.code, mainCourse.name, allStudents) stats[mainCourse.code] = new CourseStatsCounter(mainCourse.code, mainCourse.name, allStudents)
} }
const coursestats = stats[mainCourse.code] const coursestats = stats[mainCourse.code]
coursestats.addCourseType(course_type.coursetypecode, course_type.name) coursestats.addCourseType(course.coursetypecode, course.name)
coursestats.addCourseSubstitutions(course.substitutions) coursestats.addCourseSubstitutions(course.substitutions)
bottlenecks.coursetypes[course_type.coursetypecode] = course_type.name bottlenecks.coursetypes[course.coursetypecode] = course.name
if (course.enrollments) { if (course.enrollments) {
course.enrollments.forEach(({ studentnumber, state, enrollment_date_time }) => { course.enrollments.forEach(({ studentnumber, state, enrollment_date_time }) => {
if ((query?.selectedStudents && query?.selectedStudents.includes(studentnumber)) || !query?.selectedStudents) { if ((query?.selectedStudents && query?.selectedStudents.includes(studentnumber)) || !query?.selectedStudents) {
const semester = getPassingSemester(startYear, enrollment_date_time) const semester = getPassingSemester(startYear, enrollment_date_time)
coursestats.markEnrollment(studentnumber, state, semester, enrollment_date_time) coursestats.markEnrollment(studentnumber, state, semester)
} }
}) })
} }
@@ -157,7 +177,3 @@ const bottlenecksOf = async (query, studentNumbers, encryptData = false) => {
return bottlenecks return bottlenecks
} }
module.exports = {
bottlenecksOf,
}
@@ -8,6 +8,7 @@ import {
formatStudentsForApi, formatStudentsForApi,
getOptionsForStudents, getOptionsForStudents,
parseQueryParams, parseQueryParams,
Query as FormattedQueryParams,
} from './shared' } from './shared'
import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements' import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements'
@@ -34,7 +35,7 @@ export const optimizedStatisticsOf = async (query: Query, studentNumberList?: st
} }
const { studyRights, startDate, months, endDate, exchangeStudents, nondegreeStudents, transferredStudents, tag } = const { studyRights, startDate, months, endDate, exchangeStudents, nondegreeStudents, transferredStudents, tag } =
parseQueryParams(formattedQueryParams) parseQueryParams(formattedQueryParams as FormattedQueryParams)
const studentNumbers = const studentNumbers =
studentNumberList || studentNumberList ||
@@ -5,7 +5,7 @@ import { Op, QueryTypes } from 'sequelize'
import { dbConnections } from '../../database/connection' import { dbConnections } from '../../database/connection'
import { Course, Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../../models' import { Course, Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../../models'
import { TagStudent } from '../../models/kone' import { Tag, TagStudent } from '../../models/kone'
import { Criteria, DegreeProgrammeType, Name, ParsedCourse } from '../../types' import { Criteria, DegreeProgrammeType, Name, ParsedCourse } from '../../types'
import { SemesterStart } from '../../util/semester' import { SemesterStart } from '../../util/semester'
import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers' import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers'
@@ -286,7 +286,31 @@ export const dateMonthsFromNow = (date: string, months: number) => {
return new Date(moment(date).add(months, 'months').format('YYYY-MM-DD')) return new Date(moment(date).add(months, 'months').format('YYYY-MM-DD'))
} }
export const parseQueryParams = query => { export type Query = {
year: string
months: number
semesters: string[]
studyRights: string | string[]
selectedStudents?: string[]
selectedStudentsByYear?: Record<string, string[]>
studentStatuses?: string[]
years?: string[]
tag?: string
courses?: string[]
}
export type Params = {
startDate: string
endDate: string
exchangeStudents: boolean
nondegreeStudents: boolean
transferredStudents: boolean
studyRights: string[]
months: number
tag?: Tag
}
export const parseQueryParams = (query: Query) => {
const { semesters, studentStatuses, studyRights, months, year, tag } = query const { semesters, studentStatuses, studyRights, months, year, tag } = query
const hasFall = semesters.includes('FALL') const hasFall = semesters.includes('FALL')
const hasSpring = semesters.includes('SPRING') const hasSpring = semesters.includes('SPRING')
@@ -313,7 +337,7 @@ export const parseQueryParams = query => {
startDate, startDate,
endDate, endDate,
tag, tag,
} } as Params
} }
export const getOptionsForStudents = async ( export const getOptionsForStudents = async (
@@ -462,10 +486,21 @@ const getCourseCodes = async (courses: string[]) => {
return courseCodes return courseCodes
} }
export type QueryResult = Array<{
code: string
name: Name
coursetypecode: string
substitutions: string[]
main_course_code: string
course_type: Name
credits: Array<Pick<Credit, 'grade' | 'student_studentnumber' | 'attainment_date' | 'credittypecode' | 'course_code'>>
enrollments: Array<Pick<Enrollment, 'studentnumber' | 'state' | 'enrollment_date_time'>> | null
}>
// This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students // This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students
export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => { export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = await getCourseCodes(courses) const courseCodes = await getCourseCodes(courses)
const res = await sequelize.query( const result: QueryResult = await sequelize.query(
` `
SELECT DISTINCT ON (course.id) SELECT DISTINCT ON (course.id)
course.code, course.code,
@@ -502,12 +537,12 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
type: QueryTypes.SELECT, type: QueryTypes.SELECT,
} }
) )
return res return result
} }
export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => { export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = await getCourseCodes(courses) const courseCodes = await getCourseCodes(courses)
const res = await sequelize.query( const result: QueryResult = await sequelize.query(
` `
SELECT DISTINCT ON (course.id) SELECT DISTINCT ON (course.id)
course.code, course.code,
@@ -559,15 +594,18 @@ export const findCourses = async (studentNumbers: string[], beforeDate: Date, co
type: QueryTypes.SELECT, type: QueryTypes.SELECT,
} }
) )
return result
return res
} }
export const parseCreditInfo = (credit: Credit) => ({ export const parseCreditInfo = (
studentnumber: credit.student_studentnumber, credit: Pick<Credit, 'grade' | 'student_studentnumber' | 'attainment_date' | 'credittypecode' | 'course_code'>
grade: credit.grade, ) => {
passingGrade: Credit.passed(credit), return {
failingGrade: Credit.failed(credit), studentnumber: credit.student_studentnumber,
improvedGrade: Credit.improved(credit), grade: credit.grade,
date: credit.attainment_date, passingGrade: Credit.passed(credit),
}) failingGrade: Credit.failed(credit),
improvedGrade: Credit.improved(credit),
date: credit.attainment_date,
}
}