mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d174d0275 | ||
|
|
2e882ff8a0 | ||
|
|
f8a6f89199 | ||
|
|
b272f9e94a | ||
|
|
4cc73d5fd8 | ||
|
|
c19810fca8 | ||
|
|
298c9e9b4b | ||
|
|
58ed0ac417 | ||
|
|
cb40fd970c | ||
|
|
cdc7331667 | ||
|
|
2ffc222bcf | ||
|
|
2fcb5fd91d | ||
|
|
f4be37a3b8 | ||
|
|
67d7b05cc6 | ||
|
|
88ad8db7bb | ||
|
|
dda612f284 | ||
|
|
6a9cf26f4b | ||
|
|
2d7b679b04 | ||
|
|
5aa4c5d146 | ||
|
|
ff9aaa1119 | ||
|
|
f443f3b7db | ||
|
|
a6a47e5ec8 | ||
|
|
b414fd3501 | ||
|
|
88ce7bd2a5 | ||
|
|
eb35ea503d | ||
|
|
20de79a26f | ||
|
|
dab21ab390 | ||
|
|
6069dbc328 | ||
|
|
7cb8041ca0 | ||
|
|
729192cddc | ||
|
|
4b3b10d033 | ||
|
|
9c6812d7cf | ||
|
|
58ce626d97 | ||
|
|
0834b33778 | ||
|
|
cbce1d7dbd | ||
|
|
b8e214f8cd | ||
|
|
69d920015c | ||
|
|
cf64249d4f | ||
|
|
f372e4f362 | ||
|
|
dd619d375d | ||
|
|
be0f14326a |
@@ -8,7 +8,7 @@ To run Oodikone locally, you will need the following:
|
||||
|
||||
1. Applications:
|
||||
- [Docker](https://docs.docker.com/install/) (version 20.10+),
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)(version 0.29+) and
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)(version 1.29+) and
|
||||
- [npm](https://docs.npmjs.com/cli/v7) (version 7+)
|
||||
2. Access to Toska Docker Hub,
|
||||
3. For real data setup, access to Toskas production servers.
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('Course Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
it.only('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
cy.init('/coursestatistics', 'onlycoursestatistics')
|
||||
cy.get('[data-cy=navbar-courseStatistics]').click()
|
||||
cy.get('[data-cy=course-code-input]').type('TKT20003')
|
||||
|
||||
@@ -81,8 +81,8 @@ describe('Population Statistics tests', () => {
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(15000) // a bit hacky way, wait until ui is ready
|
||||
cy.get('[data-cy=expand-TKT1]').click()
|
||||
cy.get('[data-cy=coursestats-link-TKT10002]').click()
|
||||
cy.get('[data-cy=toggle-group-module-TKT1]').click()
|
||||
cy.contains('td', 'TKT10002').siblings().find('i.level.up').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Student Statistics tests', () => {
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
cy.url().should('include', '/students')
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Started: 01.08.2017')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma (01.08.2017 - 01.08.2025')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
@@ -54,7 +54,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.get('.remove').click()
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
@@ -62,7 +62,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can jump to course', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Introduction to Machine Learning (DATA11002)')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
@@ -82,7 +82,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.get('i.level.up.alternate.icon').eq(0).click()
|
||||
cy.contains('Population statistics')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const deleteTag = name => {
|
||||
cy.contains(name).siblings().contains('Delete').click()
|
||||
cy.contains('td', name).siblings().contains('Delete').click()
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.contains('Confirm').click()
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.contains('td', name).should('not.exist')
|
||||
}
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
@@ -30,7 +30,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information -tab loads', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
|
||||
@@ -39,7 +39,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started, Graduated, Cancelled, Transferred Away, Transferred to]
|
||||
@@ -55,7 +55,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Total, Major credits, Non-major credits, Transferred credits]
|
||||
@@ -71,7 +71,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
@@ -87,7 +87,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it.skip('Special studyrights can be excluded and basic data changes accordingly', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
@@ -129,7 +129,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
@@ -160,7 +160,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
|
||||
.should('contain', 'Started')
|
||||
@@ -207,7 +207,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Studytracks and student populations -tab loads', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
cy.get('[data-cy=Section-StudytrackOverview]')
|
||||
cy.get('[data-cy=Section-StudytrackProgress]')
|
||||
@@ -215,7 +215,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Students of the studyprogramme are shown correctly', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Currently enrolled, Absent, Cancelled, Graduated, Men, Women, Finnish]
|
||||
@@ -309,7 +309,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
const tableContents = [
|
||||
['2020 - 2021', 12, 5, 2, 2, 0, 2, 1],
|
||||
@@ -322,7 +322,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Studytrack overview graphs render', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
|
||||
cy.get('[data-cy=Graph-StudytrackProgress]')
|
||||
@@ -348,7 +348,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -360,7 +360,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -376,7 +376,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
const student = '010113437'
|
||||
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -384,7 +384,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains(name)
|
||||
|
||||
cy.contains('Add tags to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
@@ -393,7 +393,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
cy.contains('Student statistics').click()
|
||||
cy.get('.prompt').type(student)
|
||||
cy.contains(student).click()
|
||||
cy.contains('a', student).click()
|
||||
cy.contains(name)
|
||||
|
||||
cy.go('back')
|
||||
@@ -403,7 +403,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
cy.contains('Student statistics').click()
|
||||
cy.get('.prompt').type(student)
|
||||
cy.contains(student).click()
|
||||
cy.contains('a', student).click()
|
||||
cy.contains(name).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Teachers page tests', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.cs('course-providers').children('.icon').click()
|
||||
cy.get('.form > .fluid').click()
|
||||
cy.contains('Name')
|
||||
cy.contains('Teacher')
|
||||
cy.contains(teacher1)
|
||||
cy.contains(teacher2)
|
||||
})
|
||||
@@ -32,21 +32,13 @@ describe('Teachers page tests', () => {
|
||||
cy.url().should('include', '/teachers')
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher1.split()[0])
|
||||
cy.contains(teacher1).click()
|
||||
cy.contains('td', teacher1).click()
|
||||
cy.contains('Aineopintojen harjoitustyö: Tietokantasovellus')
|
||||
})
|
||||
|
||||
it('Check leaderboad works', () => {
|
||||
cy.get('.borderless > :nth-child(2)').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('2019-2020').click({ force: true })
|
||||
cy.contains('Recalculate this year').click({ force: true })
|
||||
cy.wait(5000)
|
||||
cy.reload()
|
||||
cy.contains('2019-2020').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('Passed')
|
||||
cy.contains('Ahokas Jarmo Ilkka')
|
||||
cy.contains('2020-2021').click({ force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,9 +39,7 @@ describe('Users tests', () => {
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
cy.contains('mocking').should('not.exist')
|
||||
cy.contains('tr', 'basic').within(() => {
|
||||
cy.contains('.button', 'Edit').click()
|
||||
})
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.contains('mocking as basic')
|
||||
cy.wait(1000)
|
||||
|
||||
@@ -32,8 +32,10 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getBasicStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setBasicStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
@@ -62,8 +64,10 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getCreditStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setCreditStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
@@ -92,8 +96,10 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setGraduationStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
@@ -110,18 +116,26 @@ router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const graduated = req.query?.graduated
|
||||
const specialGroups = req.query?.special_groups
|
||||
const yearsCombined = req.query?.years_combined
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getStudytrackStats(code, graduated, specialGroups)
|
||||
data = await getStudytrackStats(code, graduated, specialGroups, yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} studytrack stats: ${e}`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await getStudytrackStatsForStudyprogramme({ studyprogramme: code, specialGroups, graduated })
|
||||
data = await setStudytrackStats(result, graduated, specialGroups)
|
||||
const result = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
graduated: graduated === 'GRADUATED_INCLUDED',
|
||||
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
|
||||
yearsCombined: yearsCombined === 'YEARS_COMBINED',
|
||||
},
|
||||
})
|
||||
data = await setStudytrackStats(result, graduated, specialGroups, yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} studytrack stats: ${e}`)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STAT
|
||||
const createRedisKeyForCreditStats = (id, yearType, specialGroups) => `CREDIT_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForGraduationStats = (id, yearType, specialGroups) =>
|
||||
`GRADUATION_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups) =>
|
||||
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
|
||||
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups, yearsCombined) =>
|
||||
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}_${yearsCombined}`
|
||||
|
||||
const getBasicStats = async (id, yearType, specialGroups) => {
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
|
||||
@@ -68,16 +68,16 @@ const setGraduationStats = async (data, yearType, specialGroups) => {
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getStudytrackStats = async (id, graduated, specialGroups) => {
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
|
||||
const getStudytrackStats = async (id, graduated, specialGroups, yearsCombined) => {
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setStudytrackStats = async (data, graduated, specialGroups) => {
|
||||
const setStudytrackStats = async (data, graduated, specialGroups, yearsCombined) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
|
||||
@@ -3,7 +3,7 @@ const { isProduction, pateToken } = require('../conf-backend')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const pateClient = axios.create({
|
||||
baseURL: 'https://pate.toska.cs.helsinki.fi',
|
||||
baseURL: 'https://importer.cs.helsinki.fi/api/pate/',
|
||||
params: {
|
||||
token: pateToken,
|
||||
},
|
||||
|
||||
@@ -105,9 +105,8 @@ const getTransferredToStats = async ({ studyprogramme, since, years, isAcademicY
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { includeAllSpecials, isAcademicYear } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
@@ -119,7 +118,7 @@ const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGro
|
||||
const transferredTo = await getTransferredToStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const titles = tableTitles['basics'][specialGroups]
|
||||
const titles = tableTitles['basics'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
|
||||
const tableStats = reversedYears.map(year =>
|
||||
includeAllSpecials
|
||||
? [
|
||||
|
||||
@@ -71,9 +71,8 @@ const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcade
|
||||
|
||||
// Fetches all credits for the studytrack and combines them into the statistics for the table
|
||||
// and graph in the studyprogramme overview
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { isAcademicYear, includeAllSpecials } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
@@ -82,7 +81,7 @@ const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGr
|
||||
const transferred = await getTransferredCreditStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const titles = tableTitles['credits'][specialGroups]
|
||||
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
|
||||
|
||||
const tableStats = reversedYears.map(year =>
|
||||
includeAllSpecials
|
||||
|
||||
@@ -78,9 +78,9 @@ const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, in
|
||||
}
|
||||
|
||||
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
let graduationAmounts = getYearsObject(years)
|
||||
let graduationTimes = getYearsObject(years, true)
|
||||
let totalAmounts = isAcademicYear ? getAcademicYearsObject(years) : getYearsObject(years)
|
||||
let graduationAmounts = getYearsObject({ years })
|
||||
let graduationTimes = getYearsObject({ years, emptyArrays: true })
|
||||
let totalAmounts = isAcademicYear ? getAcademicYearsObject({ years }) : getYearsObject({ years })
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
@@ -106,8 +106,8 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
|
||||
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
|
||||
const comparisonValue = studyprogramme.includes('KH') ? 72 : 48
|
||||
|
||||
const medians = getYearsObject(years, true)
|
||||
const means = getYearsObject(years, true)
|
||||
const medians = getYearsObject({ years, emptyArrays: true })
|
||||
const means = getYearsObject({ years, emptyArrays: true })
|
||||
|
||||
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
|
||||
years.forEach(year => {
|
||||
@@ -154,7 +154,7 @@ const getProgrammesBeforeStarting = async ({ studyprogramme, since, years, isAca
|
||||
let studyrightsBeforeMasters = await previousStudyrights(since, programmes, allStudents)
|
||||
const studyprogrammeCodes = uniqBy(studyrightsBeforeMasters, 'code').map(s => ({ code: s.code, name: s.name }))
|
||||
const stats = {}
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject(years) }))
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
|
||||
|
||||
// Map transfers to the Master's programme
|
||||
const transfers = await getTransferStudyrightMap(studyprogramme, since)
|
||||
@@ -192,7 +192,7 @@ const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAc
|
||||
let studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
|
||||
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
|
||||
const stats = {}
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject(years) }))
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
|
||||
|
||||
const transfers = await getTransferStudyrightMap(studyprogramme, since)
|
||||
|
||||
@@ -218,9 +218,8 @@ const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAc
|
||||
return graphAndTableStats
|
||||
}
|
||||
|
||||
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
|
||||
const getGraduationStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { isAcademicYear, includeAllSpecials } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
|
||||
@@ -87,8 +87,9 @@ const formatTransfer = transfer => {
|
||||
}
|
||||
}
|
||||
|
||||
const getYearsArray = (since, isAcademicYear) => {
|
||||
const getYearsArray = (since, isAcademicYear, yearsCombined) => {
|
||||
const years = []
|
||||
if (yearsCombined) return [`${since} - ${new Date().getFullYear()}`]
|
||||
for (let i = since; i <= new Date().getFullYear(); i++) {
|
||||
const year = isAcademicYear ? `${i} - ${i + 1}` : i
|
||||
years.push(year)
|
||||
@@ -96,7 +97,7 @@ const getYearsArray = (since, isAcademicYear) => {
|
||||
return years
|
||||
}
|
||||
|
||||
const getYearsObject = (years, emptyArrays = false) => {
|
||||
const getYearsObject = ({ years, emptyArrays = false }) => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
|
||||
@@ -104,7 +105,7 @@ const getYearsObject = (years, emptyArrays = false) => {
|
||||
return yearsObject
|
||||
}
|
||||
|
||||
const getAcademicYearsObject = (years, emptyArrays = false) => {
|
||||
const getAcademicYearsObject = ({ years, emptyArrays = false }) => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [`${year} - ${year + 1}`]: emptyArrays ? [] : 0 }
|
||||
@@ -115,7 +116,7 @@ const getAcademicYearsObject = (years, emptyArrays = false) => {
|
||||
const getStatsBasis = years => {
|
||||
return {
|
||||
graphStats: new Array(years.length).fill(0),
|
||||
tableStats: getYearsObject(years),
|
||||
tableStats: getYearsObject({ years }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,72 +8,132 @@ const {
|
||||
setGraduationStats,
|
||||
setStudytrackStats,
|
||||
} = require('../services/analyticsService')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const updateBasicView = async code => {
|
||||
const yearTypes = ['ACADEMIC_YEAR', 'CALENDAR_YEAR']
|
||||
const special = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED']
|
||||
const specialCalendar = {
|
||||
yearType: 'CALENDAR_YEAR',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
}
|
||||
const specialAcademic = {
|
||||
yearType: 'ACADEMIC_YEAR',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
}
|
||||
const specialExcludedCalendar = {
|
||||
yearType: 'CALENDAR_YEAR',
|
||||
specialGroups: 'SPECIAL_EXCLUDED',
|
||||
}
|
||||
|
||||
yearTypes.forEach(async yearType => {
|
||||
special.forEach(async specialGroups => {
|
||||
const specialExcludedAcademic = {
|
||||
yearType: 'ACADEMIC_YEAR',
|
||||
specialGroups: 'SPECIAL_EXCLUDED',
|
||||
}
|
||||
|
||||
const options = [specialCalendar, specialAcademic, specialExcludedCalendar, specialExcludedAcademic]
|
||||
|
||||
for (const option of options) {
|
||||
try {
|
||||
const basicStats = await getBasicStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setBasicStats(basicStats, yearType, specialGroups)
|
||||
await setBasicStats(basicStats, option.yearType, option.specialGroups)
|
||||
|
||||
const creditStats = await getCreditStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setCreditStats(creditStats, yearType, specialGroups)
|
||||
await setCreditStats(creditStats, option.yearType, option.specialGroups)
|
||||
|
||||
const graduationStats = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType,
|
||||
specialGroups,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setGraduationStats(graduationStats, yearType, specialGroups)
|
||||
})
|
||||
})
|
||||
|
||||
return 'OK'
|
||||
await setGraduationStats(graduationStats, option.yearType, option.specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
return 'OK'
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudytrackView = async code => {
|
||||
const specialIncluded = 'SPECIAL_INCLUDED'
|
||||
const specialExcluded = 'SPECIAL_EXCLUDED'
|
||||
const graduatedIncluded = 'GRADUATED_INCLUDED'
|
||||
const graduatedExcluded = 'GRADUATED_EXCLUDED'
|
||||
const specialGraduatedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_SEPARATED',
|
||||
}
|
||||
const specialGraduatedExcludedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedExcludedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialGraduatedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_SEPARATED',
|
||||
}
|
||||
const specialGraduatedExcludedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedExcludedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
|
||||
const statsSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
specialGroups: specialIncluded,
|
||||
graduated: graduatedIncluded,
|
||||
})
|
||||
await setStudytrackStats(statsSpecialAndGraduated, graduatedIncluded, specialIncluded)
|
||||
const options = [
|
||||
specialGraduatedCombined,
|
||||
specialExcludedGraduatedCombined,
|
||||
specialGraduatedExcludedCombined,
|
||||
specialExcludedGraduatedExcludedCombined,
|
||||
specialGraduatedSeparated,
|
||||
specialExcludedGraduatedSeparated,
|
||||
specialGraduatedExcludedSeparated,
|
||||
specialExcludedGraduatedExcludedSeparated,
|
||||
]
|
||||
|
||||
const statsSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
specialGroups: specialIncluded,
|
||||
graduated: graduatedExcluded,
|
||||
})
|
||||
await setStudytrackStats(statsSpecialNotGraduated, graduatedExcluded, specialIncluded)
|
||||
|
||||
const statsNotSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
specialGroups: specialExcluded,
|
||||
graduated: graduatedIncluded,
|
||||
})
|
||||
await setStudytrackStats(statsNotSpecialAndGraduated, graduatedIncluded, specialExcluded)
|
||||
|
||||
const statsNotSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
specialGroups: specialExcluded,
|
||||
graduated: graduatedExcluded,
|
||||
})
|
||||
await setStudytrackStats(statsNotSpecialNotGraduated, graduatedExcluded, specialExcluded)
|
||||
for (const option of options) {
|
||||
try {
|
||||
const stats = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
specialGroups: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
graduated: option.graduated === 'GRADUATED_INCLUDED',
|
||||
yearsCombined: option.yearsCombined === 'YEARS_COMBINED',
|
||||
},
|
||||
})
|
||||
await setStudytrackStats(stats, option.graduated, option.specialGroups, option.yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return 'OK'
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const {
|
||||
getYearsArray,
|
||||
getPercentage,
|
||||
getYearsObject,
|
||||
getAcademicYearsObject,
|
||||
getCorrectStudentnumbers,
|
||||
getCreditThresholds,
|
||||
getCreditGraphStats,
|
||||
@@ -79,8 +78,7 @@ const getGraduationTimeStats = async ({
|
||||
// Goes through the programme and all its studytracks for the said year and adds the wanted stats to the data objects
|
||||
const getStudytrackDataForTheYear = async ({
|
||||
studyprogramme,
|
||||
special,
|
||||
includeGraduated,
|
||||
settings,
|
||||
studytracks,
|
||||
studytrackNames,
|
||||
year,
|
||||
@@ -89,6 +87,8 @@ const getStudytrackDataForTheYear = async ({
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
}) => {
|
||||
const { specialGroups: includeAllSpecials, graduated: includeGraduated, yearsCombined } = settings
|
||||
const { startDate, endDate } = getAcademicYearDates(year, yearsCombined)
|
||||
const {
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
@@ -100,7 +100,6 @@ const getStudytrackDataForTheYear = async ({
|
||||
totalAmounts,
|
||||
emptyTracks,
|
||||
} = data
|
||||
const { startDate, endDate } = getAcademicYearDates(year)
|
||||
|
||||
await Promise.all(
|
||||
studytracks.map(async track => {
|
||||
@@ -110,7 +109,7 @@ const getStudytrackDataForTheYear = async ({
|
||||
codes,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials: special,
|
||||
includeAllSpecials,
|
||||
includeGraduated,
|
||||
})
|
||||
|
||||
@@ -122,7 +121,7 @@ const getStudytrackDataForTheYear = async ({
|
||||
const enrolled = await enrolledStudents(track, startDate, studentnumbers)
|
||||
const absent = await absentStudents(track, studentnumbers)
|
||||
const cancelled = all.filter(s => s.canceldate !== null && s.canceldate !== undefined)
|
||||
const graduated = await graduatedStudyRights(track, null, studentnumbers)
|
||||
const graduated = await graduatedStudyRights(track, startDate, studentnumbers)
|
||||
|
||||
// If the track has no stats for that year, it should be removed from the table and dropdown options
|
||||
if (all.length === 0) {
|
||||
@@ -137,14 +136,14 @@ const getStudytrackDataForTheYear = async ({
|
||||
// Count stats for the credit progress table for the year per track
|
||||
creditTableStats[track] = [
|
||||
...creditTableStats[track],
|
||||
[`${year} - ${year + 1}`, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
|
||||
[year, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
|
||||
]
|
||||
|
||||
// Count stats for the main studytrack table grouped by tracks
|
||||
mainStatsByTrack[track] = [
|
||||
...mainStatsByTrack[track],
|
||||
[
|
||||
`${year} - ${year + 1}`,
|
||||
year,
|
||||
all.length,
|
||||
getPercentage(all.length, all.length),
|
||||
started.length,
|
||||
@@ -170,9 +169,7 @@ const getStudytrackDataForTheYear = async ({
|
||||
mainStatsByYear[year] = [
|
||||
...mainStatsByYear[year],
|
||||
[
|
||||
studytrackNames[track]?.name['fi']
|
||||
? `${studytrackNames[track]?.name['fi']}, ${track}`
|
||||
: `${year} - ${year + 1}`,
|
||||
studytrackNames[track]?.name['fi'] ? `${studytrackNames[track]?.name['fi']}, ${track}` : year,
|
||||
all.length,
|
||||
getPercentage(all.length, all.length),
|
||||
started.length,
|
||||
@@ -195,9 +192,9 @@ const getStudytrackDataForTheYear = async ({
|
||||
]
|
||||
|
||||
// Count stats for the graduation time charts grouped by year
|
||||
totalAmounts[track][`${year} - ${year + 1}`] = await all.length
|
||||
totalAmounts[track][year] = await all.length
|
||||
await getGraduationTimeStats({
|
||||
year: `${year} - ${year + 1}`,
|
||||
year,
|
||||
graduated,
|
||||
studyprogramme,
|
||||
track,
|
||||
@@ -224,7 +221,7 @@ const getStudytrackOptions = (studyprogramme, studytrackNames, studytracks, empt
|
||||
|
||||
// Creates empty objects for each statistic type, which are then updated with the studytrack data
|
||||
const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
|
||||
const mainStatsByYear = getYearsObject(years, true)
|
||||
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
|
||||
const mainStatsByTrack = {}
|
||||
const creditGraphStats = {}
|
||||
const creditTableStats = {}
|
||||
@@ -238,10 +235,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
|
||||
mainStatsByTrack[track] = []
|
||||
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years)
|
||||
creditTableStats[track] = []
|
||||
graduationMedianTime[track] = getAcademicYearsObject(years, true)
|
||||
graduationMeanTime[track] = getAcademicYearsObject(years, true)
|
||||
graduationAmounts[track] = getAcademicYearsObject(years)
|
||||
totalAmounts[track] = getAcademicYearsObject(years)
|
||||
graduationMedianTime[track] = getYearsObject({ years, emptyArrays: true })
|
||||
graduationMeanTime[track] = getYearsObject({ years, emptyArrays: true })
|
||||
graduationAmounts[track] = getYearsObject({ years })
|
||||
totalAmounts[track] = getYearsObject({ years })
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -258,12 +255,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
|
||||
}
|
||||
|
||||
// Combines all the data for the Populations and Studytracks -view
|
||||
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated, specialGroups }) => {
|
||||
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, settings }) => {
|
||||
const isAcademicYear = true
|
||||
const special = specialGroups === 'SPECIAL_INCLUDED'
|
||||
const includeGraduated = graduated === 'GRADUATED_INCLUDED'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear())
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined)
|
||||
|
||||
const associations = await getAssociations()
|
||||
const studytracks = associations.programmes[studyprogramme]
|
||||
@@ -279,8 +274,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
|
||||
years.map(async year => {
|
||||
return await getStudytrackDataForTheYear({
|
||||
studyprogramme,
|
||||
special,
|
||||
includeGraduated,
|
||||
settings,
|
||||
studytracks,
|
||||
studytrackNames,
|
||||
year,
|
||||
@@ -296,7 +290,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years: getYearsArray(since.getFullYear(), isAcademicYear),
|
||||
years: getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined),
|
||||
mainStatsByTrack: data.mainStatsByTrack,
|
||||
mainStatsByYear: data.mainStatsByYear,
|
||||
creditTableStats: data.creditTableStats,
|
||||
@@ -306,7 +300,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
|
||||
graduationAmounts: data.graduationAmounts,
|
||||
totalAmounts: data.totalAmounts,
|
||||
studytrackOptions,
|
||||
includeGraduated,
|
||||
includeGraduated: settings.graduated,
|
||||
populationTitles: tableTitles['studytracks'],
|
||||
creditTableTitles: tableTitles['creditProgress'][studyprogramme.includes('KH') ? 'bachelor' : 'master'],
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@ const findTopTeachers = async yearcode => {
|
||||
.filter(isRegularCourse)
|
||||
.map(credit => {
|
||||
const { credits, course, credittypecode } = credit
|
||||
const teachers = credit.teachers.map(({ id, name }) => ({ id, name }))
|
||||
const teachers = credit.teachers
|
||||
.filter(({ id }) => !id.includes('hy-hlo-org')) // Remove faculties from the leaderboards
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
const passed = Credit.passed(credit) || Credit.improved(credit)
|
||||
const failed = Credit.failed(credit)
|
||||
const transferred = credittypecode === 9
|
||||
|
||||
@@ -29,10 +29,12 @@ const getPassingSemester = (startYear, date) => {
|
||||
return yearCount < 6 ? `${yearCount}-${semester}` : 'LATER'
|
||||
}
|
||||
|
||||
const getAcademicYearDates = year => {
|
||||
const getAcademicYearDates = (year, yearsCombined) => {
|
||||
const startYear = typeof year === 'number' ? year : Number(year.slice(0, 4))
|
||||
const correctEndYear = yearsCombined ? new Date().getFullYear() : startYear
|
||||
return {
|
||||
startDate: `${year}-${semesterStart['FALL']}`,
|
||||
endDate: `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
|
||||
startDate: `${startYear}-${semesterStart['FALL']}`,
|
||||
endDate: `${moment(correctEndYear, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
const [courseGrades, setCourseGrades] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (samples && singleCourseStats.alternatives) {
|
||||
if (samples && singleCourseStats?.unifyStats?.alternatives) {
|
||||
const filteredGradeArray = []
|
||||
const grades = {}
|
||||
|
||||
|
||||
+2
-3
@@ -143,9 +143,8 @@ const AttemptsTable = ({
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
defaultdescending
|
||||
getRowKey={s => s.code}
|
||||
tableProps={{ celled: true, style: { width: 'auto' } }}
|
||||
title={`Yearly attempt statistics for group ${name}`}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -28,6 +28,7 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
|
||||
key: 'TIME',
|
||||
title: 'Time',
|
||||
getRowVal: s => s.code,
|
||||
getRowExportVal: s => s.name,
|
||||
getRowContent: s => (
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
{s.name}
|
||||
@@ -110,9 +111,11 @@ const StudentTable = ({
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
title={`Student statistics for group ${name}`}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
defaultdescending
|
||||
getRowKey={s => s.code}
|
||||
tableProps={{ celled: true, fixed: true, style: { width: 'auto' } }}
|
||||
tableProps={{ celled: true, fixed: true }}
|
||||
columns={columns}
|
||||
data={stats}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { Icon, Card, Dropdown, Portal, Popup } from 'semantic-ui-react'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
const FigureContext = createContext(null)
|
||||
|
||||
const Header = ({ children, actions, contextItems }) => {
|
||||
const { isFullscreen, setFullscreen } = useContext(FigureContext)
|
||||
|
||||
return (
|
||||
<Card.Content style={{ flexGrow: 0, display: 'flex', alignItems: 'center', padding: '1em', height: '3.25em' }}>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>{children}</div>
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<div style={{ marginRight: '1em' }}>{actions}</div>
|
||||
<Popup
|
||||
trigger={
|
||||
<Icon
|
||||
name={isFullscreen ? 'compress' : 'expand'}
|
||||
onClick={() => setFullscreen(!isFullscreen)}
|
||||
style={{ cursor: 'pointer', fontSize: '1.1rem', marginRight: '0.5em', position: 'relative', top: '-1px' }}
|
||||
/>
|
||||
}
|
||||
on="hover"
|
||||
position="bottom"
|
||||
>
|
||||
Toggle Fullscreen
|
||||
</Popup>
|
||||
{contextItems && (
|
||||
<div>
|
||||
<Dropdown position="bottom center" direction="left" icon="ellipsis vertical">
|
||||
<Dropdown.Menu>
|
||||
{contextItems.map(({ label, onClick }) => (
|
||||
<Dropdown.Item onClick={onClick}>{label}</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
)
|
||||
}
|
||||
|
||||
const Content = ({ children, ...rest }) => {
|
||||
const { isFullscreen } = useContext(FigureContext)
|
||||
|
||||
const style = {
|
||||
...rest.style,
|
||||
overflow: 'auto',
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
style.maxHeight = 'initial'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card.Content {...rest} style={style}>
|
||||
{children}
|
||||
</Card.Content>
|
||||
)
|
||||
}
|
||||
|
||||
const FigureContainer = ({ children, style }) => {
|
||||
const [isFullscreen, setFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.body.classList.add('figure-fullscreen')
|
||||
} else {
|
||||
document.body.classList.remove('figure-fullscreen')
|
||||
}
|
||||
|
||||
return () => document.body.classList.remove('figure-fullscreen')
|
||||
}, [isFullscreen])
|
||||
|
||||
return (
|
||||
<FigureContext.Provider value={{ isFullscreen, setFullscreen }}>
|
||||
<Card fluid style={{ overflow: 'hidden', maxHeight: '100%', ...style }}>
|
||||
{children}
|
||||
</Card>
|
||||
<Portal
|
||||
open={isFullscreen}
|
||||
onClose={() => setFullscreen(false)}
|
||||
style={{
|
||||
width: '100% !important',
|
||||
height: '100% !important',
|
||||
}}
|
||||
>
|
||||
<div style={{ inset: 0, position: 'absolute', zIndex: 910, height: '100000vh' }}>
|
||||
<Card
|
||||
fluid
|
||||
style={{ position: 'sticky', margin: 0, inset: 0, height: '100vh', border: 'none', borderRadius: 0 }}
|
||||
>
|
||||
{isFullscreen && children}
|
||||
</Card>
|
||||
</div>
|
||||
</Portal>
|
||||
</FigureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FigureContainer.Header = Header
|
||||
FigureContainer.Content = Content
|
||||
|
||||
export default FigureContainer
|
||||
@@ -0,0 +1,3 @@
|
||||
body.figure-fullscreen {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Icon } from 'semantic-ui-react'
|
||||
import './filterToggleIcon.css'
|
||||
|
||||
const FilterToggleIcon = ({ onClick, isActive }) => (
|
||||
<span className={`flexIcon ${isActive ? 'active' : ''}`}>
|
||||
<span className={`flexIcon ${isActive ? 'active' : ''}`} style={{ cursor: 'pointer' }}>
|
||||
<Icon onClick={onClick} name="filter" />
|
||||
<Icon onClick={onClick} name={isActive ? 'remove' : 'add'} size="tiny" />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { Popup } from 'semantic-ui-react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { isCourseSelected, toggleCourseSelection } from 'components/FilterView/filters/courses'
|
||||
import FilterToggleIcon from 'components/FilterToggleIcon'
|
||||
import useFilters from 'components/FilterView/useFilters'
|
||||
import { getTextIn } from '../../common'
|
||||
|
||||
const CourseFilterToggle = ({ course }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
|
||||
return (
|
||||
<Popup
|
||||
trigger={
|
||||
<FilterToggleIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
isActive={isActive}
|
||||
onClick={() => filterDispatch(toggleCourseSelection(course.code))}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CourseFilterToggle
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import GradeDistributionHeader from './GradeDistributionHeader'
|
||||
import GradeDistributionRow from './GradeDistributionRow'
|
||||
|
||||
const gradeTypes = [1, 2, 3, 4, 5]
|
||||
|
||||
const GradeDistributionFlat = () => {
|
||||
const { courseStatistics } = UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table celled sortable className="fixed-header">
|
||||
<GradeDistributionHeader gradeTypes={gradeTypes} />
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<GradeDistributionRow key={stats.course.code} courseStatistics={stats} gradeTypes={gradeTypes} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradeDistributionFlat
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import React from 'react'
|
||||
import { arrayOf, number } from 'prop-types'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import SortableHeaderCell from '../SortableHeaderCell'
|
||||
|
||||
const GradeDistributionHeader = ({ gradeTypes }) => {
|
||||
const { onSortableColumnHeaderClick, filterInput, reversed, sortCriteria, tableColumnNames } =
|
||||
UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
|
||||
<SortableHeaderCell
|
||||
content="Attempts"
|
||||
columnName={tableColumnNames.STUDENTS}
|
||||
onClickFn={onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
/>
|
||||
|
||||
<Table.HeaderCell>0</Table.HeaderCell>
|
||||
{gradeTypes.map(g => (
|
||||
<Table.HeaderCell content={g} key={g} />
|
||||
))}
|
||||
<Table.HeaderCell content="Other passed" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
}
|
||||
|
||||
GradeDistributionHeader.propTypes = {
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistributionHeader
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table, Popup, Item, Icon } from 'semantic-ui-react'
|
||||
import { replace, sortBy, omit } from 'lodash'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { shape, string, number, arrayOf } from 'prop-types'
|
||||
import { getTextIn } from '../../../common'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import { useLanguage } from '../../../common/hooks'
|
||||
|
||||
const formatGradeDistribution = grades =>
|
||||
replace(
|
||||
JSON.stringify(
|
||||
sortBy(
|
||||
Object.entries(grades).map(([key, value]) => ({ [key]: value.count })),
|
||||
o => -Object.keys(o)
|
||||
),
|
||||
null,
|
||||
1
|
||||
),
|
||||
/\[\n|{\n*|{\s|}|\s*}|]|"|,/g,
|
||||
''
|
||||
)
|
||||
|
||||
const CourseRow = ({ courseStatistics, gradeTypes }) => {
|
||||
const language = useLanguage()
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const { course, grades } = courseStatistics
|
||||
const { name, code } = course
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
let attempts = 0
|
||||
let failedGrades = 0
|
||||
let otherPassed = 0
|
||||
|
||||
if (grades) {
|
||||
const countSumReducer = (acc, cur) => acc + cur.count
|
||||
const gradeValues = grades ? Object.values(grades) : null
|
||||
attempts = gradeValues.reduce(countSumReducer, 0)
|
||||
failedGrades = gradeValues.filter(g => g.status.failingGrade).reduce(countSumReducer, 0)
|
||||
otherPassed = Object.values(omit(grades, gradeTypes))
|
||||
.filter(g => g.status.passingGrade || g.status.improvedGrade)
|
||||
.reduce(countSumReducer, 0)
|
||||
}
|
||||
return (
|
||||
<Table.Row active={isActive}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
|
||||
<Table.Cell className="iconCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={failedGrades} />
|
||||
{gradeTypes.map(g => (
|
||||
<Table.Cell content={grades[g] ? grades[g].count || 0 : 0} key={code + g} />
|
||||
))}
|
||||
<Table.Cell content={otherPassed} />
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
const GradeDistributionRow = ({ courseStatistics, gradeTypes }) => {
|
||||
const { course, grades } = courseStatistics
|
||||
const { code } = course
|
||||
return (
|
||||
<Popup
|
||||
key={code}
|
||||
flowing
|
||||
hoverable
|
||||
inverted
|
||||
position="top right"
|
||||
hideOnScroll
|
||||
content={grades ? <pre>{formatGradeDistribution(grades)}</pre> : 'Nothing to see here'}
|
||||
trigger={<CourseRow courseStatistics={courseStatistics} gradeTypes={gradeTypes} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CourseRow.propTypes = {
|
||||
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
GradeDistributionRow.propTypes = {
|
||||
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistributionRow
|
||||
+128
-38
@@ -1,49 +1,139 @@
|
||||
import React from 'react'
|
||||
import { Table, Icon } from 'semantic-ui-react'
|
||||
import { instanceOf, func } from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Icon, Item } from 'semantic-ui-react'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import _ from 'lodash'
|
||||
import { getTextIn } from '../../../common'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import GradeDistributionHeader from './GradeDistributionHeader'
|
||||
import GradeDistributionRow from './GradeDistributionRow'
|
||||
|
||||
const gradeTypes = [1, 2, 3, 4, 5]
|
||||
const mapCourseData = course => ({
|
||||
name: course.course.name,
|
||||
code: course.course.code,
|
||||
attempts: _.chain(course.grades).values().map('count').sum().value(),
|
||||
otherPassed: _.chain(course.grades)
|
||||
.omit(_.range(0, 6))
|
||||
.filter(g => g.status.passingGrade || g.status.improvedGrade)
|
||||
.map('count')
|
||||
.sum()
|
||||
.value(),
|
||||
grades: {
|
||||
...course.grades,
|
||||
0: {
|
||||
count: _.chain(course.grades)
|
||||
.filter(g => g.status.failingGrade)
|
||||
.map('count')
|
||||
.sum()
|
||||
.value(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const GradeDistribution = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const GradeDistribution = ({ flat }) => {
|
||||
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: row => getTextIn(row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: row => row.code,
|
||||
},
|
||||
{
|
||||
key: 'stats',
|
||||
noHeader: true,
|
||||
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
|
||||
children: [
|
||||
{
|
||||
key: 'attempts',
|
||||
title: 'Attempts',
|
||||
getRowVal: row => row.attempts,
|
||||
},
|
||||
..._.range(0, 6).map(grade => ({
|
||||
key: `grade-${grade}`,
|
||||
title: `${grade}`,
|
||||
getRowVal: row => row.grades[grade]?.count ?? 0,
|
||||
})),
|
||||
{
|
||||
key: 'other-passed',
|
||||
title: 'Other Passed',
|
||||
getRowVal: row => row.otherPassed,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (flat) {
|
||||
return courseStatistics.map(mapCourseData)
|
||||
}
|
||||
|
||||
return modules.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: ({ definition: { module }, children }) => ({
|
||||
name: module.name,
|
||||
code: module.code,
|
||||
attempts: _.chain(children).map('attempts').sum().value(),
|
||||
otherPassed: _.chain(children).map('otherPassed').sum().value(),
|
||||
grades: _.range(0, 6).map(grade => ({
|
||||
count: _.chain(children)
|
||||
.map(course => course.grades[grade]?.count ?? 0)
|
||||
.sum()
|
||||
.value(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
courses.map(mapCourseData)
|
||||
)
|
||||
)
|
||||
}, [modules])
|
||||
|
||||
return (
|
||||
<Table celled sortable className="fixed-header">
|
||||
<GradeDistributionHeader gradeTypes={gradeTypes} />
|
||||
<Table.Body>
|
||||
{modules.map(({ module, courses }) => (
|
||||
<React.Fragment key={module.code}>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
|
||||
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
|
||||
<b>{getTextIn(module.name, language)}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<b>{module.code}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell colSpan="8" />
|
||||
</Table.Row>
|
||||
{expandedGroups.has(module.code) &&
|
||||
courses.map(course => (
|
||||
<GradeDistributionRow key={course.course.code} courseStatistics={course} gradeTypes={gradeTypes} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<>
|
||||
<SortableTable title="Grade distribution of courses" data={data} columns={columns} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
GradeDistribution.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistribution
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import PassFailHeader from './PassFailHeader'
|
||||
import PassFailRow from './PassFailRow'
|
||||
|
||||
const PassFail = () => {
|
||||
const { courseStatistics } = UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table className="fixed-header" celled sortable>
|
||||
<PassFailHeader />
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<PassFailRow key={stats.course.code} courseStats={stats} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassFail
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import SortableHeaderCell from '../SortableHeaderCell'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
|
||||
const PassFailHeader = () => {
|
||||
const { onSortableColumnHeaderClick, filterInput, tableColumnNames, sortCriteria, reversed } =
|
||||
UsePopulationCourseContext()
|
||||
|
||||
const getSortableHeaderCell = (label, columnName, rowSpan = 1) => (
|
||||
<SortableHeaderCell
|
||||
content={label}
|
||||
columnName={columnName}
|
||||
onClickFn={onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
rowSpan={rowSpan}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="4" content="Course" />
|
||||
{getSortableHeaderCell('Students', tableColumnNames.STUDENTS, 2)}
|
||||
<Table.HeaderCell colSpan="3" content="Passed" />
|
||||
<Table.HeaderCell colSpan="2" content="Failed" />
|
||||
<Table.HeaderCell colSpan="2" content="Attempts" />
|
||||
<Table.HeaderCell colSpan="2" content="percentage of population" />
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
{getSortableHeaderCell('n', tableColumnNames.PASSED)}
|
||||
{getSortableHeaderCell('after retry', tableColumnNames.RETRY_PASSED)}
|
||||
{getSortableHeaderCell('percentage', tableColumnNames.PERCENTAGE)}
|
||||
{getSortableHeaderCell('n', tableColumnNames.FAILED)}
|
||||
{getSortableHeaderCell('many times', tableColumnNames.FAILED_MANY)}
|
||||
{getSortableHeaderCell('n', tableColumnNames.ATTEMPTS)}
|
||||
{getSortableHeaderCell('per student', tableColumnNames.PER_STUDENT)}
|
||||
{getSortableHeaderCell('Passed', tableColumnNames.PASSED_OF_POPULATION)}
|
||||
{getSortableHeaderCell('attempted', tableColumnNames.TRIED_OF_POPULATION)}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassFailHeader
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table, Icon, Popup, Item } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { shape, string } from 'prop-types'
|
||||
import { getTextIn } from '../../../common'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
|
||||
const PassFailRow = ({ courseStats }) => {
|
||||
const { language } = useLanguage()
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
const { course, stats } = courseStats
|
||||
const { code, name } = course
|
||||
const {
|
||||
failed,
|
||||
passed,
|
||||
retryPassed,
|
||||
failedMany,
|
||||
attempts,
|
||||
percentage,
|
||||
perStudent,
|
||||
passedOfPopulation,
|
||||
triedOfPopulation,
|
||||
} = stats
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
return (
|
||||
<Table.Row key={code} active={isActive} data-cy={name.fi}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
|
||||
<Table.Cell className="iconCell clickableCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
data-cy={`coursestats-link-${code}`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={passed + failed} />
|
||||
<Table.Cell content={passed} />
|
||||
<Table.Cell content={retryPassed} />
|
||||
<Table.Cell content={`${percentage.toFixed(2)} %`} />
|
||||
<Table.Cell content={failed} />
|
||||
<Table.Cell content={failedMany} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={perStudent.toFixed(2)} />
|
||||
<Table.Cell content={`${passedOfPopulation.toFixed(2)} %`} />
|
||||
<Table.Cell content={`${triedOfPopulation.toFixed(2)} %`} />
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
PassFailRow.propTypes = {
|
||||
courseStats: shape({ course: shape({ code: string }) }).isRequired,
|
||||
}
|
||||
|
||||
export default PassFailRow
|
||||
@@ -1,34 +1,215 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { func, instanceOf } from 'prop-types'
|
||||
import React, { useMemo } from 'react'
|
||||
import _ from 'lodash'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { Item, Icon } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import CollapsibleModuleTable from '../CollapsibleModuleTable'
|
||||
import PassFailRow from './PassFailRow'
|
||||
import PassFailHeader from './PassFailHeader'
|
||||
import { getTextIn } from '../../../common'
|
||||
|
||||
const PassFail = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const createModuleAggregateRow = ({ definition, children }) => ({
|
||||
label_code: definition.module.code,
|
||||
label_name: definition.module.name,
|
||||
stats: {
|
||||
passed: _.chain(children).map('stats.passed').sum().value(),
|
||||
retryPassed: _.chain(children).map('stats.retryPassed').sum().value(),
|
||||
percentage: _.chain(children).map('stats.percentage').mean().value(),
|
||||
failed: _.chain(children).map('stats.failed').sum().value(),
|
||||
failedMany: _.chain(children).map('stats.failedMany').sum().value(),
|
||||
attempts: _.chain(children).map('stats.attempts').sum().value(),
|
||||
perStudent: _.chain(children).map('stats.perStudent').mean().value(),
|
||||
passedOfPopulation: _.chain(children).map('stats.passedOfPopulation').mean().value(),
|
||||
triedOfPopulation: _.chain(children).map('stats.triedOfPopulation').mean().value(),
|
||||
},
|
||||
})
|
||||
|
||||
const PassFail = ({ flat }) => {
|
||||
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course',
|
||||
title: 'Course',
|
||||
children: [
|
||||
{
|
||||
key: 'course-name-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'course-name',
|
||||
title: 'Name',
|
||||
getRowVal: (row, isGroup) => getTextIn(isGroup ? row.label_name : row.name ?? row.course.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'course-code',
|
||||
title: 'Code',
|
||||
getRowVal: (row, isGroup) => (isGroup ? row.label_code : row.code ?? row.course.code),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'statistics',
|
||||
noHeader: true,
|
||||
getRowVal: (_, isGroup) => (isGroup ? ' ' : undefined),
|
||||
children: [
|
||||
{
|
||||
key: 'passed',
|
||||
title: 'Passed',
|
||||
children: [
|
||||
{
|
||||
key: 'passed-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.passed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'passed-after-retry',
|
||||
title: 'After Retry',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.retryPassed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'passed-percentage',
|
||||
title: 'Passed-%',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.percentage ?? 0,
|
||||
formatValue: value =>
|
||||
value &&
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
title: 'Failed',
|
||||
children: [
|
||||
{
|
||||
key: 'failed-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.failed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'failed-many',
|
||||
title: 'Multiple Times',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.failedMany ?? 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
title: 'Attempts',
|
||||
children: [
|
||||
{
|
||||
key: 'attempts-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.attempts,
|
||||
},
|
||||
{
|
||||
key: 'attempts-per-student',
|
||||
title: 'per Student',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.perStudent,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'of-population',
|
||||
title: 'Percentage of Population',
|
||||
children: [
|
||||
{
|
||||
key: 'passed-of-population',
|
||||
title: 'Passed',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.passedOfPopulation,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
{
|
||||
key: 'attempted-of-population',
|
||||
title: 'Attempted',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.triedOfPopulation,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (flat) {
|
||||
return courseStatistics.map(course => ({ ...course, code: course.course.code, name: course.course.name }))
|
||||
}
|
||||
|
||||
return _.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: createModuleAggregateRow,
|
||||
columnOverrides: {},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value()
|
||||
}, [modules])
|
||||
|
||||
return (
|
||||
<Table className="fixed-header" celled sortable>
|
||||
<PassFailHeader />
|
||||
<Table.Body>
|
||||
<CollapsibleModuleTable
|
||||
emptyColSpan={12}
|
||||
modules={modules}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
>
|
||||
{courses => courses.map(stats => <PassFailRow key={stats.course.code} courseStats={stats} />)}
|
||||
</CollapsibleModuleTable>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<>
|
||||
<SortableTable title="Pass and fail statistics of courses" data={data} columns={columns} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
PassFail.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default PassFail
|
||||
|
||||
+3
-1
@@ -14,7 +14,9 @@ const CumulativeCheckbox = ({ cumulativeStats, setCumulativeStats }) => {
|
||||
setCumulativeStats(!cumulativeStats)
|
||||
}
|
||||
|
||||
return <Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" />
|
||||
return (
|
||||
<Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" size="small" />
|
||||
)
|
||||
}
|
||||
|
||||
CumulativeCheckbox.propTypes = {
|
||||
|
||||
+137
-32
@@ -1,43 +1,148 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { func, instanceOf } from 'prop-types'
|
||||
import PassingSemesterRow from './PassingSemesterRow'
|
||||
import CollapsibleModuleTable from '../CollapsibleModuleTable'
|
||||
import PassingSemestersHeader from './PassingSemestersHeader'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Item, Icon, Button } from 'semantic-ui-react'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { getTextIn } from '../../../common'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import CumulativeCheckbox from './CumulativeCheckbox'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
|
||||
const PassingSemesters = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const semesterColumn = (year, semester, cumulative) => ({
|
||||
key: `semester-${year}-${semester}`,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
title: semester,
|
||||
getRowVal: row =>
|
||||
((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
|
||||
`${year}-${semester.toUpperCase()}`
|
||||
],
|
||||
getRowContent: row => {
|
||||
const value = ((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
|
||||
`${year}-${semester.toUpperCase()}`
|
||||
]
|
||||
|
||||
if (value === 0) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const yearColumn = (year, cumulative) => ({
|
||||
key: `year-${year}`,
|
||||
title: `${year + 1}${['st', 'nd', 'rd'][year] ?? 'th'} year`,
|
||||
children: [semesterColumn(year, 'Fall', cumulative), semesterColumn(year, 'Spring', cumulative)],
|
||||
})
|
||||
|
||||
const PassingSemesters = () => {
|
||||
const { modules, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
const [cumulativeStats, setCumulativeStats] = useState(false)
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course-name',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: (row, isGroup, parents) => getTextIn(isGroup ? parents[0].module.name : row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: (row, isGroup, parents) => (isGroup ? parents[0].module.code : row.code),
|
||||
},
|
||||
{
|
||||
key: 'stats',
|
||||
noHeader: true,
|
||||
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
|
||||
children: [
|
||||
{
|
||||
key: 'students',
|
||||
title: 'Students',
|
||||
getRowVal: row => row.stats?.students,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
{
|
||||
key: 'passed',
|
||||
title: 'Passed',
|
||||
getRowVal: row => row.stats?.passed,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
{
|
||||
key: 'passed-before',
|
||||
title: 'Before 1st year',
|
||||
getRowVal: row =>
|
||||
(cumulativeStats ? row.stats?.passingSemesters : row.stats?.passingSemestersCumulative)?.BEFORE,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
..._.range(0, 6).map(i => yearColumn(i, cumulativeStats)),
|
||||
],
|
||||
},
|
||||
],
|
||||
[cumulativeStats, onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
_.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: {},
|
||||
columnOverrides: {},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value(),
|
||||
[modules]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CumulativeCheckbox cumulativeStats={cumulativeStats} setCumulativeStats={setCumulativeStats} />
|
||||
<Table celled className="fixed-header">
|
||||
<PassingSemestersHeader />
|
||||
<Table.Body>
|
||||
<CollapsibleModuleTable
|
||||
modules={modules}
|
||||
emptyColSpan={15}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
>
|
||||
{courses =>
|
||||
courses.map(stats => (
|
||||
<PassingSemesterRow key={stats.course.code} statistics={stats} cumulative={cumulativeStats} />
|
||||
))
|
||||
}
|
||||
</CollapsibleModuleTable>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<SortableTable
|
||||
title="Students Passing a Course per Semester"
|
||||
actions={
|
||||
<Button size="mini" style={{ padding: '.75em .75em' }} onClick={() => setCumulativeStats(!cumulativeStats)}>
|
||||
{cumulativeStats ? 'Show yearly stats' : 'Show cumulative stats'}
|
||||
</Button>
|
||||
}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PassingSemesters.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default PassingSemesters
|
||||
|
||||
+8
-8
@@ -6,8 +6,8 @@ import { clearCourseStats } from '../../redux/coursestats'
|
||||
import './populationCourseStats.css'
|
||||
import { PopulationCourseContext } from './PopulationCourseContext'
|
||||
import TSA from '../../common/tsa'
|
||||
import GradeDistribution from './GradeDistribution/GradeDistributionFlat'
|
||||
import PassFail from './PassFail/PassFailFlat'
|
||||
import GradeDistribution from './GradeDistribution'
|
||||
import PassFail from './PassFail'
|
||||
import { getTextIn } from '../../common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
|
||||
@@ -215,17 +215,17 @@ const PopulationCourseStatsFlat = ({ courses, pending, filteredStudents, showFil
|
||||
{
|
||||
menuItem: 'pass/fail',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<PassFail />
|
||||
</div>
|
||||
<Tab.Pane>
|
||||
<PassFail flat />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'grades',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<GradeDistribution />
|
||||
</div>
|
||||
<Tab.Pane>
|
||||
<GradeDistribution flat />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,99 +1,41 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Table, Icon, Popup, Item, Pagination } from 'semantic-ui-react'
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { Icon, Button, Item, Pagination } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSelector, connect } from 'react-redux'
|
||||
import { bool, instanceOf, func } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { getTextIn } from '../../../common'
|
||||
import StudentNameVisibilityToggle from '../../StudentNameVisibilityToggle'
|
||||
import { useStudentNameVisibility } from '../../StudentNameVisibilityToggle'
|
||||
|
||||
const verticalTitle = (...params) => {
|
||||
// https://stackoverflow.com/a/41396815
|
||||
return (
|
||||
<div className="studentVerticalTitle">
|
||||
{params.map(p => (
|
||||
<div key={p}>{p}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StudentsRow = ({ course, pagedStudents, hasCompleted, onGoToCourseStatisticsClick }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
|
||||
return (
|
||||
<Table.Row key={course.code}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell className="nameCell" key="name" content={course.name.fi} />
|
||||
<Table.Cell className="iconCell clickableCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
course.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(course.code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell key="code" content={course.code} />
|
||||
{pagedStudents.map(student => (
|
||||
<Table.Cell
|
||||
key={student.studentnumber}
|
||||
content={hasCompleted(student.studentnumber) ? <Icon fitted name="check" color="green" /> : null}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, showNames }) => {
|
||||
const { courseStatistics, filterInput, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const Students = ({ filteredStudents }) => {
|
||||
const { courseStatistics, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
|
||||
const { visible: namesVisible, toggle: toggleStudentNames } = useStudentNameVisibility()
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const hasCompleted = (courseCode, student) => {
|
||||
const course = courseStatistics.find(c => c.course.code === courseCode)
|
||||
if (!course) return false
|
||||
const hasCompleted = useCallback(
|
||||
(courseCode, student) => {
|
||||
const course = courseStatistics.find(c => c.course.code === courseCode)
|
||||
if (!course) return false
|
||||
|
||||
return Boolean(course.students.passed[student])
|
||||
}
|
||||
return Boolean(course.students.passed[student])
|
||||
},
|
||||
[courseStatistics]
|
||||
)
|
||||
|
||||
const countCompleted = (courses, student) => {
|
||||
let completed = 0
|
||||
courses.forEach(course => {
|
||||
if (hasCompleted(course.code, student)) {
|
||||
completed++
|
||||
}
|
||||
})
|
||||
const countCompleted = useCallback(
|
||||
(courses, student) => {
|
||||
let completed = 0
|
||||
courses.forEach(course => {
|
||||
if (hasCompleted(course.code, student)) {
|
||||
completed++
|
||||
}
|
||||
})
|
||||
|
||||
return completed
|
||||
}
|
||||
return completed
|
||||
},
|
||||
[hasCompleted]
|
||||
)
|
||||
|
||||
const students = useMemo(() => {
|
||||
const studentSet = new Set()
|
||||
@@ -124,81 +66,123 @@ const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, show
|
||||
|
||||
const pagedStudents = students.slice(page * 10, page * 10 + 10)
|
||||
|
||||
const data = useMemo(() => {
|
||||
return _.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
headerRowData: {
|
||||
name: module.name,
|
||||
code: module.code,
|
||||
courses,
|
||||
},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value()
|
||||
}, [modules])
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course',
|
||||
title: 'Course',
|
||||
children: [
|
||||
{
|
||||
key: 'title-parent',
|
||||
mergeHeader: 'title',
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'title',
|
||||
title: 'Name',
|
||||
getRowVal: row => getTextIn(row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: row => row.code,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'Students',
|
||||
children: pagedStudents.map(student => ({
|
||||
key: `student-${student.studentnumber}`,
|
||||
title: `${namesVisible ? student.name : student.studentnumber}`,
|
||||
getRowVal: (row, isGroup) =>
|
||||
isGroup
|
||||
? countCompleted(row.courses, student.studentnumber)
|
||||
: hasCompleted(row.code, student.studentnumber),
|
||||
getRowContent: (row, isGroup) =>
|
||||
isGroup
|
||||
? countCompleted(row.courses, student.studentnumber)
|
||||
: hasCompleted(row.code, student.studentnumber) && <Icon fitted name="check" color="green" />,
|
||||
formatValue: value => (value ? 'Passed' : 'Not passed'),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[modules, pagedStudents]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<StudentNameVisibilityToggle />
|
||||
</div>
|
||||
<Pagination
|
||||
secondary
|
||||
activePage={page + 1}
|
||||
totalPages={maxPages + 1}
|
||||
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
|
||||
ellipsisItem={null}
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
<SortableTable
|
||||
title={
|
||||
<>
|
||||
Courses passed by students
|
||||
<Pagination
|
||||
style={{ marginLeft: '1em' }}
|
||||
size="mini"
|
||||
secondary
|
||||
activePage={page + 1}
|
||||
totalPages={maxPages + 1}
|
||||
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
|
||||
ellipsisItem={null}
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button size="tiny" onClick={() => toggleStudentNames()}>
|
||||
{namesVisible ? 'Hide student names' : 'Show student names'}
|
||||
</Button>
|
||||
}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
<Table sortable className="fixed-header" striped celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
{pagedStudents.map(student => (
|
||||
<Table.HeaderCell
|
||||
key={student.studentnumber}
|
||||
content={
|
||||
<Link style={{ color: 'black' }} to={`/students/${student.studentnumber}`}>
|
||||
{showNames ? verticalTitle(student.name) : verticalTitle(student.studentnumber)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{modules.map(({ module, courses }) => (
|
||||
<React.Fragment key={module.code}>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
|
||||
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
|
||||
<b>{getTextIn(module.name, language)}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<b>{module.code}</b>
|
||||
</Table.Cell>
|
||||
{pagedStudents.map(student => (
|
||||
<Table.Cell key={`${module.code}-${student.studentnumber}`}>
|
||||
{countCompleted(courses, student.studentnumber)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
{expandedGroups.has(module.code) &&
|
||||
courses
|
||||
.filter(c => c.visible.visibility)
|
||||
.map(col => (
|
||||
<StudentsRow
|
||||
course={col}
|
||||
pagedStudents={pagedStudents}
|
||||
hasCompleted={sn => hasCompleted(col.code, sn)}
|
||||
onGoToCourseStatisticsClick={onGoToCourseStatisticsClick}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showNames: state.settings.namesVisible,
|
||||
})
|
||||
|
||||
Students.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Students)
|
||||
export default Students
|
||||
|
||||
@@ -229,11 +229,14 @@ const PopulationCourseStats = props => {
|
||||
})
|
||||
}
|
||||
|
||||
const onGoToCourseStatisticsClick = courseCode => {
|
||||
const { clearCourseStats: clearCourseStatsfn } = props
|
||||
sendAnalytics('Courses of Population course stats button clicked', courseCode)
|
||||
clearCourseStatsfn()
|
||||
}
|
||||
const onGoToCourseStatisticsClick = useCallback(
|
||||
courseCode => {
|
||||
const { clearCourseStats: clearCourseStatsfn } = props
|
||||
sendAnalytics('Courses of Population course stats button clicked', courseCode)
|
||||
clearCourseStatsfn()
|
||||
},
|
||||
[sendAnalytics, props.clearCourseStats]
|
||||
)
|
||||
|
||||
const onFilterReset = field => {
|
||||
setFilterFields({ ...filterFields, [field]: '' })
|
||||
@@ -282,48 +285,49 @@ const PopulationCourseStats = props => {
|
||||
tableColumnNames,
|
||||
sortCriteria,
|
||||
reversed,
|
||||
courses,
|
||||
}
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'pass/fail',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<Tab.Pane className="menuTab">
|
||||
<PassFail expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'grades',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<Tab.Pane className="menuTab">
|
||||
<GradeDistribution expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'when passed',
|
||||
render: () => (
|
||||
<div className="menuTab" style={{ marginTop: '0.5em' }}>
|
||||
<Tab.Pane className="menuTab">
|
||||
<PassingSemesters
|
||||
filterInput={renderFilterInputHeaderCell}
|
||||
courseStatistics={courseStatistics}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'students',
|
||||
render: () => (
|
||||
<div className="menuTab" style={{ marginTop: '0.5em' }}>
|
||||
<Tab.Pane className="menuTab">
|
||||
<Students
|
||||
filteredStudents={props.filteredStudents}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -33,9 +33,3 @@
|
||||
white-space: initial;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menuTab {
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -212,32 +212,40 @@ const GeneralTab = ({
|
||||
const columnsAvailable = {
|
||||
lastname: { key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
|
||||
firstname: { key: 'firstname', title: 'given names', getRowVal: s => s.firstnames },
|
||||
studentnumber: {
|
||||
key: 'studentnumber',
|
||||
title: 'Student Number',
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
getRowContent: s => (
|
||||
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
|
||||
{!s.obfuscated ? s.studentNumber : 'hidden'}
|
||||
</span>
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
icon: {
|
||||
key: 'icon',
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'General tab')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
'studentnumber-parent': {
|
||||
key: 'studentnumber-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'studentnumber',
|
||||
title: 'Student Number',
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
getRowContent: s => (
|
||||
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
|
||||
{!s.obfuscated ? s.studentNumber : 'hidden'}
|
||||
</span>
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
export: false,
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'General tab')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { className: 'iconCellNoPointer' },
|
||||
},
|
||||
],
|
||||
},
|
||||
creditsSinceStart: {
|
||||
key: 'creditsSinceStart',
|
||||
@@ -303,19 +311,19 @@ const GeneralTab = ({
|
||||
studyStartDate: {
|
||||
key: 'studyStartDate',
|
||||
title: 'start of studyright',
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
|
||||
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]),
|
||||
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
|
||||
},
|
||||
studyStartDateActual: {
|
||||
key: 'studyStartDateActual',
|
||||
title: 'started in studyright',
|
||||
getRowVal: s => new Date(getActualStartDate(s.studentNumber)).getTime(),
|
||||
getRowContent: s => reformatDate(getActualStartDate(s.studentNumber), 'YYYY-MM-DD'),
|
||||
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
|
||||
getRowVal: s => new Date(getActualStartDate(s.studentNumber)),
|
||||
},
|
||||
endDate: {
|
||||
key: 'endDate',
|
||||
title: 'graduation date',
|
||||
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
|
||||
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]),
|
||||
getRowContent: s =>
|
||||
studentToStudyrightEndMap[s.studentNumber]
|
||||
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
|
||||
@@ -393,12 +401,13 @@ const GeneralTab = ({
|
||||
/>
|
||||
) : null,
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
cellProps: { className: 'iconCellNoPointer' },
|
||||
},
|
||||
updatedAt: {
|
||||
key: 'updatedAt',
|
||||
title: 'Last Updated At',
|
||||
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
|
||||
getRowVal: s => new Date(s.updatedAt),
|
||||
formatValue: value => reformatDate(value, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
}
|
||||
// Columns are shown in order they're declared above. JS guarantees this order of keys
|
||||
@@ -418,20 +427,20 @@ const GeneralTab = ({
|
||||
.value()
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
<SortableTable
|
||||
getRowKey={s => s.studentNumber}
|
||||
tableProps={{
|
||||
collapsing: true,
|
||||
basic: true,
|
||||
compact: 'very',
|
||||
padded: false,
|
||||
celled: true,
|
||||
}}
|
||||
columns={columns}
|
||||
data={selectedStudents.map(sn => students[sn])}
|
||||
/>
|
||||
</div>
|
||||
<SortableTable
|
||||
style={{ height: '80vh' }}
|
||||
title="General student information"
|
||||
getRowKey={s => s.studentNumber}
|
||||
tableProps={{
|
||||
collapsing: true,
|
||||
basic: true,
|
||||
compact: 'very',
|
||||
padded: false,
|
||||
celled: true,
|
||||
}}
|
||||
columns={columns}
|
||||
data={selectedStudents.map(sn => students[sn])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -477,7 +486,7 @@ const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
|
||||
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
|
||||
}
|
||||
|
||||
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
|
||||
const baseColumns = ['studentnumber-parent', 'allCredits', 'tags', 'updatedAt', 'option']
|
||||
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
|
||||
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
|
||||
import { orderBy, uniqBy, sortBy, isNumber } from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { getTextIn } from '../../common'
|
||||
@@ -145,30 +145,37 @@ const PopulationStudents = ({
|
||||
: []
|
||||
nameColumns.push(
|
||||
{
|
||||
key: 'studentnumber',
|
||||
title: verticalTitle('student number'),
|
||||
cellProps: { title: 'student number' },
|
||||
getRowVal: s => (s.total ? '*' : s.studentNumber),
|
||||
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
title: '',
|
||||
getRowVal: s =>
|
||||
!s.total && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'Mandatory courses table')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { collapsing: true, className: 'iconCell' },
|
||||
child: true,
|
||||
key: 'studentnumber-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
title: 'Student Number',
|
||||
children: [
|
||||
{
|
||||
key: 'studentnumber',
|
||||
cellProps: { title: 'student number' },
|
||||
getRowVal: s => (s.total ? '*' : s.studentNumber),
|
||||
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
export: false,
|
||||
getRowVal: s =>
|
||||
!s.total && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'Mandatory courses table')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { className: 'iconCell' },
|
||||
child: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'totalpassed',
|
||||
@@ -224,6 +231,8 @@ const PopulationStudents = ({
|
||||
{ visibleLabels: new Set(), visibleCourseCodes: new Set() }
|
||||
)
|
||||
|
||||
const getTotalRowVal = (t, m) => t[m.code]
|
||||
|
||||
const labelColumns = []
|
||||
labelColumns.push(
|
||||
{
|
||||
@@ -231,6 +240,7 @@ const PopulationStudents = ({
|
||||
title: <b>Labels:</b>,
|
||||
parent: true,
|
||||
headerProps: { colSpan: nameColumns.length, style: { textAlign: 'right' } },
|
||||
children: nameColumns,
|
||||
},
|
||||
...sortedlabels
|
||||
.filter(({ code }) => visibleLabels.has(code))
|
||||
@@ -247,17 +257,7 @@ const PopulationStudents = ({
|
||||
title: e.label,
|
||||
ordernumber: e.orderNumber,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const getTotalRowVal = (t, m) => t[m.code]
|
||||
|
||||
const mandatoryCourseColumns = [
|
||||
...nameColumns,
|
||||
...labelColumns,
|
||||
...flatten(
|
||||
sortedlabels.map(e =>
|
||||
sortBy(labelToMandatoryCourses[e.label], [
|
||||
children: sortBy(labelToMandatoryCourses[e.label], [
|
||||
m => {
|
||||
const res = m.code.match(/\d+/)
|
||||
return res ? Number(res[0]) : Number.MAX_VALUE
|
||||
@@ -270,7 +270,8 @@ const PopulationStudents = ({
|
||||
title: verticalTitle(mandatoryTitle(m)),
|
||||
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
|
||||
getRowVal: s =>
|
||||
s.total ? getTotalRowVal(s, m) : JSON.stringify(hasPassedMandatory(s.studentNumber, m.code)),
|
||||
getRowContent: s => {
|
||||
if (s.total) return getTotalRowVal(s, m)
|
||||
return hasPassedMandatory(s.studentNumber, m.code) ? <Icon fitted name="check" color="green" /> : null
|
||||
@@ -278,10 +279,11 @@ const PopulationStudents = ({
|
||||
child: true,
|
||||
childOf: e.label,
|
||||
code: m.code,
|
||||
}))
|
||||
)
|
||||
),
|
||||
]
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
const mandatoryCourseColumns = [...labelColumns]
|
||||
|
||||
const totals = filteredStudents.reduce(
|
||||
(acc, s) => {
|
||||
@@ -317,9 +319,10 @@ const PopulationStudents = ({
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
<div style={{ maxHeight: '80vh', width: '100%' }}>
|
||||
{mandatoryCourses.length > 0 && (
|
||||
<SortableTable
|
||||
title={`Courses of population's students`}
|
||||
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
|
||||
tableProps={{
|
||||
celled: true,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import xlsx from 'xlsx'
|
||||
import { getDataItemType, getColumnValue, DataItemType, DataVisitor } from './common'
|
||||
|
||||
const getExportColumns = columns => {
|
||||
const stack = _.cloneDeep(columns)
|
||||
|
||||
const exportColumns = []
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [column] = stack.splice(0, 1)
|
||||
|
||||
const parents = column.parents ?? []
|
||||
|
||||
if (column.export === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (column.children && column.children.length > 0) {
|
||||
stack.splice(0, 0, ...column.children.map(c => ({ ...c, parents: [...parents, column] })))
|
||||
continue
|
||||
}
|
||||
|
||||
exportColumns.push(column)
|
||||
}
|
||||
|
||||
return exportColumns
|
||||
}
|
||||
|
||||
const flattenData = data => {
|
||||
const stack = [...data]
|
||||
const flat = []
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [item] = stack.splice(0, 1)
|
||||
|
||||
if (getDataItemType(item) === DataItemType.Group) {
|
||||
stack.splice(0, 0, ...item.children)
|
||||
} else {
|
||||
flat.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return flat
|
||||
}
|
||||
|
||||
const getColumnTitle = column => {
|
||||
return [...(column.parents ?? []), column]
|
||||
.filter(c => c.title)
|
||||
.map(c => c.title)
|
||||
.join(' - ')
|
||||
}
|
||||
|
||||
class ExportVisitor extends DataVisitor {
|
||||
constructor(columns) {
|
||||
super()
|
||||
this.columns = columns
|
||||
this.rows = []
|
||||
}
|
||||
|
||||
visitRow(ctx) {
|
||||
const row = _.chain(this.columns)
|
||||
.map(column => [getColumnTitle(column), getColumnValue(ctx, column)])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
this.rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
class ValueSamplerVisitor extends DataVisitor {
|
||||
constructor(columns) {
|
||||
super()
|
||||
this.columns = columns
|
||||
this.values = _.fromPairs(_.map(columns, c => [c.key, new Set()]))
|
||||
}
|
||||
|
||||
visitRow(ctx) {
|
||||
this.columns.forEach(column => {
|
||||
const value = getColumnValue(ctx, column)
|
||||
this.values[column.key].add(value)
|
||||
})
|
||||
}
|
||||
|
||||
sample(n) {
|
||||
return _.chain(this.values)
|
||||
.mapValues(v => _.sampleSize([...v], n))
|
||||
.value()
|
||||
}
|
||||
}
|
||||
|
||||
const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
|
||||
const exportColumns = useMemo(() => getExportColumns(columns), [columns])
|
||||
const flatData = useMemo(() => flattenData(data), [data])
|
||||
const [selected, setSelected] = useState(_.uniq(_.map(exportColumns, 'key')))
|
||||
|
||||
const sampledValues = useMemo(() => ValueSamplerVisitor.visit(data, exportColumns).sample(10), [exportColumns, data])
|
||||
|
||||
const handleExport = () => {
|
||||
const columns = exportColumns.filter(ec => _.includes(selected, ec.key))
|
||||
const { rows } = ExportVisitor.visit(data, columns)
|
||||
const sheet = xlsx.utils.json_to_sheet(rows)
|
||||
const book = xlsx.utils.book_new()
|
||||
xlsx.utils.book_append_sheet(book, sheet)
|
||||
xlsx.writeFile(book, 'Oodikone_Export.xlsx')
|
||||
}
|
||||
|
||||
const toggleSelection = key => {
|
||||
setSelected(prev => {
|
||||
const i = prev.indexOf(key)
|
||||
|
||||
if (i > -1) {
|
||||
const n = [...prev]
|
||||
n.splice(i, 1)
|
||||
return n
|
||||
}
|
||||
return [...prev, key]
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onOpen={onOpen} onClose={onClose} open={open}>
|
||||
<Modal.Header>Export to Excel</Modal.Header>
|
||||
<Modal.Content>
|
||||
<p>
|
||||
Exporting {flatData.length} rows into an Excel (.xlsx) file. Choose which columns you want to include in the
|
||||
generated field from the list below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setSelected(exportColumns.map(c => c.key))}
|
||||
disabled={selected.length === exportColumns.length}
|
||||
>
|
||||
Include all
|
||||
</Button>
|
||||
<Button onClick={() => setSelected([])} disabled={selected.length === 0}>
|
||||
Exclude all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table celled striped>
|
||||
<Table.Header>
|
||||
<Table.HeaderCell />
|
||||
<Table.HeaderCell>Column</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sample Values</Table.HeaderCell>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{exportColumns.map(column => (
|
||||
<Table.Row key={column.key} onClick={() => toggleSelection(column.key)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Cell collapsing verticalAlign="middle">
|
||||
<Checkbox checked={_.includes(selected, column.key)} />
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>{getColumnTitle(column)}</Table.Cell>
|
||||
<Table.Cell style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ width: '0' }}>
|
||||
{sampledValues[column.key].map(value => (
|
||||
<span
|
||||
style={{
|
||||
border: '1px solid #dedede',
|
||||
borderRadius: '3px',
|
||||
padding: '0px 3px',
|
||||
backgroundColor: '#f9fafb',
|
||||
marginRight: '0.25em',
|
||||
color: '#555',
|
||||
}}
|
||||
>
|
||||
{column.formatValue ? column.formatValue(value) : `${value}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button content="Cancel" onClick={() => onClose()} />
|
||||
<Button primary content="Export" onClick={() => handleExport()} />
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportModal
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
const DataItemTypeKey = Symbol('DATA_ITEM_TYPE')
|
||||
|
||||
export const DataItemType = {
|
||||
Group: 'group',
|
||||
Row: 'row',
|
||||
}
|
||||
|
||||
export const getDataItemType = item => {
|
||||
if (item[DataItemTypeKey] === DataItemType.Group) {
|
||||
return DataItemType.Group
|
||||
}
|
||||
|
||||
return DataItemType.Row
|
||||
}
|
||||
|
||||
export const group = (definition, children) => {
|
||||
return {
|
||||
definition,
|
||||
children,
|
||||
[DataItemTypeKey]: DataItemType.Group,
|
||||
}
|
||||
}
|
||||
|
||||
export const getColumnValue = (ctx, column) => {
|
||||
if (column.getRowVal) {
|
||||
return column.getRowVal(ctx.item, ctx.isGroup, ctx.parents)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class DataVisitorContext {
|
||||
constructor(visitor, item, parents = [], isGroup = false) {
|
||||
this.visitor = visitor
|
||||
this.item = item
|
||||
this.parents = parents
|
||||
this.isGroup = isGroup
|
||||
}
|
||||
|
||||
visitItem(item) {
|
||||
return this.visitor.visitItem(item, this)
|
||||
}
|
||||
|
||||
withGroup(group) {
|
||||
return new DataVisitorContext(this.visitor, group, [group, ...this.parents], true)
|
||||
}
|
||||
|
||||
withRow(row) {
|
||||
return new DataVisitorContext(this.visitor, row, this.parents, false)
|
||||
}
|
||||
}
|
||||
|
||||
export class DataVisitor {
|
||||
visitRow() {}
|
||||
|
||||
visitGroup(ctx) {
|
||||
ctx.item.children.forEach(item => ctx.visitItem(item))
|
||||
}
|
||||
|
||||
visitItem(item, pCtx) {
|
||||
let ctx = pCtx
|
||||
|
||||
if (ctx === undefined) {
|
||||
ctx = new DataVisitorContext(this, null)
|
||||
}
|
||||
|
||||
const type = getDataItemType(item)
|
||||
|
||||
if (type === DataItemType.Group) {
|
||||
return this.visitGroup(ctx.withGroup(item))
|
||||
}
|
||||
return this.visitRow(ctx.withRow(item))
|
||||
}
|
||||
|
||||
static visit(items, ...args) {
|
||||
const visitor = new this(...args)
|
||||
items.forEach(item => visitor.visitItem(item))
|
||||
return visitor
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,34 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, bool } from 'prop-types'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Radio } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
|
||||
import TSA from '../../common/tsa'
|
||||
import { toggleStudentNameVisibility } from '../../redux/settings'
|
||||
|
||||
const StudentNameVisibilityToggle = ({ showNames, toggleStudentNameVisibility: toggle }) => {
|
||||
export const useStudentNameVisibility = () => {
|
||||
const visible = useSelector(state => state.settings.namesVisible)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
dispatch(toggleStudentNameVisibility())
|
||||
}, [dispatch])
|
||||
|
||||
return { visible, toggle }
|
||||
}
|
||||
|
||||
const StudentNameVisibilityToggle = ({ style = {} }) => {
|
||||
const { visible, toggle } = useStudentNameVisibility()
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', showNames ? 'hide' : 'show')
|
||||
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', visible ? 'hide' : 'show')
|
||||
toggle()
|
||||
}, [showNames, toggle])
|
||||
}, [visible, toggle])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 15, marginBottom: 10 }}>
|
||||
<Radio
|
||||
data-cy="toggleStudentNames"
|
||||
toggle
|
||||
label="Show student names"
|
||||
checked={showNames}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div style={{ marginTop: 15, marginBottom: 10, ...style }}>
|
||||
<Radio data-cy="toggleStudentNames" toggle label="Show student names" checked={visible} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
StudentNameVisibilityToggle.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
toggleStudentNameVisibility: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showNames: state.settings.namesVisible,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { toggleStudentNameVisibility })(withRouter(StudentNameVisibilityToggle))
|
||||
export default StudentNameVisibilityToggle
|
||||
|
||||
@@ -28,13 +28,11 @@ const TagsTable = ({ student, language }) => {
|
||||
key: 'PROGRAMME',
|
||||
title: 'Programme',
|
||||
getRowVal: t => getTextIn(t.programme.name, language),
|
||||
cellProps: { collapsing: true },
|
||||
},
|
||||
{
|
||||
key: 'CODE',
|
||||
title: 'Code',
|
||||
getRowVal: t => t.programme.code,
|
||||
cellProps: { collapsing: true },
|
||||
},
|
||||
{
|
||||
key: 'TAGS',
|
||||
|
||||
@@ -182,7 +182,18 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
|
||||
},
|
||||
]
|
||||
|
||||
return <Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels(filteredStudents)} />
|
||||
return (
|
||||
<div style={{ overflow: 'hidden', flexGrow: 1, padding: '1px' }}>
|
||||
<Accordion
|
||||
activeIndex={activeIndex}
|
||||
exclusive={false}
|
||||
styled
|
||||
fluid
|
||||
panels={createPanels(filteredStudents)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGroupFilterView = props => {
|
||||
|
||||
+10
-8
@@ -38,7 +38,7 @@ const prettifyCamelCase = str => {
|
||||
}
|
||||
|
||||
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
|
||||
const cellContent = { flex: '0 1 50%' }
|
||||
const cellContent = { flexGrow: 1 }
|
||||
|
||||
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
|
||||
@@ -99,7 +99,7 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={cellContent}>
|
||||
<div style={{ ...cellContent, flexGrow: 0 }}>
|
||||
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
|
||||
Add {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
@@ -134,7 +134,7 @@ const TagCell = ({ tagName, group, studyProgrammes }) => {
|
||||
return group.tags?.[tagName] && !showEdit ? (
|
||||
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
|
||||
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
|
||||
<div style={cellContent}>
|
||||
<div style={{ ...cellContent, flexGrow: 0 }}>
|
||||
<Button type="button" onClick={() => toggleEdit()}>
|
||||
Edit {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
@@ -152,7 +152,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
const headers = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: group => getTextIn(group.name, language),
|
||||
getRowContent: group => <LinkToGroup group={group} language={language} />,
|
||||
cellProps: {
|
||||
@@ -164,7 +164,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'students',
|
||||
title: 'Students',
|
||||
getRowVal: group => group.members?.length || 0,
|
||||
getRowContent: group => group.members?.length || 0,
|
||||
cellProps: {
|
||||
@@ -176,14 +176,16 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
},
|
||||
{
|
||||
key: 'studyProgramme',
|
||||
title: 'Study programme',
|
||||
title: 'Study Programme',
|
||||
getRowVal: group => group.tags?.studyProgramme,
|
||||
formatValue: value => studyProgrammes.find(p => p.value === value)?.text,
|
||||
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedyear',
|
||||
title: 'Associated starting academic year',
|
||||
title: 'Associated Starting Academic Year',
|
||||
getRowVal: group => group.tags?.year,
|
||||
formatValue: startYearToAcademicYear,
|
||||
getRowContent: group => <TagCell tagName="year" group={group} />,
|
||||
},
|
||||
]
|
||||
@@ -200,7 +202,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
lisäominaisuuksia.{' '}
|
||||
</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
<SortableTable figure={false} columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYea
|
||||
year={year}
|
||||
data={
|
||||
showMeanTime
|
||||
? graduations?.data?.graduationMeanTime[year]
|
||||
: graduations?.data?.graduationMedianTime[year]
|
||||
? graduations?.data?.graduationMeanTime?.[year]
|
||||
: graduations?.data?.graduationMedianTime?.[year]
|
||||
}
|
||||
graduationAmount={graduations?.data?.graduationAmounts[year]}
|
||||
totalAmount={graduations?.data?.totalAmounts[year]}
|
||||
graduationAmount={graduations?.data?.graduationAmounts?.[year]}
|
||||
totalAmount={graduations?.data?.totalAmounts?.[year]}
|
||||
studyprogramme={studyprogramme}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -117,20 +117,35 @@ const StudyProgrammeSelector = ({ studyprogrammes, selected, language }) => {
|
||||
{bachelorProgrammes.length > 0 ? (
|
||||
<>
|
||||
<Header>Bachelor programmes</Header>
|
||||
<SortableTable columns={headers} getRowKey={programme => programme.code} data={bachelorProgrammes} />
|
||||
<SortableTable
|
||||
figure={false}
|
||||
columns={headers}
|
||||
getRowKey={programme => programme.code}
|
||||
data={bachelorProgrammes}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{masterProgrammes.length > 0 ? (
|
||||
<>
|
||||
<Header>Master programmes</Header>
|
||||
<SortableTable columns={headers} getRowKey={programme => programme.code} data={masterProgrammes} />
|
||||
<SortableTable
|
||||
figure={false}
|
||||
columns={headers}
|
||||
getRowKey={programme => programme.code}
|
||||
data={masterProgrammes}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{otherProgrammes.length > 0 ? (
|
||||
<>
|
||||
<Header>Doctoral programmes and old programmes</Header>
|
||||
<SortableTable columns={headers} getRowKey={programme => programme.code} data={otherProgrammes} />
|
||||
<SortableTable
|
||||
figure={false}
|
||||
columns={headers}
|
||||
getRowKey={programme => programme.code}
|
||||
data={otherProgrammes}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -13,13 +13,27 @@ import Toggle from '../Toggle'
|
||||
import InfotoolTips from '../../../common/InfoToolTips'
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, graduated, setGraduated }) => {
|
||||
const StudytrackOverview = ({
|
||||
studyprogramme,
|
||||
specialGroups,
|
||||
setSpecialGroups,
|
||||
graduated,
|
||||
setGraduated,
|
||||
combined,
|
||||
setCombined,
|
||||
}) => {
|
||||
const toolTips = InfotoolTips.Studyprogramme
|
||||
const [showMeanTime, setShowMeanTime] = useState(true)
|
||||
const [track, setTrack] = useState(studyprogramme)
|
||||
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
|
||||
const grad = graduated ? 'GRADUATED_EXCLUDED' : 'GRADUATED_INCLUDED'
|
||||
const stats = useGetStudytrackStatsQuery({ id: studyprogramme, specialGroups: special, graduated: grad })
|
||||
const yearsCombined = combined ? 'YEARS_COMBINED' : 'YEARS_SEPARATED'
|
||||
const stats = useGetStudytrackStatsQuery({
|
||||
id: studyprogramme,
|
||||
specialGroups: special,
|
||||
graduated: grad,
|
||||
yearsCombined,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!track && stats?.data?.mainStatsByTrack[studyprogramme]) {
|
||||
@@ -66,6 +80,14 @@ const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, g
|
||||
value={graduated}
|
||||
setValue={setGraduated}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="AllStudentsToggles"
|
||||
toolTips={toolTips.GraduatedToggle}
|
||||
firstLabel="Show by year"
|
||||
secondLabel="Show all years combined"
|
||||
value={combined}
|
||||
setValue={setCombined}
|
||||
/>
|
||||
</div>
|
||||
{getDivider(
|
||||
`Students of ${
|
||||
|
||||
@@ -28,6 +28,7 @@ const StudyProgramme = props => {
|
||||
const [academicYear, setAcademicYear] = useState(false)
|
||||
const [specialGroups, setSpecialGroups] = useState(false)
|
||||
const [graduated, setGraduated] = useState(false)
|
||||
const [combined, setCombined] = useState(false)
|
||||
|
||||
useTitle('Study programmes')
|
||||
|
||||
@@ -62,6 +63,8 @@ const StudyProgramme = props => {
|
||||
setSpecialGroups={setSpecialGroups}
|
||||
graduated={graduated}
|
||||
setGraduated={setGraduated}
|
||||
combined={combined}
|
||||
setCombined={setCombined}
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
.studyprogramme-overview .toggle-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Segment, Message, Button, Popup } from 'semantic-ui-react'
|
||||
import { Segment, Message } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, arrayOf, bool, shape, any, string } from 'prop-types'
|
||||
@@ -8,7 +8,6 @@ import { getTopTeachersCategories } from '../../../redux/teachersTopCategories'
|
||||
import { getTopTeachers } from '../../../redux/teachersTop'
|
||||
import TeacherStatisticsTable from '../TeacherStatisticsTable'
|
||||
import LeaderForm from './LeaderForm'
|
||||
import { callApi } from '../../../apiConnection'
|
||||
|
||||
const TeacherLeaderBoard = ({
|
||||
getTopTeachersCategories,
|
||||
@@ -22,31 +21,12 @@ const TeacherLeaderBoard = ({
|
||||
}) => {
|
||||
const [selectedyear, setSelectedyear] = useState(null)
|
||||
const [selectedcategory, setSelectedcategory] = useState(null)
|
||||
const [recalculating, setRecalculating] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getTopTeachersCategories()
|
||||
}, [])
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true)
|
||||
setRecalculating(true)
|
||||
setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
callApi('/teachers/top', 'post', { startyearcode: selectedyear, endyearcode: selectedyear + 1 }, null)
|
||||
}
|
||||
|
||||
const updateAndSubmitForm = args => {
|
||||
setRecalculating(false)
|
||||
const year = args.selectedyear || selectedyear
|
||||
const category = args.selectedcategory || selectedcategory
|
||||
getTopTeachers(year, category)
|
||||
@@ -96,24 +76,13 @@ const TeacherLeaderBoard = ({
|
||||
/>
|
||||
<Segment>
|
||||
<Message>{`Last updated: ${updated}`}</Message>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button
|
||||
disabled={recalculating}
|
||||
content="Recalculate this year"
|
||||
onClick={() => {
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
content="Recalculation started. Recalculation might take multiple minutes. Refresh page to see the results"
|
||||
on="click"
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
|
||||
<TeacherStatisticsTable statistics={statistics} onClickFn={id => history.push(`/teachers/${id}`)} />
|
||||
<TeacherStatisticsTable
|
||||
statistics={statistics}
|
||||
onClickFn={id => history.push(`/teachers/${id}`)}
|
||||
unifyOpenUniCourses={false}
|
||||
renderLink={false}
|
||||
/>
|
||||
</Segment>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Search, Segment, Icon } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, string } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
@@ -51,15 +51,34 @@ const TeacherSearch = ({ icon, teachers, onClick, setTimeout, clearTimeout, find
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', title: 'Name', getRowVal: s => s.name, headerProps: { onClick: null, sorted: null, colSpan: 2 } },
|
||||
{
|
||||
key: 'icon',
|
||||
getRowContent: () => <Icon name={icon} />,
|
||||
cellProps: { collapsing: true },
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
},
|
||||
]
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: s => s.name,
|
||||
cellProps: row => ({
|
||||
onClick: () => onClick(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
getRowContent: () => <Icon name={icon} />,
|
||||
export: false,
|
||||
cellProps: row => ({
|
||||
onClick: () => onClick(row),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onClick]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,111 +1,87 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Table, Segment, Icon, Item } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, any, string, number, func, bool } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { Segment, Icon, Item } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import SortableTable from 'components/SortableTable'
|
||||
import { getCourseStats } from '../../../redux/coursestats'
|
||||
|
||||
const calculatePassrate = (pass, fail) => (100 * pass) / (pass + fail)
|
||||
|
||||
const TeacherStatisticsTable = ({ statistics, onClickFn, getCourseStats, unifyOpenUniCourses, renderLink }) => {
|
||||
const [selected, setSelected] = useState('credits')
|
||||
const [direction, setDirection] = useState('descending')
|
||||
const fetchCourseStats = useCallback(
|
||||
id => getCourseStats({ courseCodes: [id], separate: false, unifyOpenUniCourses }, null),
|
||||
[unifyOpenUniCourses]
|
||||
)
|
||||
|
||||
const handleSort = column => () => {
|
||||
if (selected === column) {
|
||||
const newDirection = direction === 'ascending' ? 'descending' : 'ascending'
|
||||
setDirection(newDirection)
|
||||
} else {
|
||||
setSelected(column)
|
||||
setDirection('descending')
|
||||
}
|
||||
}
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'teacher',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Teacher',
|
||||
getRowVal: row => row.name,
|
||||
cellProps: row => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: () => onClickFn(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
getRowContent: row => {
|
||||
if (!renderLink) return null
|
||||
const query = `courseCodes=["${row.id}"]&separate=false&unifyOpenUniCourses=${unifyOpenUniCourses}`
|
||||
return (
|
||||
<Item as={Link} to={`/coursestatistics?${query}`} onClick={() => fetchCourseStats(row.id)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
title: 'Credits',
|
||||
getRowVal: row => row.credits,
|
||||
formatValue: value => value.toFixed(2),
|
||||
},
|
||||
{
|
||||
key: 'credits-transferred',
|
||||
title: 'Credits Transferred',
|
||||
getRowVal: row => row.transferred,
|
||||
},
|
||||
{
|
||||
key: 'passrate',
|
||||
title: 'Passed',
|
||||
getRowVal: row => parseFloat(row.passrate),
|
||||
formatValue: value => value.toFixed(2),
|
||||
},
|
||||
],
|
||||
[renderLink, unifyOpenUniCourses, fetchCourseStats]
|
||||
)
|
||||
|
||||
const sortStatistics = statistics => {
|
||||
const formatted = statistics.map(stat => ({
|
||||
...stat,
|
||||
passrate: calculatePassrate(stat.passed, stat.failed),
|
||||
}))
|
||||
const sorted = sortBy(formatted, selected)
|
||||
return direction === 'ascending' ? sorted : sorted.reverse()
|
||||
}
|
||||
|
||||
const roundStatisticCredits = statistics =>
|
||||
statistics.map(s => ({
|
||||
...s,
|
||||
credits: s.credits.toFixed(2),
|
||||
}))
|
||||
|
||||
const sortDirection = name => (selected === name ? direction : null)
|
||||
|
||||
const fetchCourseStats = id => getCourseStats({ courseCodes: [id], separate: false, unifyOpenUniCourses }, null)
|
||||
|
||||
const renderCourseStatsLink = id => {
|
||||
if (!renderLink) return null
|
||||
const query = `courseCodes=["${id}"]&separate=false&unifyOpenUniCourses=${unifyOpenUniCourses}`
|
||||
return (
|
||||
<Item as={Link} to={`/coursestatistics?${query}`} onClick={() => fetchCourseStats(id)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
const data = useMemo(
|
||||
() =>
|
||||
statistics.map(stat => ({
|
||||
...stat,
|
||||
passrate: calculatePassrate(stat.passed, stat.failed),
|
||||
})),
|
||||
[statistics]
|
||||
)
|
||||
|
||||
return statistics.length === 0 ? (
|
||||
<Segment basic content="No statistics found for the given query." />
|
||||
) : (
|
||||
<Table structured celled textAlign="center" sortable selectable className="fixed-header">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
content="Name"
|
||||
textAlign="left"
|
||||
onClick={handleSort('name')}
|
||||
sorted={sortDirection('name')}
|
||||
/>
|
||||
<Table.HeaderCell content="Credits" onClick={handleSort('credits')} sorted={sortDirection('credits')} />
|
||||
<Table.HeaderCell
|
||||
content="Credits transferred"
|
||||
onClick={handleSort('transferred')}
|
||||
sorted={sortDirection('transferred')}
|
||||
/>
|
||||
<Table.HeaderCell content="Passed" onClick={handleSort('passrate')} sorted={sortDirection('passrate')} />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{roundStatisticCredits(sortStatistics(statistics)).map(({ id, name, credits, passrate, transferred }) => (
|
||||
<Table.Row key={id} onClick={() => onClickFn(id)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Cell textAlign="left">
|
||||
{name}
|
||||
{renderCourseStatsLink(id)}
|
||||
</Table.Cell>
|
||||
<Table.Cell content={credits} width={2} />
|
||||
<Table.Cell content={transferred} width={2} />
|
||||
<Table.Cell content={`${parseFloat(passrate).toFixed(2)} %`} width={2} />
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<>
|
||||
<SortableTable title="Teacher statistics" columns={columns} data={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
TeacherStatisticsTable.propTypes = {
|
||||
statistics: arrayOf(
|
||||
shape({
|
||||
id: any,
|
||||
name: string,
|
||||
transferred: number,
|
||||
credits: any,
|
||||
failed: number,
|
||||
passed: number,
|
||||
})
|
||||
).isRequired,
|
||||
onClickFn: func.isRequired,
|
||||
getCourseStats: func.isRequired,
|
||||
unifyOpenUniCourses: bool.isRequired,
|
||||
renderLink: bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { unifyOpenUniCourses } = state.courseSearch
|
||||
return { unifyOpenUniCourses }
|
||||
|
||||
@@ -15,6 +15,9 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
return error ? null : (
|
||||
<div>
|
||||
<SortableTable
|
||||
stretch
|
||||
singleLine={false}
|
||||
figure={false}
|
||||
getRowKey={user => user.id}
|
||||
tableProps={{ celled: true, structured: true }}
|
||||
columns={[
|
||||
@@ -51,6 +54,19 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
key: 'PROGRAMMES',
|
||||
title: 'Programmes',
|
||||
getRowVal: user => {
|
||||
if (!user.elementdetails || user.elementdetails.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const nameInLanguage = code => {
|
||||
const elem = elementdetails.find(e => e.code === code)
|
||||
if (!elem) return null
|
||||
return getTextIn(elem.name, language)
|
||||
}
|
||||
|
||||
return user.elementdetails.map(element => nameInLanguage(element))
|
||||
},
|
||||
getRowContent: user => {
|
||||
const nameInLanguage = code => {
|
||||
const elem = elementdetails.find(e => e.code === code)
|
||||
if (!elem) return null
|
||||
@@ -70,6 +86,7 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
key: 'OODIACCESS',
|
||||
title: 'Has access',
|
||||
getRowVal: user => user.is_enabled,
|
||||
formatValue: value => (value ? 'Has access' : 'No access'),
|
||||
getRowContent: user => (
|
||||
<Icon
|
||||
style={{ margin: 'auto' }}
|
||||
@@ -92,6 +109,8 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
{
|
||||
key: 'SHOWAS',
|
||||
title: 'Show as user',
|
||||
filterable: false,
|
||||
sortable: false,
|
||||
getRowVal: user => (
|
||||
<Button circular size="tiny" basic icon="spy" onClick={() => showAsUser(user.username)} />
|
||||
),
|
||||
@@ -100,9 +119,17 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
{
|
||||
key: 'EDIT',
|
||||
title: '',
|
||||
filterable: false,
|
||||
sortable: false,
|
||||
getRowVal: user => (
|
||||
<Button.Group compact widths={2}>
|
||||
<Button basic size="mini" as={Link} to={`users/${user.id}`}>
|
||||
<Button
|
||||
basic
|
||||
size="mini"
|
||||
as={Link}
|
||||
to={`users/${user.id}`}
|
||||
data-cy={`user-edit-button-${user.username}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Button.Group>
|
||||
|
||||
@@ -15,8 +15,8 @@ const studyprogrammeApi = RTKApi.injectEndpoints({
|
||||
`/v2/studyprogrammes/${id}/graduationstats?year_type=${yearType}&special_groups=${specialGroups}`,
|
||||
}),
|
||||
getStudytrackStats: builder.query({
|
||||
query: ({ id, graduated, specialGroups }) =>
|
||||
`/v2/studyprogrammes/${id}/studytrackstats?graduated=${graduated}&special_groups=${specialGroups}`,
|
||||
query: ({ id, graduated, specialGroups, yearsCombined }) =>
|
||||
`/v2/studyprogrammes/${id}/studytrackstats?graduated=${graduated}&special_groups=${specialGroups}&years_combined=${yearsCombined}`,
|
||||
}),
|
||||
updateBasicView: builder.query({
|
||||
query: ({ id }) => `/v2/studyprogrammes/${id}/update_basicview`,
|
||||
|
||||
+6
-6
@@ -1250,9 +1250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
|
||||
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==",
|
||||
"version": "1.14.8",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
|
||||
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9371,9 +9371,9 @@
|
||||
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q=="
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
|
||||
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw=="
|
||||
"version": "1.14.8",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
|
||||
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
|
||||
@@ -2,7 +2,7 @@ const { chunk } = require('lodash')
|
||||
const { eachLimit } = require('async')
|
||||
const { dbConnections } = require('./connection')
|
||||
const { logger } = require('../utils/logger')
|
||||
const { getLatestSnapshot, isActive } = require('../utils')
|
||||
const { getLatestSnapshot, isActive, getActiveSnapshot } = require('../utils')
|
||||
|
||||
const selectFromByIds = async (table, ids, col = 'id') => dbConnections.knex(table).whereIn(col, ids)
|
||||
|
||||
@@ -39,6 +39,17 @@ const selectFromSnapshotsByIds = async (table, ids, col = 'id') =>
|
||||
.filter(s => !!s)
|
||||
.filter(isActive)
|
||||
|
||||
const selectFromActiveSnapshotsByIds = async (table, ids, col = 'id') =>
|
||||
(
|
||||
await dbConnections.knex
|
||||
.select(dbConnections.knex.raw(`array_agg(to_json(${table}.*)) as data`))
|
||||
.from(table)
|
||||
.whereIn(col, ids)
|
||||
.groupBy('id')
|
||||
)
|
||||
.map(({ data }) => getActiveSnapshot(data))
|
||||
.filter(s => !!s)
|
||||
|
||||
const getColumnsToUpdate = (model, keys) => Object.keys(model.rawAttributes).filter(a => !keys.includes(a))
|
||||
|
||||
const bulkCreate = async (model, entities, transaction = null, properties = ['id']) => {
|
||||
@@ -70,4 +81,5 @@ module.exports = {
|
||||
selectColumnsFrom,
|
||||
selectWithoutNull,
|
||||
getCourseUnitsByCodes,
|
||||
selectFromActiveSnapshotsByIds,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const { STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable('enrollment', {
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: STRING,
|
||||
},
|
||||
studentnumber: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'student',
|
||||
key: 'studentnumber',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
semestercode: {
|
||||
type: INTEGER,
|
||||
},
|
||||
course_code: {
|
||||
type: STRING,
|
||||
},
|
||||
state: {
|
||||
type: STRING,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
type: DATE,
|
||||
},
|
||||
course_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'course',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
semester_composite: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'semesters',
|
||||
key: 'composite',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE,
|
||||
},
|
||||
is_open: {
|
||||
type: BOOLEAN,
|
||||
},
|
||||
})
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
const { Model, DATE, INTEGER, STRING, BOOLEAN } = require('sequelize')
|
||||
const { dbConnections } = require('../connection')
|
||||
|
||||
class Enrollment extends Model {}
|
||||
|
||||
Enrollment.init(
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: STRING,
|
||||
},
|
||||
studentnumber: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'student',
|
||||
key: 'studentnumber',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
course_code: {
|
||||
type: STRING,
|
||||
},
|
||||
state: {
|
||||
type: STRING,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
type: DATE,
|
||||
},
|
||||
course_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'course',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
semester_composite: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'semesters',
|
||||
key: 'composite',
|
||||
},
|
||||
onUpdate: 'cascade',
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
semestercode: {
|
||||
type: INTEGER,
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE,
|
||||
},
|
||||
is_open: {
|
||||
type: BOOLEAN,
|
||||
},
|
||||
},
|
||||
{
|
||||
underscored: false,
|
||||
sequelize: dbConnections.sequelize,
|
||||
modelName: 'enrollment',
|
||||
tableName: 'enrollment',
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = Enrollment
|
||||
@@ -16,6 +16,7 @@ const StudyrightElement = require('./studyrightElement')
|
||||
const Transfer = require('./transfer')
|
||||
const ProgrammeModule = require('./programmeModule')
|
||||
const ProgrammeModuleChild = require('./programmeModuleChild')
|
||||
const Enrollment = require('./enrollment')
|
||||
|
||||
CourseType.hasMany(Course, { foreignKey: 'coursetypecode', sourceKey: 'coursetypecode' })
|
||||
Course.belongsTo(CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
@@ -101,6 +102,9 @@ ProgrammeModule.belongsToMany(ProgrammeModule, {
|
||||
|
||||
Organization.hasMany(Organization, { foreignKey: 'parent_id', as: 'children' })
|
||||
|
||||
Enrollment.belongsTo(Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
Student.hasMany(Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
|
||||
module.exports = {
|
||||
Organization,
|
||||
Course,
|
||||
@@ -120,4 +124,5 @@ module.exports = {
|
||||
Transfer,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Enrollment,
|
||||
}
|
||||
|
||||
@@ -330,6 +330,33 @@ const mapStudyrightExtent = educationType => ({
|
||||
name: educationType.name,
|
||||
})
|
||||
|
||||
const enrollmentMapper =
|
||||
(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
realisationIdToActivityPeriod,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
) =>
|
||||
enrollment => {
|
||||
const { startDate } = realisationIdToActivityPeriod[enrollment.course_unit_realisation_id] || {}
|
||||
const targetSemester = getSemesterByDate(new Date(startDate || enrollment.enrolment_date_time))
|
||||
const course_id = courseUnitIdToCourseGroupId[enrollment.course_unit_id]
|
||||
return {
|
||||
id: enrollment.id,
|
||||
studentnumber: personIdToStudentNumber[enrollment.person_id],
|
||||
state: enrollment.state,
|
||||
course_code: courseGroupIdToCourseCode[course_id],
|
||||
semestercode: targetSemester.semestercode,
|
||||
semester_composite: targetSemester.composite,
|
||||
enrollment_date_time: enrollment.enrolment_date_time,
|
||||
is_open:
|
||||
studyRightIdToEducationType[enrollment.study_right_id] ===
|
||||
'urn:code:education-type:non-degree-education:open-university-studies',
|
||||
course_id,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
studentMapper,
|
||||
mapTeacher,
|
||||
@@ -340,4 +367,5 @@ module.exports = {
|
||||
mapCourseType,
|
||||
mapSemester,
|
||||
mapStudyrightExtent,
|
||||
enrollmentMapper,
|
||||
}
|
||||
|
||||
@@ -9,10 +9,24 @@ const {
|
||||
CreditTeacher,
|
||||
Transfer,
|
||||
CourseProvider,
|
||||
Enrollment,
|
||||
} = require('../../db/models')
|
||||
const { selectFromByIds, selectFromSnapshotsByIds, bulkCreate, getCourseUnitsByCodes } = require('../../db')
|
||||
const {
|
||||
selectFromByIds,
|
||||
selectFromSnapshotsByIds,
|
||||
bulkCreate,
|
||||
getCourseUnitsByCodes,
|
||||
selectFromActiveSnapshotsByIds,
|
||||
} = require('../../db')
|
||||
const { getEducation, getUniOrgId, getEducationType, loadMapsIfNeeded } = require('../shared')
|
||||
const { studentMapper, mapTeacher, creditMapper, semesterEnrollmentMapper, courseProviderMapper } = require('../mapper')
|
||||
const {
|
||||
studentMapper,
|
||||
mapTeacher,
|
||||
creditMapper,
|
||||
semesterEnrollmentMapper,
|
||||
courseProviderMapper,
|
||||
enrollmentMapper,
|
||||
} = require('../mapper')
|
||||
const { dbConnections } = require('../../db/connection')
|
||||
const { isBaMa } = require('../../utils')
|
||||
const { updateStudyRights, updateStudyRightElements, updateElementDetails } = require('./studyRightUpdaters')
|
||||
@@ -158,13 +172,15 @@ const parseTransfers = async (groupedStudyRightSnapshots, moduleGroupIdToCode, p
|
||||
const updateStudents = async personIds => {
|
||||
await loadMapsIfNeeded()
|
||||
|
||||
const [students, studyrightSnapshots, attainments, termRegistrations, studyRightPrimalities] = await Promise.all([
|
||||
selectFromByIds('persons', personIds),
|
||||
selectFromByIds('studyrights', personIds, 'person_id'),
|
||||
selectFromByIds('attainments', personIds, 'person_id'),
|
||||
selectFromByIds('term_registrations', personIds, 'student_id'),
|
||||
selectFromByIds('study_right_primalities', personIds, 'student_id'),
|
||||
])
|
||||
const [students, studyrightSnapshots, attainments, termRegistrations, studyRightPrimalities, enrollments] =
|
||||
await Promise.all([
|
||||
selectFromByIds('persons', personIds),
|
||||
selectFromByIds('studyrights', personIds, 'person_id'),
|
||||
selectFromByIds('attainments', personIds, 'person_id'),
|
||||
selectFromByIds('term_registrations', personIds, 'student_id'),
|
||||
selectFromByIds('study_right_primalities', personIds, 'student_id'),
|
||||
selectFromByIds('enrolments', personIds, 'person_id'),
|
||||
])
|
||||
|
||||
const parsedStudyrightSnapshots = parseStudyrightSnapshots(studyrightSnapshots)
|
||||
|
||||
@@ -203,10 +219,72 @@ const updateStudents = async personIds => {
|
||||
),
|
||||
updateAttainments(attainments, personIdToStudentNumber, attainmentsToBeExluced),
|
||||
updateTermRegistrations(termRegistrations, personIdToStudentNumber),
|
||||
updateEnrollments(enrollments, personIdToStudentNumber),
|
||||
await bulkCreate(Transfer, mappedTransfers, null, ['studyrightid']),
|
||||
])
|
||||
}
|
||||
|
||||
const updateEnrollments = async (enrollments, personIdToStudentNumber) => {
|
||||
const [courseUnitRealisations, courseUnits, studyRights] = await Promise.all([
|
||||
selectFromByIds(
|
||||
'course_unit_realisations',
|
||||
enrollments.map(({ course_unit_realisation_id }) => course_unit_realisation_id).filter(id => !!id)
|
||||
),
|
||||
selectFromByIds(
|
||||
'course_units',
|
||||
enrollments.map(({ course_unit_id }) => course_unit_id).filter(id => !!id)
|
||||
),
|
||||
selectFromActiveSnapshotsByIds(
|
||||
'studyrights',
|
||||
enrollments.map(({ study_right_id }) => study_right_id).filter(id => !!id)
|
||||
),
|
||||
])
|
||||
|
||||
const realisationIdToActivityPeriod = courseUnitRealisations.reduce((res, cur) => {
|
||||
res[cur.id] = cur.activity_period
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const courseUnitIdToCourseGroupId = courseUnits.reduce((res, curr) => {
|
||||
res[curr.id] = curr.group_id
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const sisDbCoursesForStudentAttainments = await Course.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: Object.values(courseUnitIdToCourseGroupId),
|
||||
},
|
||||
},
|
||||
})
|
||||
const courseGroupIdToCourseCode = sisDbCoursesForStudentAttainments.reduce((res, curr) => {
|
||||
res[curr.id] = curr.code
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const educations = await selectFromByIds(
|
||||
'educations',
|
||||
studyRights.map(({ education_id }) => education_id).filter(id => !!id)
|
||||
)
|
||||
|
||||
const studyRightIdToEducationType = studyRights.reduce((res, curr) => {
|
||||
const education = educations.find(e => e.id === curr.education_id)
|
||||
res[curr.id] = education ? education.education_type : null
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const mapEnrollment = enrollmentMapper(
|
||||
personIdToStudentNumber,
|
||||
courseUnitIdToCourseGroupId,
|
||||
realisationIdToActivityPeriod,
|
||||
courseGroupIdToCourseCode,
|
||||
studyRightIdToEducationType
|
||||
)
|
||||
|
||||
const mappedEnrollments = enrollments.filter(({ document_state }) => document_state === 'ACTIVE').map(mapEnrollment)
|
||||
await bulkCreate(Enrollment, mappedEnrollments)
|
||||
}
|
||||
|
||||
const updateAttainments = async (attainments, personIdToStudentNumber, attainmentsToBeExluced) => {
|
||||
await updateTeachers(attainments)
|
||||
const [courseUnits, modules, studyrights] = await Promise.all([
|
||||
|
||||
@@ -7,6 +7,14 @@ const getLatestSnapshot = entities => {
|
||||
)
|
||||
}
|
||||
|
||||
const getActiveSnapshot = entities => {
|
||||
const now = new Date()
|
||||
return maxBy(
|
||||
entities.filter(e => e.document_state === 'ACTIVE').filter(e => now >= new Date(e.snapshot_date_time)),
|
||||
e => new Date(e.snapshot_date_time)
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = entity => {
|
||||
return entity.document_state === 'ACTIVE'
|
||||
}
|
||||
@@ -44,4 +52,5 @@ module.exports = {
|
||||
isActive,
|
||||
getMinMaxDate,
|
||||
isBaMa,
|
||||
getActiveSnapshot,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user