mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Merge pull request #4948 from UniversityOfHelsinkiCS/progress-chart-graduated-category
Progress chart graduated category
This commit is contained in:
@@ -398,30 +398,32 @@ describe('Degree programme overview', () => {
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
|
||||
['2021 - 2022', 38, 8, 4, 9, 8, 4, 0, 0, 5],
|
||||
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
|
||||
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
it('Progress section', () => {
|
||||
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Graduated: 134')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
|
||||
@@ -71,6 +71,12 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
|
||||
master: {} as Record<string, number[]>,
|
||||
doctor: {} as Record<string, number[]>,
|
||||
},
|
||||
graduatedCount: {
|
||||
bachelor: {} as Record<string, number>,
|
||||
bachelorMaster: {} as Record<string, number>,
|
||||
master: {} as Record<string, number>,
|
||||
doctor: {} as Record<string, number>,
|
||||
},
|
||||
}
|
||||
|
||||
const unifyProgressStats = (progStats: number[][][]) => {
|
||||
@@ -96,6 +102,14 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
|
||||
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
|
||||
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
|
||||
}
|
||||
|
||||
for (const fieldName of degreeNames) {
|
||||
if (!facultyData.graduatedCount[fieldName] || Object.keys(facultyData.graduatedCount[fieldName]).length === 0)
|
||||
continue
|
||||
if (!universityData.graduatedCount[fieldName][year]) universityData.graduatedCount[fieldName][year] = 0
|
||||
universityData.graduatedCount[fieldName][year] += facultyData.graduatedCount[fieldName][year]
|
||||
}
|
||||
|
||||
const progStats = ['bachelorsProgStats', 'bcMsProgStats', 'mastersProgStats', 'doctoralProgStats'] as const
|
||||
for (const fieldName of progStats) {
|
||||
if (Object.keys(facultyData[fieldName] || {}).length === 0) continue
|
||||
|
||||
@@ -107,6 +107,7 @@ export const combineFacultyStudentProgress = async (
|
||||
const statsOfProgrammes: Array<Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>> = []
|
||||
const allDegreeProgrammes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
const creditCounts: Record<string, Record<string, number[]>> = {}
|
||||
const graduatedCount: Record<string, Record<string, number>> = {}
|
||||
const progressStats: Record<string, Record<string, number[][]>> = {}
|
||||
const yearlyTitles: Record<string, string[][]> = {}
|
||||
|
||||
@@ -137,12 +138,14 @@ export const combineFacultyStudentProgress = async (
|
||||
}
|
||||
}
|
||||
|
||||
const updateCreditCounts = (
|
||||
const updateCreditAndGraduatedCount = (
|
||||
level: 'bachelor' | 'bcMs' | 'master' | 'doctor',
|
||||
creditCountsOfProgramme: Record<string, number[]>
|
||||
creditCountsOfProgramme: Record<string, number[]>,
|
||||
graduatedCountOfProgramme: Record<string, number>
|
||||
) => {
|
||||
if (!(level in creditCounts)) {
|
||||
creditCounts[level] = cloneDeep(creditCountsOfProgramme)
|
||||
graduatedCount[level] = cloneDeep(graduatedCountOfProgramme)
|
||||
} else {
|
||||
for (const year of Object.keys(creditCountsOfProgramme)) {
|
||||
if (!(year in creditCounts[level])) {
|
||||
@@ -151,6 +154,10 @@ export const combineFacultyStudentProgress = async (
|
||||
creditCounts[level][year].push(...creditCountsOfProgramme[year])
|
||||
}
|
||||
}
|
||||
|
||||
for (const year of Object.keys(graduatedCountOfProgramme)) {
|
||||
graduatedCount[level][year] = cloneDeep(graduatedCountOfProgramme[year])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +170,11 @@ export const combineFacultyStudentProgress = async (
|
||||
const { degreeProgrammeType, progId } = programmeInfo
|
||||
const level = programmeTypes[degreeProgrammeType]
|
||||
|
||||
updateCreditCounts(level, stats.creditCounts)
|
||||
updateCreditAndGraduatedCount(level, stats.creditCounts, stats.graduatedCount)
|
||||
calculateProgressStats(level, stats.creditCounts, yearlyTitles, progressStats, progId)
|
||||
|
||||
if (level === 'master' && Object.keys(stats.creditCountsCombo).length > 0) {
|
||||
updateCreditCounts('bcMs', stats.creditCountsCombo)
|
||||
updateCreditAndGraduatedCount('bcMs', stats.creditCountsCombo, stats.graduatedCount)
|
||||
calculateProgressStats('bcMs', stats.creditCountsCombo, yearlyTitles, progressStats, progId)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +187,7 @@ export const combineFacultyStudentProgress = async (
|
||||
bcMsProgStats: progressStats.bcMs,
|
||||
doctoralProgStats: progressStats.doctor,
|
||||
creditCounts: {} as Record<string, Record<string, number[]>>,
|
||||
graduatedCount: {} as Record<string, Record<string, number>>,
|
||||
yearlyBachelorTitles: yearlyTitles.bachelor,
|
||||
yearlyBcMsTitles: yearlyTitles.bcMs,
|
||||
yearlyMasterTitles: yearlyTitles.master,
|
||||
@@ -202,6 +210,14 @@ export const combineFacultyStudentProgress = async (
|
||||
}
|
||||
result.creditCounts[levelName][year] = creditCounts[level][year]
|
||||
}
|
||||
|
||||
for (const year of Object.keys(graduatedCount[level]).toSorted(sortByYear)) {
|
||||
const levelName = level === 'bcMs' ? 'bachelorMaster' : level
|
||||
if (!(levelName in result.graduatedCount)) {
|
||||
result.graduatedCount[levelName] = {}
|
||||
}
|
||||
result.graduatedCount[levelName][year] = graduatedCount[level][year]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -338,9 +338,6 @@ const getMainStatsByTrackAndYear = async (
|
||||
if (!studyRightElement) {
|
||||
continue
|
||||
}
|
||||
if (!includeGraduated && studyRightElement.graduated) {
|
||||
continue
|
||||
}
|
||||
|
||||
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
|
||||
studyRight,
|
||||
@@ -378,15 +375,18 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
|
||||
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
|
||||
if (countAsBachelorMaster) {
|
||||
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
|
||||
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
|
||||
creditCountsCombo[defineYear(startedInBachelor, true)].push(
|
||||
getCreditCount(studyRight.student.credits, startedInBachelor)
|
||||
)
|
||||
// TODO: FIXME: This could be written better
|
||||
if (!studyRightElement.graduated) {
|
||||
if (countAsBachelorMaster) {
|
||||
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
|
||||
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
|
||||
creditCountsCombo[defineYear(startedInBachelor, true)].push(
|
||||
getCreditCount(studyRight.student.credits, startedInBachelor)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
|
||||
}
|
||||
} else {
|
||||
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,15 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
const graduatedCount: Record<string, number> = {}
|
||||
years.forEach(year => {
|
||||
if (!yearlyStats[year]) {
|
||||
graduatedCount[year] = 0
|
||||
} else {
|
||||
graduatedCount[year] = yearlyStats[year][studyProgramme].graduated
|
||||
}
|
||||
})
|
||||
|
||||
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
|
||||
years,
|
||||
yearlyStats,
|
||||
@@ -438,6 +447,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
otherCountriesCount,
|
||||
creditCounts,
|
||||
creditCountsCombo,
|
||||
graduatedCount,
|
||||
graduationTimes: finalGraduationTimes,
|
||||
graduationTimesSecondProg: finalCombinedGraduationTimes,
|
||||
}
|
||||
@@ -484,6 +494,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
|
||||
creditCounts: stats.creditCounts,
|
||||
creditCountsCombo: stats.creditCountsCombo,
|
||||
doCombo,
|
||||
graduatedCount: stats.graduatedCount,
|
||||
graduationTimes: stats.graduationTimes,
|
||||
graduationTimesSecondProg: stats.graduationTimesSecondProg,
|
||||
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
|
||||
|
||||
@@ -316,7 +316,12 @@ export const getCreditCategories = (
|
||||
const limitBreaks = cumulative
|
||||
? creditCategoryArray.map(num => Math.round(minCredits + (num * (maxCredits - minCredits)) / creditCategoryAmount))
|
||||
: [15, 30, 45, 60].map(limit => limit * (timeDivision === TimeDivision.SEMESTER ? 0.5 : 1))
|
||||
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
|
||||
const limits: (number[] | 'Graduated')[] = range(0, limitBreaks.length + 1).map(i => [
|
||||
limitBreaks[i - 1],
|
||||
limitBreaks[i],
|
||||
])
|
||||
limits.push('Graduated')
|
||||
return limits
|
||||
}
|
||||
|
||||
export const validateInputLength = (input: string, minLength: number) => input?.trim().length >= minLength
|
||||
|
||||
@@ -19,11 +19,18 @@ export const FacultyProgress = ({
|
||||
progressStats: GetAllProgressStatsResponse | undefined
|
||||
}) => {
|
||||
const creditCounts = progressStats?.creditCounts
|
||||
const graduatedCount = progressStats?.graduatedCount
|
||||
|
||||
const bachelorStats = calculateStats(creditCounts?.bachelor, 180)
|
||||
const bachelorMasterStats = calculateStats(creditCounts?.bachelorMaster, faculty === 'H90' ? 360 : 300, 180, 7)
|
||||
const masterStats = calculateStats(creditCounts?.master, 120)
|
||||
const doctorStats = calculateStats(creditCounts?.doctor, 40, 0, 5)
|
||||
const bachelorStats = calculateStats(creditCounts?.bachelor, graduatedCount?.bachelor, 180)
|
||||
const bachelorMasterStats = calculateStats(
|
||||
creditCounts?.bachelorMaster,
|
||||
graduatedCount?.bachelorMaster,
|
||||
faculty === 'H90' ? 360 : 300,
|
||||
180,
|
||||
7
|
||||
)
|
||||
const masterStats = calculateStats(creditCounts?.master, graduatedCount?.master, 120)
|
||||
const doctorStats = calculateStats(creditCounts?.doctor, graduatedCount?.doctor, 40, 0, 5)
|
||||
|
||||
const sortKeys = (stats: Record<string, number[][]>) => {
|
||||
return sortProgrammeKeys(
|
||||
|
||||
+5
-9
@@ -75,8 +75,6 @@ const hasGraduatedBeforeDate = (student, programme, date) => {
|
||||
return studyRightElement.graduated && date.isAfter(studyRightElement.endDate, 'day')
|
||||
}
|
||||
|
||||
const GRADUATED = Symbol('GRADUATED')
|
||||
|
||||
const getChartData = (
|
||||
students: any[],
|
||||
timeSlots: any[],
|
||||
@@ -87,16 +85,14 @@ const getChartData = (
|
||||
) => {
|
||||
const programmeCredits = getTargetCreditsForProgramme(programme) + (combinedProgramme ? 180 : 0)
|
||||
|
||||
const limits: Array<number[] | typeof GRADUATED> = getCreditCategories(
|
||||
const limits: Array<number[] | 'Graduated'> = getCreditCategories(
|
||||
cumulative,
|
||||
timeDivision,
|
||||
programmeCredits,
|
||||
timeSlots.length,
|
||||
6
|
||||
)
|
||||
const colors = generateGradientColors(limits.length)
|
||||
|
||||
limits.push(GRADUATED)
|
||||
const colors = generateGradientColors(limits.length - 1)
|
||||
colors.push('#ddd') // Color for graduated (grey)
|
||||
|
||||
const data: { y: number; custom: { students: number[] } }[][] = limits.map(() =>
|
||||
@@ -116,9 +112,9 @@ const getChartData = (
|
||||
const credits = studentCredits[studentIndex][timeSlotIndex]
|
||||
|
||||
const rangeIndex = hasGraduated
|
||||
? limits.indexOf(GRADUATED)
|
||||
? limits.indexOf('Graduated')
|
||||
: limits.findIndex(limit => {
|
||||
if (limit === GRADUATED) {
|
||||
if (limit === 'Graduated') {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -144,7 +140,7 @@ const getChartData = (
|
||||
const limit = limits[limitN]
|
||||
let name
|
||||
|
||||
if (limit === GRADUATED) {
|
||||
if (limit === 'Graduated') {
|
||||
name = 'Graduated'
|
||||
} else {
|
||||
const [min, max] = limit
|
||||
|
||||
@@ -29,7 +29,8 @@ export const ProgressBarChart = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(Object.keys(data.stats).length)
|
||||
const colors = generateGradientColors(Object.keys(data.stats).length - 1)
|
||||
colors.push('#ddd') // For graduated students
|
||||
const dataWithColors: SeriesColumnOptions[] = Object.values(data.stats).map((series, index) => ({
|
||||
...series,
|
||||
color: colors[index],
|
||||
|
||||
+18
-3
@@ -39,15 +39,30 @@ export const ProgressSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
const bachelorStats = calculateStats(progressStats?.data?.creditCounts?.bachelor, 180)
|
||||
const bachelorStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.bachelor,
|
||||
progressStats?.data?.graduatedCount?.bachelor,
|
||||
180
|
||||
)
|
||||
const bachelorMasterStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.bachelorMaster,
|
||||
progressStats?.data?.graduatedCount?.bachelorMaster,
|
||||
faculty.code === 'H90' ? 360 : 300,
|
||||
180,
|
||||
7
|
||||
)
|
||||
const masterStats = calculateStats(progressStats?.data?.creditCounts?.master, 120)
|
||||
const doctorStats = calculateStats(progressStats?.data?.creditCounts?.doctor, 40, 0, 5)
|
||||
const masterStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.master,
|
||||
progressStats?.data?.graduatedCount?.master,
|
||||
120
|
||||
)
|
||||
const doctorStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.doctor,
|
||||
progressStats?.data?.graduatedCount?.doctor,
|
||||
40,
|
||||
0,
|
||||
5
|
||||
)
|
||||
|
||||
const hasNonZeroStats = stats => {
|
||||
const allValuesZero = values => values.every(value => parseFloat(value) === 0)
|
||||
|
||||
+10
-2
@@ -85,7 +85,11 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
|
||||
const programmeCode = combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme
|
||||
|
||||
const progressStats = calculateStats(studyTrackStats?.creditCounts, getTargetCreditsForProgramme(programmeCode))
|
||||
const progressStats = calculateStats(
|
||||
studyTrackStats?.creditCounts,
|
||||
studyTrackStats?.graduatedCount,
|
||||
getTargetCreditsForProgramme(programmeCode)
|
||||
)
|
||||
if (progressStats?.chartStats) {
|
||||
progressStats.chartStats.forEach(creditCategory => {
|
||||
const [total, ...years] = creditCategory.data
|
||||
@@ -95,7 +99,11 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
|
||||
const progressComboStats =
|
||||
Object.keys(studyTrackStats?.creditCountsCombo ?? {}).length > 0
|
||||
? calculateStats(studyTrackStats?.creditCountsCombo, getTargetCreditsForProgramme(programmeCode) + 180)
|
||||
? calculateStats(
|
||||
studyTrackStats?.creditCountsCombo,
|
||||
studyTrackStats?.graduatedCount,
|
||||
getTargetCreditsForProgramme(programmeCode) + 180
|
||||
)
|
||||
: null
|
||||
|
||||
if (progressComboStats?.chartStats) {
|
||||
|
||||
@@ -76,6 +76,7 @@ export type GetFacultyProgressStatsResponse = {
|
||||
bachelorsProgStats: Record<string, number[][]>
|
||||
bcMsProgStats: Record<string, number[][]>
|
||||
creditCounts: Record<string, Record<string, number[]>>
|
||||
graduatedCount: Record<string, Record<string, number>>
|
||||
doctoralProgStats: Record<string, number[][]>
|
||||
id: string
|
||||
mastersProgStats: Record<string, number[][]>
|
||||
@@ -101,6 +102,7 @@ export type GetAllProgressStatsResponse = {
|
||||
bachelorsProgStats: Record<string, number[][]>
|
||||
bcMsProgStats: Record<string, number[][]>
|
||||
creditCounts: Record<string, Record<string, number[]>>
|
||||
graduatedCount: Record<string, Record<string, number>>
|
||||
doctoralProgStats: Record<string, number[][]>
|
||||
mastersProgStats: Record<string, number[][]>
|
||||
programmeNames: Record<string, NameWithCode>
|
||||
|
||||
@@ -52,6 +52,7 @@ const isBetween = (number: number, lowerLimit: number, upperLimit: number) => {
|
||||
|
||||
export const calculateStats = (
|
||||
creditCounts: Record<string, number[]> | undefined,
|
||||
graduatedCount: Record<string, number> | undefined,
|
||||
maximumAmountOfCredits: number,
|
||||
minimumAmountOfCredits = 0,
|
||||
numberOfCreditCategories = 7
|
||||
@@ -75,18 +76,25 @@ export const calculateStats = (
|
||||
)
|
||||
const tableTitles = ['', 'All']
|
||||
for (const limit of limits) {
|
||||
if (limit[0] === undefined) tableTitles.push(`< ${limit[1]} credits`)
|
||||
if (limit == 'Graduated') tableTitles.push('Graduated')
|
||||
else if (limit[0] === undefined) tableTitles.push(`< ${limit[1]} credits`)
|
||||
else if (limit[1] === undefined) tableTitles.push(`≥ ${limit[0]} credits`)
|
||||
else tableTitles.push(`${limit[0]}–${limit[1]} credits`)
|
||||
}
|
||||
|
||||
Object.keys(creditCounts).forEach(year => {
|
||||
const yearGraduatedCount = graduatedCount ? graduatedCount[year] : 0
|
||||
const yearCreditCount = creditCounts[year]
|
||||
const yearCounts = [year, yearCreditCount.length]
|
||||
tableStats.push(yearCounts)
|
||||
const yearCounts = [year, yearCreditCount.length + yearGraduatedCount]
|
||||
|
||||
for (const limit of limits) {
|
||||
yearCounts.push(yearCreditCount.filter(credits => isBetween(credits, limit[0], limit[1])).length)
|
||||
if (limit === 'Graduated') {
|
||||
yearCounts.push(yearGraduatedCount)
|
||||
} else {
|
||||
yearCounts.push(yearCreditCount.filter(credits => isBetween(credits, limit[0], limit[1])).length)
|
||||
}
|
||||
}
|
||||
tableStats.push(yearCounts)
|
||||
})
|
||||
|
||||
const totalCounts: Array<number | string> = ['Total']
|
||||
|
||||
@@ -89,6 +89,7 @@ export type StudyTrackStats = {
|
||||
creditCounts: Record<string, number[]>
|
||||
creditCountsCombo: Record<string, number[]>
|
||||
doCombo: boolean
|
||||
graduatedCount: Record<string, number>
|
||||
graduationTimes: GraduationTimes
|
||||
graduationTimesSecondProg: GraduationTimes
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user