mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0d152fb7 | ||
|
|
2272890cb4 | ||
|
|
ba8471df88 | ||
|
|
575a580120 | ||
|
|
0a07dedede | ||
|
|
ed0f700fc2 | ||
|
|
616cc60b39 | ||
|
|
68b730a747 | ||
|
|
b541857980 | ||
|
|
2b3e436979 | ||
|
|
ba1224c983 | ||
|
|
aeab65358d | ||
|
|
e865d12676 | ||
|
|
96760bd0fb | ||
|
|
eb7b91a959 | ||
|
|
e07677ecaa | ||
|
|
d1a729804e | ||
|
|
bb1a43ac87 | ||
|
|
d493ac53a5 | ||
|
|
0695e031bf | ||
|
|
f799420b42 | ||
|
|
23a5ee7b08 | ||
|
|
71b5a87ef1 | ||
|
|
e0b15e457e | ||
|
|
addacd63c9 | ||
|
|
fc8c36392e | ||
|
|
1dc2c942f3 | ||
|
|
cd3ccbf5fb | ||
|
|
341b04b01a | ||
|
|
f826f5b93c | ||
|
|
137d48a483 | ||
|
|
89f31d5cdf | ||
|
|
1a8ccde9f3 | ||
|
|
96a185a59e | ||
|
|
678cd070ec | ||
|
|
a606117807 | ||
|
|
db9543edd8 | ||
|
|
eeeeb1c671 | ||
|
|
16b18bb92b | ||
|
|
2c2f98599e | ||
|
|
1f0b91a612 | ||
|
|
e3151f67d1 | ||
|
|
59ab60fc5e | ||
|
|
25233fd13e | ||
|
|
a1e0560faa | ||
|
|
7c5c785a6e | ||
|
|
61a4073d82 | ||
|
|
d19ef59fc3 | ||
|
|
986a12c27b | ||
|
|
892a366984 | ||
|
|
d87d611db7 | ||
|
|
f5ae6d42e9 | ||
|
|
40af5f0f99 | ||
|
|
e139f4cb4e | ||
|
|
71d993246b | ||
|
|
a3067c6b23 | ||
|
|
01841e104e | ||
|
|
1ae4c0b39e | ||
|
|
32e4a22c00 | ||
|
|
013da38a6b | ||
|
|
b2b2d87ff3 | ||
|
|
7e22fcd856 | ||
|
|
329466dde9 | ||
|
|
a7ebc4fb99 | ||
|
|
6e7ee36db1 | ||
|
|
ca8dc45ceb | ||
|
|
9998f2600d | ||
|
|
1f370ea9c5 | ||
|
|
f319e3e2a0 | ||
|
|
01e701b770 | ||
|
|
4542f4179d | ||
|
|
288ff2bada | ||
|
|
dce0642f08 | ||
|
|
c0a1cf99f2 | ||
|
|
f4f1ed6818 | ||
|
|
e0a7ed139f | ||
|
|
a5fe61a654 | ||
|
|
d02dff573c | ||
|
|
56faf46a1a | ||
|
|
9e09168b01 | ||
|
|
9d474ca61e | ||
|
|
9c39f00ec3 | ||
|
|
45d3178193 | ||
|
|
ea588e2997 | ||
|
|
563e39f6b5 | ||
|
|
a46a9c773d | ||
|
|
6227b5fd81 | ||
|
|
653fe445a8 | ||
|
|
cf2931c182 | ||
|
|
012e863569 | ||
|
|
21207adc94 | ||
|
|
47193678d9 | ||
|
|
021ee51ff2 | ||
|
|
51779add4f | ||
|
|
984d95c236 | ||
|
|
81b0516b37 | ||
|
|
c734cf0dce | ||
|
|
bbb7c6b059 | ||
|
|
db801b76b6 | ||
|
|
9e8a92366e | ||
|
|
e83acc14b3 | ||
|
|
5aea071132 | ||
|
|
a0ac892069 | ||
|
|
bd56d13bd8 | ||
|
|
22c62db449 |
@@ -22,7 +22,7 @@ trap cleanup SIGINT SIGTERM ERR EXIT
|
||||
cleanup() {
|
||||
trap - SIGINT SIGTERM ERR EXIT
|
||||
warningmsg "Trying to run docker-compose down and remove orphans"
|
||||
docker-compose down --remove-orphans
|
||||
docker-compose --profile oodikone --profile updater down --remove-orphans
|
||||
}
|
||||
|
||||
# === CLI ===
|
||||
|
||||
@@ -55,11 +55,14 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
[2020, 4511, 4511, 0, 0, 167],
|
||||
[2019, 7097, 7097, 0, 0, 349],
|
||||
[2018, 5742, 5742, 0, 0, 272],
|
||||
[2017, 1910, 1910, 0, 0, 388],
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2021, 0, 0, 0, 0, 0],
|
||||
[2020, 4511, 4511, 0, 0, 406],
|
||||
[2019, 7117, 7097, 0, 0, 434],
|
||||
[2018, 5742, 5737, 0, 0, 150],
|
||||
[2017, 1882, 1882, 0, 0, 186],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
@@ -96,10 +99,12 @@ describe('Studyprogramme overview', () => {
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
|
||||
const creditTableContents = [
|
||||
[2020, 4511, 4511, 0, 0, 167],
|
||||
[2019, 7097, 7097, 0, 0, 349],
|
||||
[2018, 5742, 5742, 0, 0, 272],
|
||||
[2017, 1910, 1910, 0, 0, 388],
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2021, 0, 0, 0, 0, 0],
|
||||
[2020, 4511, 4511, 0, 0, 406],
|
||||
[2019, 7117, 7097, 0, 0, 434],
|
||||
[2018, 5742, 5737, 0, 0, 150],
|
||||
[2017, 1882, 1882, 0, 0, 186],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
@@ -134,11 +139,12 @@ describe('Studyprogramme overview', () => {
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
|
||||
const creditTableContents = [
|
||||
// Credit table doesnt show (later) years without data
|
||||
['2020 - 2021', 352, 352, 0, 0, 60],
|
||||
['2019 - 2020', 6966, 6966, 0, 0, 193],
|
||||
['2018 - 2019', 7777, 7777, 0, 0, 328],
|
||||
['2017 - 2018', 4165, 4165, 0, 0, 344],
|
||||
...years.map(year => [year, 0, 0]),
|
||||
['2021 - 2022', 0, 0, 0, 0, 0],
|
||||
['2020 - 2021', 352, 352, 0, 0, 296],
|
||||
['2019 - 2020', 6966, 6966, 0, 0, 430],
|
||||
['2018 - 2019', 7797, 7777, 0, 0, 259],
|
||||
['2017 - 2018', 4137, 4132, 0, 0, 191],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
@@ -154,8 +160,8 @@ describe('Studyprogramme overview', () => {
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyprogramme')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 388)
|
||||
.should('contain', 7097)
|
||||
.should('contain', 434)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
.should('contain', 'Graduated students')
|
||||
|
||||
@@ -204,7 +204,6 @@ set_up_oodikone_from_scratch() {
|
||||
infomsg "Cleaning up docker setup and node_modules from possible previous installations"
|
||||
local compose_down_args="--remove-orphans --volumes --rmi all"
|
||||
"$PROJECT_ROOT"/run.sh both down "$compose_down_args" || infomsg "Cleaning errored, but will continue"
|
||||
docker-compose --file "$PROJECT_ROOT"/docker-compose.test.yml "$compose_down_args" || infomsg "Cleaning errored, but will continue"
|
||||
docker-compose --file "$PROJECT_ROOT"/docker-compose.ci.yml "$compose_down_args" || infomsg "Cleaning errored, but will continue"
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
|
||||
@@ -13,6 +13,7 @@ const { getAssociations } = require('./services/studyrights')
|
||||
const { redisClient } = require('./services/redis')
|
||||
const { computeLanguageCenterData, LANGUAGE_CENTER_REDIS_KEY } = require('./services/languageCenterData')
|
||||
const { jobMaker } = require('./worker/queue')
|
||||
const { deleteOutdatedUsers } = require('./services/userService')
|
||||
|
||||
const schedule = (cronTime, func) => new CronJob({ cronTime, onTick: func, start: true, timeZone: 'Europe/Helsinki' })
|
||||
|
||||
@@ -109,6 +110,11 @@ const startCron = () => {
|
||||
logger.info('Running daily jobs from cron')
|
||||
dailyJobs()
|
||||
})
|
||||
schedule('0 4 * * 3', async () => {
|
||||
logger.info("Deleting users who haven't logged in for 18 months")
|
||||
const numberOfDeletedUsers = await deleteOutdatedUsers()
|
||||
logger.info(`Deleted ${numberOfDeletedUsers} users.`)
|
||||
})
|
||||
schedule('0 19 * * 1', async () => {
|
||||
logger.info('Updating students whose studyplans have not been updated recently')
|
||||
jobMaker.studyplansUpdate(4)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const morgan = require('morgan')
|
||||
const logger = require('../util/logger')
|
||||
const _ = require('lodash')
|
||||
const { getFullStudyProgrammeRights } = require('../util/utils')
|
||||
|
||||
// So this appears to be a hack to get neatly formatted stats like response-time etc. from morgan
|
||||
// without actually using morgan what it's used for (LOGGING REQUESTS!).
|
||||
@@ -24,8 +25,10 @@ const accessLogger = morgan((tokens, req, res) => {
|
||||
tokens['url'](req, res),
|
||||
].join(' ')
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(user.programmeRights)
|
||||
|
||||
const usingIamRights = user.iamRights.some(programmeCode => tokens['url'](req, res).includes(programmeCode))
|
||||
const onlyIamRights = !user.isAdmin && user.rights.length === 0
|
||||
const onlyIamRights = !user.isAdmin && fullStudyProgrammeRights.length === 0
|
||||
|
||||
logger.info(message, {
|
||||
// don't log student list which might be huge
|
||||
|
||||
@@ -41,7 +41,7 @@ const currentUserMiddleware = async (req, _res, next) => {
|
||||
|
||||
if (!hasRequiredIamGroup(iamGroups, iamRights)) {
|
||||
logger.error({ message: 'User does not have required iam group', meta: { iamGroups, iamRights } })
|
||||
throw new ApplicationError('User does not have required iam group', 403, { logoutUrl })
|
||||
throw new ApplicationError(`User '${username}' does not have required iam group`, 403, { logoutUrl })
|
||||
}
|
||||
|
||||
let user = await getUser({
|
||||
@@ -52,6 +52,7 @@ const currentUserMiddleware = async (req, _res, next) => {
|
||||
iamRights,
|
||||
specialGroup,
|
||||
sisId,
|
||||
access,
|
||||
})
|
||||
|
||||
if (showAsUser && user.isAdmin) {
|
||||
|
||||
@@ -39,27 +39,6 @@ const AccessGroup = sequelizeUser.define('access_group', {
|
||||
},
|
||||
})
|
||||
|
||||
const UserFaculties = sequelizeUser.define('user_faculties', {
|
||||
userId: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.BIGINT,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
faculty_code: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
})
|
||||
|
||||
const User = sequelizeUser.define(
|
||||
'users',
|
||||
{
|
||||
@@ -98,12 +77,9 @@ User.belongsToMany(AccessGroup, {
|
||||
})
|
||||
AccessGroup.belongsToMany(User, { through: 'user_accessgroup' })
|
||||
|
||||
User.hasMany(UserFaculties, { as: 'faculty', foreignKey: 'userId' })
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
UserElementDetails,
|
||||
AccessGroup,
|
||||
UserFaculties,
|
||||
sequelizeUser,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-console */
|
||||
const { getCreditStatsForRapodiff } = require('../services/providerCredits')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
const { parseCsv } = require('./helpers')
|
||||
const _ = require('lodash')
|
||||
|
||||
@@ -15,33 +15,34 @@ const diff = (rapoData, okData, code) => {
|
||||
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
|
||||
continue
|
||||
}
|
||||
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'other']) {
|
||||
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'transferred', 'other']) {
|
||||
const okValue = Math.round(ok[field] || 0)
|
||||
const rapoValue = Math.round(rapo[field] || 0)
|
||||
const diff = Math.abs(okValue - rapoValue)
|
||||
const percentage =
|
||||
rapoValue !== 0 || okValue !== 0 ? Number(((diff / Math.max(okValue, rapoValue)) * 100).toFixed(2)) : 0
|
||||
const diffStr = `${field.padEnd(10, ' ')} ${year} ${code.padEnd(
|
||||
10,
|
||||
' '
|
||||
)} Difference: ${diff} Rapo: ${rapoValue} Oodikone: ${okValue}`
|
||||
diffs.push({ field, diff, okValue, rapoValue, code, year, diffStr })
|
||||
)} Difference: ${diff} Rapo: ${rapoValue} Oodikone: ${okValue} Percentage: ${percentage} %`
|
||||
diffs.push({ field, diff, okValue, rapoValue, code, year, diffStr, percentage })
|
||||
}
|
||||
}
|
||||
}
|
||||
let counter = 0
|
||||
// Acual logic in this function
|
||||
const process = async data => {
|
||||
// Define here the fields to diff against each other. Must be an array, multiple fields will be summed
|
||||
const rapoProgrammeData = formatData(data.slice(1))
|
||||
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
|
||||
for (const programmeCode of allProgrammeCodes) {
|
||||
const okProgrammeData = await getCreditStatsForRapodiff(programmeCode, '', 'CALENDAR_YEAR', 'SPECIAL_INCLUDED')
|
||||
const okProgrammeData = await computeCreditsProduced(programmeCode, false, true)
|
||||
counter++
|
||||
if (counter % 5 === 0) {
|
||||
console.log(`Done ${Math.round((counter / allProgrammeCodes.length) * 100)} %`)
|
||||
}
|
||||
diff(rapoProgrammeData[programmeCode], okProgrammeData, programmeCode)
|
||||
diff(rapoProgrammeData[programmeCode], okProgrammeData.stats, programmeCode)
|
||||
}
|
||||
const orderedDiffs = _.orderBy(diffs, 'diff', 'asc')
|
||||
const fieldToSortBy = 'diff' // 'percentage'
|
||||
const orderedDiffs = _.orderBy(diffs, fieldToSortBy, 'asc')
|
||||
orderedDiffs.forEach(d => console.log(d.diffStr))
|
||||
console.log(`${diffs.length} diffs found with current settings. Numbers with no diff: ${noDiffCounter}`)
|
||||
const totalDiff = orderedDiffs.reduce((sum, cur) => cur.diff + sum, 0)
|
||||
@@ -54,43 +55,99 @@ const programmeCreditsDiff = async fileName => {
|
||||
console.log('Diff completed.')
|
||||
}
|
||||
|
||||
const processIds = async (rawData, code) => {
|
||||
const data = rawData.slice(1)
|
||||
console.log('amount of rapo credits: ', data.length)
|
||||
/*
|
||||
separate
|
||||
Suoritus on erillisellä opiskeluoikeudella opettajankoulutuksen opintoja suorittavan opiskelijan suorittama
|
||||
Suoritus on erillisellä opiskeluoikeudella opiskelevan suorittama
|
||||
|
||||
agreement
|
||||
Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama
|
||||
|
||||
|
||||
open-uni
|
||||
Suoritus on avoimessa korkeakouluopetuksessa suoritettu
|
||||
|
||||
incoming-exchange
|
||||
Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama
|
||||
|
||||
basic
|
||||
perustutkinto-opiskelija = 1
|
||||
ja
|
||||
Tuntematon
|
||||
tai (ehkä):
|
||||
Suoritus on kotimainen harjoittelujakso
|
||||
?? Opintosuoritus on täydennyskoulutuksessa suoritettu ??
|
||||
|
||||
other
|
||||
perustutkinto-opiskelija = 0 && tuntematon
|
||||
|
||||
*/
|
||||
|
||||
const getCategory = (typeName, isBasic, hyvaksiluettu) => {
|
||||
if (hyvaksiluettu) return 'transferred' // ?? unsure
|
||||
if (isBasic || ['Suoritus on kotimainen harjoittelujakso', 'Tuntematon'].includes(typeName)) return 'basic'
|
||||
if (typeName === 'Suoritus on avoimessa korkeakouluopetuksessa suoritettu') return 'open-uni'
|
||||
if (typeName === 'Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama') return 'incoming-exchange'
|
||||
if (typeName.startsWith('Suoritus on erillisellä opiskeluoikeudella')) return 'separate'
|
||||
if (typeName === 'Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama')
|
||||
return 'agreement'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const processIds = async (rawData, code, field) => {
|
||||
const data = rawData.slice(0, 21628) // 11476 = KTM viimeinen
|
||||
const rapoData = data
|
||||
.map(row => ({
|
||||
id: row[4],
|
||||
credits: row[5],
|
||||
type: row[6],
|
||||
incl: row[7],
|
||||
id: row[0].slice(11),
|
||||
type: getCategory(row[1], row[3] === '1', row[6] === '1'),
|
||||
typeCode: row[8],
|
||||
isModule: row[9] !== '0',
|
||||
credits: parseFloat(row[10]),
|
||||
included: row[4] === '1',
|
||||
}))
|
||||
.filter(row => row.type === '2' && row.incl === '0')
|
||||
const rapoIds = rapoData.map(row => row.id)
|
||||
const stats = await getCreditStatsForRapodiff(code)
|
||||
if (!stats.ids) {
|
||||
console.log(stats)
|
||||
console.log('Edit getCreditStats code so that it saves a list of the relevant ids you want to compare.')
|
||||
.filter(c => !c.isModule && (field === 'transferred' || c.included))
|
||||
const rapoIds = rapoData.filter(c => c.credits > 0 && c.type === field).map(row => row.id)
|
||||
const rapoCredits = rapoData.reduce(
|
||||
(stats, cur) => {
|
||||
stats[cur.type] += cur.credits
|
||||
return stats
|
||||
},
|
||||
{
|
||||
transferred: 0,
|
||||
total: '_',
|
||||
basic: 0,
|
||||
'open-uni': 0,
|
||||
separate: 0,
|
||||
'incoming-exchange': 0,
|
||||
agreement: 0,
|
||||
other: 0,
|
||||
}
|
||||
)
|
||||
const stats = await computeCreditsProduced(code, false, true, field)
|
||||
if (!stats.stats.ids) {
|
||||
// console.log(stats)
|
||||
console.log({ rapoCredits })
|
||||
console.log('Edit getCreditsProduced code so that it saves a list of the relevant ids you want to compare.')
|
||||
return
|
||||
}
|
||||
const okIds = stats.ids
|
||||
const notInOk = rapoIds.filter(id => id && !okIds[id.slice(11)]).map(str => str.slice(11))
|
||||
const okIdMap = stats.stats.ids
|
||||
const rapoIdMap = rapoIds.reduce((obj, cur) => {
|
||||
obj[cur] = true
|
||||
return obj
|
||||
}, {})
|
||||
const notInRapo = Object.keys(okIds).filter(id => !rapoIdMap[`ATTAINMENT-${id}`])
|
||||
console.log('\n In rapo, but not in OK: ', notInOk.length)
|
||||
console.dir(notInOk)
|
||||
console.log('\n In OK, but not in Rapo: ', notInRapo.length)
|
||||
console.dir(notInRapo)
|
||||
const onlyInRapo = Object.keys(rapoIdMap).filter(id => !okIdMap[id])
|
||||
const onlyInOk = Object.keys(okIdMap).filter(id => !rapoIdMap[id])
|
||||
console.log('\n onlyInRapo: ', onlyInRapo.length)
|
||||
console.dir(onlyInRapo.slice(0, 30))
|
||||
console.log('\n onlyInOk: ', onlyInOk.length)
|
||||
console.dir(onlyInOk.slice(0, 30))
|
||||
delete stats.ids
|
||||
console.log(stats['2022'])
|
||||
const rapoCredits = rapoData.reduce((sum, cur) => parseInt(sum, 10) + parseInt(cur.credits, 10), 0)
|
||||
console.log(stats.stats['2022'])
|
||||
console.log('rapoCredits: ', rapoCredits)
|
||||
}
|
||||
|
||||
const testNewCalc = async code => {
|
||||
await parseCsv('credits.csv', async data => processIds(data, code))
|
||||
const testNewCalc = async (fileName, code, field) => {
|
||||
await parseCsv(fileName, async data => processIds(data, code, field))
|
||||
}
|
||||
|
||||
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
|
||||
@@ -112,8 +169,7 @@ const formatData = data =>
|
||||
'open-uni': parseNum(openUni),
|
||||
separate: parseNum(special),
|
||||
total: parseNum(total),
|
||||
abroad: parseNum(abroad),
|
||||
other: parseNum(other),
|
||||
transferred: parseNum(abroad) + parseNum(other),
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
@@ -10,7 +10,7 @@ const { programmeCreditsDiff, testNewCalc } = require('./creditsDiff')
|
||||
const modes = {
|
||||
credits: () => programmeCreditsDiff('data.csv'),
|
||||
// Check credits with current local oodikone data against provided data.csv in rapodiff folder. data.csv can be downloaded from rapo by selecting all faculties, then from the bottom of the page clicking Export > data > csv.
|
||||
check: code => testNewCalc(code),
|
||||
check: (fileName, code, field) => testNewCalc(fileName, code, field),
|
||||
// Provide programme code as argument. Parses credits from credits.csv in rapodiff folder. This must be obtained from Rapo people. This tells which credit ids are found in oodikone but not rapo, and vice versa.
|
||||
// notice: check mode requires writing ids in to the stats object in credits calculating function.
|
||||
// Add something like this there:
|
||||
@@ -22,6 +22,8 @@ const main = async () => {
|
||||
const mode = process.argv[2]
|
||||
const modeFunction = modes[mode]
|
||||
const arg = process.argv[3]
|
||||
const arg2 = process.argv[4]
|
||||
const arg3 = process.argv[5]
|
||||
if (!modeFunction) {
|
||||
console.log(
|
||||
'Unknown or missing mode. Supply the mode as an argument. Supported modes:\n\n',
|
||||
@@ -29,7 +31,7 @@ const main = async () => {
|
||||
)
|
||||
return
|
||||
}
|
||||
await modeFunction(arg)
|
||||
await modeFunction(arg, arg2, arg3)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0))
|
||||
|
||||
@@ -3,6 +3,7 @@ const crypto = require('crypto')
|
||||
const courseService = require('../services/courses')
|
||||
const { faculties } = require('../services/organisations')
|
||||
const { validateParamLength } = require('../util')
|
||||
const { getFullStudyProgrammeRights } = require('../util/utils')
|
||||
|
||||
router.get('/v2/coursesmulti', async (req, res) => {
|
||||
let results = { courses: [] }
|
||||
@@ -34,14 +35,15 @@ router.get('/v2/coursesmulti', async (req, res) => {
|
||||
|
||||
router.get('/v3/courseyearlystats', async (req, res) => {
|
||||
const {
|
||||
user: { rights, roles },
|
||||
user: { roles, programmeRights },
|
||||
} = req
|
||||
|
||||
const allowedRoles = roles && ['admin', 'courseStatistics'].find(role => roles.includes(role))
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed
|
||||
// to see all of them
|
||||
if (!allowedRoles && rights.length < 1) {
|
||||
if (!allowedRoles && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ router.get('/v3/courseyearlystats', async (req, res) => {
|
||||
} else {
|
||||
// Studentnumbers should be obfuscated to all other users except admins
|
||||
// and users with rights to any specific study programmes
|
||||
const anonymize = allowedRoles !== 'admin' && rights.length < 1
|
||||
const anonymize = allowedRoles !== 'admin' && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await courseService.courseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
res.json(results)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const { combineFacultyBasics } = require('../services/faculty/facultyBasics')
|
||||
const { combineFacultyCredits } = require('../services/faculty/facultyCredits')
|
||||
const { getFacultyCredits } = require('../services/faculty/facultyCredits')
|
||||
const { combineFacultyThesisWriters } = require('../services/faculty/facultyThesisWriters')
|
||||
const { countGraduationTimes } = require('../services/faculty/facultyGraduationTimes')
|
||||
const { updateFacultyOverview, updateFacultyProgressOverview } = require('../services/faculty/facultyUpdates')
|
||||
@@ -10,8 +10,6 @@ const {
|
||||
getProgrammes,
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
getThesisWritersStats,
|
||||
setThesisWritersStats,
|
||||
getGraduationStats,
|
||||
@@ -69,25 +67,9 @@ router.get('/:id/basicstats', async (req, res) => {
|
||||
|
||||
router.get('/:id/creditstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
const programmeFilter = req.query?.programme_filter
|
||||
|
||||
if (!code) return res.status(422).end()
|
||||
const data = await getCreditStats(code, yearType, programmeFilter)
|
||||
if (data) return res.json(data)
|
||||
|
||||
const programmes = await getProgrammes(code, programmeFilter)
|
||||
if (!programmes) return res.status(422).end()
|
||||
|
||||
// list of all programmes is also needed in classification of credits
|
||||
const allProgrammes = programmeFilter === 'ALL_PROGRAMMES' ? programmes : await getProgrammes(code, 'ALL_PROGRAMMES')
|
||||
|
||||
let updatedStats = await combineFacultyCredits(code, programmes.data, allProgrammes.data, yearType)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setCreditStats(updatedStats, yearType, programmeFilter)
|
||||
}
|
||||
|
||||
return res.json(updatedStats)
|
||||
const { year_type: yearType } = req.query
|
||||
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
|
||||
return res.json(stats)
|
||||
})
|
||||
|
||||
router.get('/:id/thesisstats', async (req, res) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ const studentService = require('../services/students')
|
||||
const StudyrightService = require('../services/studyrights')
|
||||
const CourseService = require('../services/courses')
|
||||
const StatMergeService = require('../services/statMerger')
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const { mapToProviders, getFullStudyProgrammeRights } = require('../util/utils')
|
||||
const { encrypt, decrypt } = require('../services/encrypt')
|
||||
const { mapCodesToIds } = require('../services/studyprogramme/studyprogrammeHelpers')
|
||||
|
||||
@@ -148,7 +148,9 @@ router.post('/v2/populationstatistics/coursesbystudentnumberlist', async (req, r
|
||||
|
||||
router.get('/v3/populationstatistics', async (req, res) => {
|
||||
const { year, semesters, studyRights: studyRightsJSON } = req.query
|
||||
const { user } = req
|
||||
const {
|
||||
user: { isAdmin, programmeRights, id: userId },
|
||||
} = req
|
||||
|
||||
if (!year || !semesters || !studyRightsJSON) {
|
||||
res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
@@ -156,17 +158,15 @@ router.get('/v3/populationstatistics', async (req, res) => {
|
||||
}
|
||||
let studyRights = null
|
||||
studyRights = JSON.parse(studyRightsJSON)
|
||||
const {
|
||||
user: { rights, iamRights, isAdmin },
|
||||
} = req
|
||||
|
||||
const fullProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeRightsCodes = programmeRights.map(({ code }) => code)
|
||||
|
||||
try {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!rights.includes(studyRights.programme) &&
|
||||
!rights.includes(studyRights.combinedProgramme) &&
|
||||
!iamRights.includes(studyRights.programme) &&
|
||||
!iamRights.includes(studyRights.combinedProgramme)
|
||||
!programmeRightsCodes.includes(studyRights.programme) &&
|
||||
!programmeRightsCodes.includes(studyRights.combinedProgramme)
|
||||
) {
|
||||
res.status(403).json([])
|
||||
return
|
||||
@@ -207,7 +207,7 @@ router.get('/v3/populationstatistics', async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, user.id))
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
} else {
|
||||
const result = await populationService.optimizedStatisticsOf({
|
||||
...req.query,
|
||||
@@ -220,8 +220,12 @@ router.get('/v3/populationstatistics', async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Obfuscate if user has only iam rights
|
||||
if (!isAdmin && !rights.includes(studyRights.programme) && !rights.includes(studyRights.combinedProgramme)) {
|
||||
// Obfuscate if user has only limited study programme rights
|
||||
if (
|
||||
!isAdmin &&
|
||||
!fullProgrammeRights.includes(studyRights.programme) &&
|
||||
!fullProgrammeRights.includes(studyRights.combinedProgramme)
|
||||
) {
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData } = encrypt(student.studentNumber)
|
||||
const obfuscatedBirthDate = new Date(new Date(student.birthdate).setMonth(0, 0)) // only birth year for age distribution
|
||||
@@ -242,14 +246,14 @@ router.get('/v3/populationstatistics', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, user.id))
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
const { coursecodes, from, to, separate: sep, unifyCourses } = req.query
|
||||
const {
|
||||
user: { id, isAdmin, studentsUserCanAccess: allStudentsUserCanAccess, rights },
|
||||
user: { id, isAdmin, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights },
|
||||
} = req
|
||||
const separate = sep ? JSON.parse(sep) : false
|
||||
|
||||
@@ -288,7 +292,8 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
if (!isAdmin) {
|
||||
courseproviders = await CourseService.getCourseProvidersForCourses(JSON.parse(coursecodes))
|
||||
}
|
||||
const rightsMappedToProviders = mapToProviders(rights)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
|
||||
const found = courseproviders.some(r => rightsMappedToProviders.includes(r))
|
||||
|
||||
const studentsUserCanAccess =
|
||||
@@ -356,14 +361,14 @@ router.post('/v3/populationstatisticsbystudentnumbers', async (req, res) => {
|
||||
|
||||
router.get('/v3/populationstatistics/studyprogrammes', async (req, res) => {
|
||||
const {
|
||||
user: { rights, iamRights, roles },
|
||||
user: { roles, programmeRights },
|
||||
} = req
|
||||
if (roles?.includes('admin')) {
|
||||
const studyrights = await StudyrightService.getAssociations()
|
||||
mapCodesToIds(studyrights.programmes)
|
||||
res.json(studyrights)
|
||||
} else {
|
||||
const allRights = rights.concat(iamRights)
|
||||
const allRights = _.uniq(programmeRights.map(p => p.code))
|
||||
// For combined programme
|
||||
// If more programmes are combined, then a function might be a better idea to add moar rights
|
||||
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) allRights.push('KH90_001', 'MH90_001')
|
||||
|
||||
@@ -3,6 +3,7 @@ const Tags = require('../services/tags')
|
||||
const TagStudent = require('../services/tagstudent')
|
||||
const Students = require('../services/students')
|
||||
const { difference } = require('lodash')
|
||||
const { getFullStudyProgrammeRights } = require('../util/utils')
|
||||
|
||||
const filterRelevantTags = (tags, userId) => {
|
||||
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
@@ -12,6 +13,11 @@ const filterRelevantStudentTags = (studentTags, userId) => {
|
||||
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
}
|
||||
|
||||
const userIsUnauthorized = (programmeRights, programmeCodes, roles) =>
|
||||
!programmeRights.includes(programmeCodes[0]) &&
|
||||
!programmeRights.includes(programmeCodes[1]) &&
|
||||
!roles?.includes('admin')
|
||||
|
||||
router.get('/tags', async (_req, res) => {
|
||||
const tags = await Tags.findTags()
|
||||
res.status(200).json(tags)
|
||||
@@ -20,13 +26,14 @@ router.get('/tags', async (_req, res) => {
|
||||
router.get('/tags/:studytrack', async (req, res) => {
|
||||
const { studytrack } = req.params
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const programmeCodes = studytrack.includes('KH') && studytrack.includes('MH') ? studytrack.split('-') : [studytrack]
|
||||
|
||||
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags. This is to avoid unnecessary noise in Sentry
|
||||
if (!rights.includes(programmeCodes[0]) && !rights.includes(programmeCodes[1]) && !roles?.includes('admin'))
|
||||
return res.json(null)
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) return res.json(null)
|
||||
|
||||
const tags = await Tags.findTagsByStudytrack(studytrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
@@ -37,12 +44,12 @@ router.post('/tags', async (req, res) => {
|
||||
tag: { studytrack, tagname, year, personal_user_id },
|
||||
} = req.body
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const programmeCodes = studytrack.includes('KH') && studytrack.includes('MH') ? studytrack.split('-') : [studytrack]
|
||||
if (!rights.includes(programmeCodes[0]) && !rights.includes(programmeCodes[1]) && !roles?.includes('admin'))
|
||||
return res.status(403).end()
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) return res.status(403).end()
|
||||
|
||||
await Tags.createNewTag({ studytrack, tagname, year, personal_user_id })
|
||||
const tags = await Tags.findTagsByStudytrack(studytrack)
|
||||
@@ -52,18 +59,15 @@ router.post('/tags', async (req, res) => {
|
||||
router.delete('/tags', async (req, res) => {
|
||||
const { tag } = req.body
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const programmeCodes =
|
||||
tag.studytrack.includes('KH') && tag.studytrack.includes('MH') ? tag.studytrack.split('-') : [tag.studytrack]
|
||||
|
||||
if (
|
||||
!rights.includes(programmeCodes[0]) &&
|
||||
!rights.includes(programmeCodes[1]) &&
|
||||
!roles?.includes('admin') &&
|
||||
!(tag.personal_user_id === id)
|
||||
)
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id))
|
||||
return res.status(403).end()
|
||||
|
||||
await Tags.deleteTag(tag)
|
||||
@@ -79,13 +83,19 @@ router.get('/studenttags', async (req, res) => {
|
||||
router.get('/studenttags/:studytrack', async (req, res) => {
|
||||
const { studytrack } = req.params
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const programmeCodes = studytrack.includes('KH') && studytrack.includes('MH') ? studytrack.split('-') : [studytrack]
|
||||
|
||||
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags. This is to avoid unnecessary noise in Sentry
|
||||
if (!rights.includes(programmeCodes[0]) && !rights.includes(programmeCodes[1]) && !roles?.includes('admin'))
|
||||
if (
|
||||
!fullStudyProgrammeRights.includes(programmeCodes[0]) &&
|
||||
!fullStudyProgrammeRights.includes(programmeCodes[1]) &&
|
||||
!roles?.includes('admin')
|
||||
)
|
||||
return res.json(null)
|
||||
|
||||
const result = await TagStudent.getStudentTagsByStudytrack(studytrack)
|
||||
@@ -102,11 +112,12 @@ router.get('/studenttags/:studentnumber', async (req, res) => {
|
||||
router.post('/studenttags/:studentnumber', async (req, res) => {
|
||||
const { tag, studytrack, combinedProgramme } = req.body
|
||||
const {
|
||||
user: { roles, rights, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
if (!rights.includes(studytrack) && !rights.includes(combinedProgramme) && !roles?.includes('admin'))
|
||||
return res.status(403).end()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) return res.status(403).end()
|
||||
|
||||
await TagStudent.createStudentTag(tag)
|
||||
const studytrackCode = combinedProgramme ? `${studytrack}-${combinedProgramme}` : studytrack
|
||||
@@ -117,11 +128,12 @@ router.post('/studenttags/:studentnumber', async (req, res) => {
|
||||
router.post('/studenttags', async (req, res) => {
|
||||
const { tags, studytrack, combinedProgramme } = req.body
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
if (!rights.includes(studytrack) && !rights.includes(combinedProgramme) && !roles?.includes('admin'))
|
||||
return res.status(403).end()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) return res.status(403).end()
|
||||
|
||||
const studytrackCode = combinedProgramme ? `${studytrack}-${combinedProgramme}` : studytrack
|
||||
const existingTags = await Tags.findTagsByStudytrack(studytrackCode)
|
||||
@@ -146,11 +158,13 @@ router.post('/studenttags', async (req, res) => {
|
||||
router.delete('/studenttags/delete_one', async (req, res) => {
|
||||
const { tag_id, studentnumber, studytrack, combinedProgramme } = req.body
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
if (!rights.includes(studytrack) && !rights.includes(combinedProgramme) && !roles?.includes('admin'))
|
||||
return res.status(403).end()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) return res.status(403).end()
|
||||
|
||||
const studytrackCode = combinedProgramme ? `${studytrack}-${combinedProgramme}` : studytrack
|
||||
const tags = await Tags.findTagsFromStudytrackById(studytrackCode, [tag_id])
|
||||
if (tags.length === 0) return res.status(403).json({ error: 'The tag does not exist' })
|
||||
@@ -163,11 +177,13 @@ router.delete('/studenttags/delete_one', async (req, res) => {
|
||||
router.delete('/studenttags/delete_many', async (req, res) => {
|
||||
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
|
||||
const {
|
||||
user: { rights, roles, id },
|
||||
user: { roles, id, programmeRights },
|
||||
} = req
|
||||
|
||||
if (!rights.includes(studytrack) && !rights.includes(combinedProgramme) && !roles?.includes('admin'))
|
||||
return res.status(403).end()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) return res.status(403).end()
|
||||
|
||||
const studytrackCode = combinedProgramme ? `${studytrack}-${combinedProgramme}` : studytrack
|
||||
const tags = await Tags.findTagsFromStudytrackById(studytrackCode, [tagId])
|
||||
if (tags.length === 0) return res.status(403).json({ error: 'The tag does not exist' })
|
||||
|
||||
@@ -2,7 +2,7 @@ const teachers = require('../services/teachers')
|
||||
const topteachers = require('../services/topteachers')
|
||||
const { isFaculty, providersOfFaculty } = require('../services/organisations')
|
||||
const router = require('express').Router()
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const { mapToProviders, getFullStudyProgrammeRights } = require('../util/utils')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { searchTerm } = req.query
|
||||
@@ -43,14 +43,16 @@ router.get('/top/categories', async (req, res) => {
|
||||
|
||||
router.get('/stats', async (req, res) => {
|
||||
const {
|
||||
user: { rights, roles },
|
||||
user: { roles, programmeRights },
|
||||
} = req
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const { providers, semesterStart, semesterEnd } = req.query
|
||||
if (!providers || !semesterStart) {
|
||||
return res.status(422).send('Missing required query parameters.')
|
||||
}
|
||||
const providerRights = mapToProviders(rights)
|
||||
const providerRights = mapToProviders(fullStudyProgrammeRights)
|
||||
|
||||
if (!(providers.every(p => providerRights.includes(p)) || roles.includes('admin'))) {
|
||||
return res.status(403).send('You do not have rights to see this data')
|
||||
|
||||
@@ -6,7 +6,7 @@ const { getMedian } = require('../services/studyprogramme/studyprogrammeHelpers'
|
||||
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'licentiate', 'doctor']
|
||||
|
||||
router.get('/allprogressstats', async (req, res) => {
|
||||
const specialGroups = 'SPECIAL_EXCLUDED'
|
||||
const specialGroups = req.query?.specialsIncluded === 'true' ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
|
||||
const graduated = req.query?.graduated
|
||||
const allFaculties = await getFacultyList()
|
||||
const facultyCodes = allFaculties.map(f => f.code)
|
||||
@@ -15,7 +15,7 @@ router.get('/allprogressstats', async (req, res) => {
|
||||
for (const facultyCode of facultyCodes) {
|
||||
const data = await getFacultyProgressStats(facultyCode, specialGroups, graduated)
|
||||
if (!data) {
|
||||
return res.status(500).end()
|
||||
return res.status(404).send('Data missing from server: Refreshing faculty data required')
|
||||
}
|
||||
codeToData[facultyCode] = data
|
||||
}
|
||||
@@ -23,6 +23,9 @@ router.get('/allprogressstats', async (req, res) => {
|
||||
const universityData = {
|
||||
years: codeToData.H50.years,
|
||||
yearlyBachelorTitles: codeToData.H50.yearlyBachelorTitles,
|
||||
yearlyBcMsTitles: codeToData.H50.yearlyBcMsTitles,
|
||||
yearlyMasterTitles: codeToData.H50.yearlyMasterTitles,
|
||||
yearlyLicentiateTitles: codeToData.H50.yearlyLicentiateTitles,
|
||||
programmeNames: allFaculties.reduce((obj, fac) => {
|
||||
const { name, ...rest } = fac.dataValues
|
||||
obj[fac.code] = { ...rest, ...name }
|
||||
@@ -66,8 +69,8 @@ router.get('/allprogressstats', async (req, res) => {
|
||||
'mastersProgStats',
|
||||
'doctoralProgStats',
|
||||
]) {
|
||||
if (Object.keys(facultyData[fieldName]).length === 0) continue
|
||||
if (!universityData[fieldName]) universityData[fieldName] = {}
|
||||
if (Object.keys(facultyData[fieldName]).length === 0) continue
|
||||
universityData[fieldName][facultyCode] = unifyProgressStats(Object.values(facultyData[fieldName]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
updateStudentsByStudentNumber,
|
||||
abort,
|
||||
updateCoursesByCourseCode,
|
||||
updateStudentsIndividually,
|
||||
} = require('../services/sisUpdaterService')
|
||||
|
||||
const logger = require('../util/logger')
|
||||
@@ -37,6 +38,11 @@ router.get('/update/v2/students', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/students_individually', async (req, res) => {
|
||||
await updateStudentsIndividually()
|
||||
res.status(200).send()
|
||||
})
|
||||
|
||||
router.post('/update/v2/customlist/:type', async (req, res) => {
|
||||
const type = req.params.type
|
||||
const list = req.body
|
||||
|
||||
@@ -65,11 +65,4 @@ router.delete('/:uid/elements', async (req, res) => {
|
||||
res.json(user)
|
||||
})
|
||||
|
||||
router.post('/:uid/faculties', async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { faculties } = req.body
|
||||
const user = await userService.setFaculties(uid, faculties)
|
||||
res.json(user)
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -3,6 +3,8 @@ const moment = require('moment')
|
||||
|
||||
// Only new bachelor, masters and doctoral programmes get their data updated in redis every night, use redis for them
|
||||
const isUpdatedNewProgramme = code => code.includes('KH') || code.includes('MH') || /^(T)[0-9]{6}$/.test(code)
|
||||
const isFaculty = code =>
|
||||
['H10', 'H20', 'H30', 'H40', 'H50', 'H55', 'H57', 'H60', 'H70', 'H74', 'H80', 'H90'].includes(code)
|
||||
const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForCreditStats = (id, yearType, specialGroups) => `CREDIT_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForGraduationStats = (id, yearType, specialGroups) =>
|
||||
@@ -34,7 +36,6 @@ const setBasicStats = async (data, yearType, specialGroups) => {
|
||||
}
|
||||
|
||||
const getCreditStats = async (id, isAcademicYear, specialGroups) => {
|
||||
if (!isUpdatedNewProgramme(id)) return null
|
||||
const redisKey = createRedisKeyForCreditStats(
|
||||
id,
|
||||
isAcademicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR',
|
||||
@@ -57,7 +58,7 @@ const setCreditStats = async (data, isAcademicYear, specialGroups) => {
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
if (!isUpdatedNewProgramme(id)) return dataToRedis
|
||||
if (!isUpdatedNewProgramme(data.id) && !isFaculty(data.id)) return dataToRedis
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
mapCodesToIds,
|
||||
facultyProgrammeStudents,
|
||||
} = require('./facultyHelpers')
|
||||
const { uniqBy } = require('lodash')
|
||||
const {
|
||||
dbConnections: { sequelize },
|
||||
} = require('../../database/connection')
|
||||
@@ -278,20 +279,25 @@ const transferredTo = async (programmes, allProgrammeCodes, since) =>
|
||||
).map(formatFacultyTransfer)
|
||||
|
||||
const degreeProgrammesOfFaculty = async facultyCode =>
|
||||
(
|
||||
await ProgrammeModule.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
include: {
|
||||
model: Organization,
|
||||
where: {
|
||||
code: {
|
||||
[Op.startsWith]: facultyCode,
|
||||
// Some programmenames are different, causing this to return multiples of same codes.
|
||||
// Hence the uniqBy
|
||||
uniqBy(
|
||||
(
|
||||
await ProgrammeModule.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
include: {
|
||||
model: Organization,
|
||||
where: {
|
||||
code: {
|
||||
[Op.startsWith]: facultyCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
group: ['programme_module.code', 'programme_module.name', 'organization.id'],
|
||||
})
|
||||
).map(facultyFormatProgramme)
|
||||
group: ['programme_module.code', 'programme_module.name', 'organization.id'],
|
||||
})
|
||||
).map(facultyFormatProgramme),
|
||||
'code'
|
||||
)
|
||||
|
||||
const facultyOrganizationId = async faculty => {
|
||||
return await Organization.findOne({
|
||||
|
||||
@@ -1,281 +1,36 @@
|
||||
const { indexOf, uniqBy } = require('lodash')
|
||||
const { mapToProviders } = require('../../util/utils')
|
||||
const {
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
isMajorStudentCredit,
|
||||
isNonMajorCredit,
|
||||
getYearsArray,
|
||||
getStartDate,
|
||||
} = require('../studyprogramme/studyprogrammeHelpers')
|
||||
const { getCreditsForProvider } = require('../studyprogramme/creditGetters')
|
||||
const { getStudyRights } = require('../studyprogramme/studyrightFinders')
|
||||
const { getCourseCodesOfProvider } = require('../providers')
|
||||
const { getCreditsProduced } = require('../providerCredits')
|
||||
const { getProgrammes } = require('./facultyService')
|
||||
|
||||
const isFacultyNonMajorCredit = (studyrights, attainmentDate, facultyProgrammes, facultyCode) => {
|
||||
let right = ''
|
||||
studyrights.forEach(studyright => {
|
||||
if (studyright && studyright.facultyCode === facultyCode) {
|
||||
const startDate =
|
||||
studyright.studyrightid.slice(-2) === '-2' && studyright.extentcode === 2
|
||||
? studyright.studystartdate
|
||||
: studyright.startdate
|
||||
if (!studyright.graduated && new Date(attainmentDate) >= new Date(startDate)) {
|
||||
if (!right) right = studyright.code
|
||||
else if (right && !facultyProgrammes.includes(right) && facultyProgrammes.includes(studyright.code))
|
||||
right = studyright.code
|
||||
} else {
|
||||
if (
|
||||
new Date(attainmentDate) >= new Date(startDate) &&
|
||||
new Date(attainmentDate) <= new Date(studyright.enddate)
|
||||
) {
|
||||
if (!right) right = studyright.code
|
||||
else if (right && !facultyProgrammes.includes(right) && facultyProgrammes.includes(studyright.code))
|
||||
right = studyright.code
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return facultyProgrammes.includes(right)
|
||||
}
|
||||
const isFacultyOtherCredit = (studyrights, attainmentDate) => {
|
||||
return studyrights.some(studyright => {
|
||||
return (
|
||||
studyright &&
|
||||
[6, 7, 23, 34, 99].includes(studyright.extentcode) &&
|
||||
new Date(attainmentDate) >= new Date(studyright.startdate)
|
||||
)
|
||||
})
|
||||
}
|
||||
/*
|
||||
Returns credits produced by the programmes of the faculty but also the credits where the provider is the faculty itself.
|
||||
*/
|
||||
const getFacultyCredits = async (facultyCode, isAcademicYear) => {
|
||||
const allProgrammes = (await getProgrammes(facultyCode)).data
|
||||
// Filter out old & obsolete codes
|
||||
const programmes = allProgrammes.filter(
|
||||
({ code }) =>
|
||||
(code.includes('KH') && !code.startsWith('2_KH') && !code.endsWith('_2')) ||
|
||||
(code.includes('MH') && !code.startsWith('2_MH') && !code.endsWith('_2')) ||
|
||||
/^(T)[0-9]{6}$/.test(code)
|
||||
)
|
||||
|
||||
// Fetches all the credits for the studyprogramme and divides them into major-students, non-major faculty students,
|
||||
// non-major other faculty students, other (exhange students) and non-degree student credits.
|
||||
// Division is done on the basis that whether the student had a primary studyright to the programme on the attainment_date
|
||||
const getFacultyRegularCreditStats = async ({
|
||||
studyprogramme,
|
||||
since,
|
||||
years,
|
||||
isAcademicYear,
|
||||
facultyProgrammes,
|
||||
facultyCode,
|
||||
}) => {
|
||||
let majors = getStatsBasis(years) // Has studyright to programme
|
||||
let facultyNonMajors = getStatsBasis(years) // Has studyright in the same faculty but not in the programme from which credits are
|
||||
let facultyOthers = getStatsBasis(years) // Exchange students, specialization education in Medicine, Dentistry and vetenary medicine, continuous education
|
||||
let otherNonMajors = getStatsBasis(years) // Has studyright in different faculty
|
||||
let nonDegree = getStatsBasis(years) // open uni, non-degree study rights, contract studies, (other) specialisation education, credits outside of the studyright
|
||||
if (!studyprogramme) {
|
||||
return { majors, facultyNonMajors, otherNonMajors, facultyOthers, nonDegree }
|
||||
}
|
||||
|
||||
const providercode = mapToProviders([studyprogramme])[0]
|
||||
const providedByProgramme = await getCourseCodesOfProvider(providercode)
|
||||
const allCredits = await getCreditsForProvider(providercode, providedByProgramme, since)
|
||||
const credits = uniqBy(allCredits, 'id')
|
||||
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
|
||||
|
||||
let studyrights = await getStudyRights(students)
|
||||
|
||||
const studentNumberToStudyrightsMap = studyrights.reduce((obj, cur) => {
|
||||
if (!obj[cur.studentNumber]) obj[cur.studentNumber] = []
|
||||
obj[cur.studentNumber].push(cur)
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
credits.forEach(({ studentNumber, attainmentDate, credits }) => {
|
||||
const studentStudyrights = studentNumberToStudyrightsMap[studentNumber] || []
|
||||
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
|
||||
|
||||
if (isMajorStudentCredit(studentStudyrights, attainmentDate, studyprogramme)) {
|
||||
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
majors.tableStats[attainmentYear] += credits || 0
|
||||
} else if (isNonMajorCredit(studentStudyrights, attainmentDate)) {
|
||||
if (isFacultyNonMajorCredit(studentStudyrights, attainmentDate, facultyProgrammes, facultyCode)) {
|
||||
facultyNonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
facultyNonMajors.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
otherNonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
otherNonMajors.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
} else if (isFacultyOtherCredit(studentStudyrights, attainmentDate)) {
|
||||
facultyOthers.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
facultyOthers.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
nonDegree.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
nonDegree.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
majors.graphStats.every(year => year === 0) &&
|
||||
facultyNonMajors.graphStats.every(year => year === 0) &&
|
||||
otherNonMajors.graphStats.every(year => year === 0) &&
|
||||
nonDegree.graphStats.every(year => year === 0)
|
||||
) {
|
||||
return {
|
||||
majors: { graphStats: [], tableStats: {} },
|
||||
facultyNonMajors: { graphStats: [], tableStats: {} },
|
||||
otherNonMajors: { graphStats: [], tableStats: {} },
|
||||
nonDegree: { graphStats: [], tablestats: {} },
|
||||
const facultyCredits = await getCreditsProduced(facultyCode, isAcademicYear)
|
||||
const stats = { [facultyCode]: facultyCredits, codes: [facultyCode], programmeNames: {}, ids: [facultyCode] }
|
||||
for (const programme of programmes) {
|
||||
const programmeCode = programme.code
|
||||
const programmeCredits = await getCreditsProduced(programmeCode, isAcademicYear)
|
||||
stats[programme.progId] = programmeCredits
|
||||
stats.codes.push(programmeCode)
|
||||
stats.ids.push(programme.progId)
|
||||
stats.programmeNames[programme.progId] = {
|
||||
code: programmeCode,
|
||||
...programmes.find(p => p.code === programmeCode).name,
|
||||
}
|
||||
}
|
||||
|
||||
return { majors, facultyNonMajors, otherNonMajors, facultyOthers, nonDegree }
|
||||
return stats
|
||||
}
|
||||
|
||||
// Fetches all credits for the studytrack and combines them into statistics
|
||||
// These are for the faculty view which differs from study programme overview
|
||||
// (no transfers, non majors split to in- and outside-faculty)
|
||||
const getFacultyCreditStatsForStudytrack = async ({
|
||||
studyprogramme,
|
||||
facultyProgrammes,
|
||||
isAcademicYear,
|
||||
facultyCode,
|
||||
}) => {
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
const queryParameters = { studyprogramme, since, years, isAcademicYear, facultyProgrammes, facultyCode }
|
||||
const { majors, facultyNonMajors, otherNonMajors, facultyOthers, nonDegree } =
|
||||
await getFacultyRegularCreditStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const dataFound = [majors, facultyNonMajors, otherNonMajors].some(d => d.graphStats.length) // , transferred
|
||||
|
||||
if (!dataFound) return null
|
||||
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
majors.tableStats[year] +
|
||||
facultyNonMajors.tableStats[year] +
|
||||
otherNonMajors.tableStats[year] +
|
||||
nonDegree.tableStats[year] +
|
||||
facultyOthers.tableStats[year],
|
||||
majors.tableStats[year],
|
||||
facultyNonMajors.tableStats[year],
|
||||
otherNonMajors.tableStats[year],
|
||||
nonDegree.tableStats[year],
|
||||
facultyOthers.tableStats[year],
|
||||
])
|
||||
|
||||
const graphStats = [
|
||||
{
|
||||
name: 'Major students credits',
|
||||
data: majors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major faculty students credits',
|
||||
data: facultyNonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major other faculty students credits 2',
|
||||
data: otherNonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-degree credits',
|
||||
data: nonDegree.graphStats,
|
||||
},
|
||||
{
|
||||
other: 'Other credits',
|
||||
data: facultyOthers.graphStats,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats,
|
||||
}
|
||||
module.exports = {
|
||||
getFacultyCredits,
|
||||
}
|
||||
|
||||
const getProgrammeCredits = async (code, yearType, facultyProgrammes, faculty) => {
|
||||
const updatedStats = await getFacultyCreditStatsForStudytrack({
|
||||
facultyCode: faculty,
|
||||
studyprogramme: code,
|
||||
facultyProgrammes,
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
})
|
||||
|
||||
return updatedStats
|
||||
}
|
||||
|
||||
const combineFacultyCredits = async (faculty, programmes, allProgrammes, yearType) => {
|
||||
let counts = {}
|
||||
let years = []
|
||||
const titles = [
|
||||
'',
|
||||
'Total',
|
||||
'Major students credits',
|
||||
'Non-major faculty students credits',
|
||||
'Non-major other faculty students credits',
|
||||
'Non-degree student credits',
|
||||
'Other non-degree credits',
|
||||
]
|
||||
|
||||
let allCredits = {
|
||||
id: faculty,
|
||||
years: [],
|
||||
tableStats: [],
|
||||
graphStats: [],
|
||||
programmeTableStats: {},
|
||||
programmeNames: {},
|
||||
titles,
|
||||
}
|
||||
|
||||
const progCodes = allProgrammes.map(p => p.code)
|
||||
for (const prog of programmes) {
|
||||
if (prog.code === 'MH70_008_2') continue
|
||||
const data = await getProgrammeCredits(prog.code, yearType, progCodes, faculty)
|
||||
if (data) {
|
||||
allCredits.programmeNames[prog.progId] = { code: prog.code, ...prog.name }
|
||||
allCredits.programmeTableStats[prog.progId] = data.tableStats
|
||||
if (!allCredits.lastUpdated || new Date(data.lastUpdated) > new Date(allCredits.lastUpdated))
|
||||
allCredits.lastUpdated = data.lastUpdated
|
||||
|
||||
data.tableStats.forEach(row => {
|
||||
if (!(row[0] in counts)) {
|
||||
counts[row[0]] = row.slice(1)
|
||||
years.push(row[0])
|
||||
} else {
|
||||
counts[row[0]] = row.slice(1).map((value, i) => {
|
||||
return counts[row[0]][i] + value
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// save table stats and graph stats
|
||||
years.forEach(year => {
|
||||
allCredits.tableStats.push([year, ...counts[year]])
|
||||
})
|
||||
|
||||
years.sort()
|
||||
allCredits.years = years
|
||||
|
||||
let majors = []
|
||||
let facultyNonMajor = []
|
||||
let otherNonMajor = []
|
||||
let facultyOthers = []
|
||||
let nonDegree = []
|
||||
|
||||
years.forEach(year => {
|
||||
majors.push(counts[year][1])
|
||||
facultyNonMajor.push(counts[year][2])
|
||||
otherNonMajor.push(counts[year][3])
|
||||
facultyOthers.push(counts[year][4])
|
||||
nonDegree.push(counts[year][5])
|
||||
})
|
||||
|
||||
allCredits.graphStats = [
|
||||
{ name: 'Major students credits', data: majors },
|
||||
{ name: 'Non-major faculty students credits', data: facultyNonMajor },
|
||||
{ name: 'Non-major other faculty students credits', data: otherNonMajor },
|
||||
{ name: 'Non-degree credits', data: nonDegree },
|
||||
{ name: 'Other non-degree credits', data: facultyOthers },
|
||||
]
|
||||
return allCredits
|
||||
}
|
||||
|
||||
module.exports = { combineFacultyCredits }
|
||||
|
||||
@@ -5,8 +5,6 @@ const { findFacultyProgrammeCodes } = require('./faculty')
|
||||
const createRedisKeyForFacultyProgrammes = (id, programmeFilter) => `FACULTY_PROGRAMMES_${id}_${programmeFilter}`
|
||||
const createRedisKeyForBasicStats = (id, yearType, programmeFilter, specialGroups) =>
|
||||
`FACULTY_BASIC_STATS_${id}_${yearType}_${programmeFilter}_${specialGroups}`
|
||||
const createRedisKeyForCreditStats = (id, yearType, programmeFilter) =>
|
||||
`FACULTY_CREDIT_STATS_${id}_${yearType}_${programmeFilter}`
|
||||
const createRedisKeyForThesiswriters = (id, yearType, programmeFilter, specialGroups) =>
|
||||
`FACULTY_THESIS_WRITERS_STATS_${id}_${yearType}_${programmeFilter}_${specialGroups}`
|
||||
const createRedisKeyForGraduationTimeStats = (id, programmeFilter) =>
|
||||
@@ -43,7 +41,7 @@ const setFacultyProgrammes = async (id, data, programmeFilter) => {
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getProgrammes = async (code, programmeFilter) => {
|
||||
const getProgrammes = async (code, programmeFilter = 'NEW_STUDY_PROGRAMMES') => {
|
||||
const programmes = await getFacultyProgrammesFromRedis(code, programmeFilter)
|
||||
if (programmes) return programmes
|
||||
let updatedProgrammes = await findFacultyProgrammeCodes(code, programmeFilter)
|
||||
@@ -79,26 +77,6 @@ const getBasicStats = async (id, yearType, programmeFilter, specialGroups) => {
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setCreditStats = async (data, yearType, programmeFilter) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType, programmeFilter)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getCreditStats = async (id, yearType, programmeFilter) => {
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType, programmeFilter)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setThesisWritersStats = async (data, yearType, programmeFilter, specialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForThesiswriters(id, yearType, programmeFilter, specialGroups)
|
||||
@@ -187,8 +165,6 @@ module.exports = {
|
||||
getProgrammes,
|
||||
setBasicStats,
|
||||
getBasicStats,
|
||||
setCreditStats,
|
||||
getCreditStats,
|
||||
setThesisWritersStats,
|
||||
getThesisWritersStats,
|
||||
setGraduationStats,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
const logger = require('../../util/logger')
|
||||
const { findFacultyProgrammeCodes } = require('./faculty')
|
||||
const { combineFacultyBasics } = require('./facultyBasics')
|
||||
const { combineFacultyCredits } = require('./facultyCredits')
|
||||
const { combineFacultyThesisWriters } = require('./facultyThesisWriters')
|
||||
const { countGraduationTimes } = require('./facultyGraduationTimes')
|
||||
const {
|
||||
setFacultyProgrammes,
|
||||
setBasicStats,
|
||||
setCreditStats,
|
||||
setThesisWritersStats,
|
||||
setGraduationStats,
|
||||
setFacultyProgressStats,
|
||||
@@ -15,6 +13,8 @@ const {
|
||||
} = require('./facultyService')
|
||||
const { combineFacultyStudentProgress } = require('./facultyStudentProgress')
|
||||
const { combineFacultyStudents } = require('./facultyStudents')
|
||||
const { computeCreditsProduced } = require('../providerCredits')
|
||||
const { setCreditStats } = require('../analyticsService')
|
||||
|
||||
const updateFacultyOverview = async (faculty, statsType) => {
|
||||
const calendarNewSpecial = {
|
||||
@@ -102,13 +102,12 @@ const updateFacultyOverview = async (faculty, statsType) => {
|
||||
await setBasicStats(updatedStudentInfo, option.yearType, option.programmeFilter, option.specialGroups)
|
||||
}
|
||||
if ((statsType === 'ALL' || statsType === 'CREDITS') && specialGroups !== 'SPECIAL_EXCLUDED') {
|
||||
const updatedCredits = await combineFacultyCredits(
|
||||
const updatedCredits = await computeCreditsProduced(
|
||||
faculty,
|
||||
programmeFilter === 'ALL_PROGRAMMES' ? allProgrammes.data : newProgrammes.data,
|
||||
allProgrammes.data,
|
||||
yearType
|
||||
yearType === 'ACADEMIC_YEAR',
|
||||
specialGroups === 'SPECIAL_INCLUDED'
|
||||
)
|
||||
await setCreditStats(updatedCredits, yearType, programmeFilter, specialGroups)
|
||||
await setCreditStats(updatedCredits, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
|
||||
}
|
||||
if (statsType === 'ALL' || statsType === 'THESIS') {
|
||||
const updateThesisWriters = await combineFacultyThesisWriters(
|
||||
|
||||
@@ -17,7 +17,6 @@ const getCredits = async (courseCodes, startdate) =>
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
is_open: true,
|
||||
attainment_date: {
|
||||
[Op.gte]: startdate,
|
||||
},
|
||||
@@ -80,6 +79,9 @@ const getStudyRights = async students =>
|
||||
student_studentnumber: {
|
||||
[Op.in]: students.length > 0 ? students : { [Op.not]: null },
|
||||
},
|
||||
extentcode: {
|
||||
[Op.in]: [1, 2, 3, 4],
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(mapStudyRights)
|
||||
|
||||
@@ -3,7 +3,7 @@ const { getCredits, getStudyRights, getEnrollments, getStudentInfo, getCourseNam
|
||||
|
||||
const uniq = objects => [...new Set(objects)]
|
||||
|
||||
const calulateTotalsForStudent = async (studentStats, studentnumber) => {
|
||||
const calculateTotalsForStudent = async (studentStats, studentnumber) => {
|
||||
Object.keys(studentStats[studentnumber].courseInfo).forEach(course => {
|
||||
if (studentStats[studentnumber].courseInfo[course].status.passed) {
|
||||
studentStats[studentnumber].totals.passed += 1
|
||||
@@ -100,7 +100,7 @@ const getCustomOpenUniCourses = async (courseCodes, startdate, enddate) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
await calulateTotalsForStudent(studentStats, studentnumber)
|
||||
await calculateTotalsForStudent(studentStats, studentnumber)
|
||||
}
|
||||
}
|
||||
const openUniStats = { students: studentStats, courses: courses }
|
||||
|
||||
@@ -1,196 +1,7 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const { Op } = require('sequelize')
|
||||
const { Organization } = require('../models')
|
||||
const Op = Sequelize.Op
|
||||
const { dbConnections } = require('../database/connection')
|
||||
|
||||
// temp refactoring faculty <-> userservice mappings away from userservice
|
||||
const facultiesAndProgrammesInUserService = [
|
||||
['H10', 'MH10_001'],
|
||||
['H10', 'MH40_011'],
|
||||
['H10', 'KH10_001'],
|
||||
['H20', 'KH20_001'],
|
||||
['H20', 'MH20_001'],
|
||||
['H20', 'MH20_003'],
|
||||
['H20', 'MH20_002'],
|
||||
['H30', 'KH30_001'],
|
||||
['H30', 'KH30_002'],
|
||||
['H30', 'MH30_002'],
|
||||
['H30', 'MH30_003'],
|
||||
['H30', 'MH30_004'],
|
||||
['H30', 'MH30_005'],
|
||||
['H30', 'MH30_001'],
|
||||
['H40', 'KH40_001'],
|
||||
['H40', 'KH40_002'],
|
||||
['H40', 'KH40_003'],
|
||||
['H40', 'KH40_004'],
|
||||
['H40', 'KH40_005'],
|
||||
['H40', 'KH40_006'],
|
||||
['H40', 'MH40_001'],
|
||||
['H40', 'MH40_002'],
|
||||
['H40', 'MH40_003'],
|
||||
['H40', 'MH40_004'],
|
||||
['H40', 'MH40_005'],
|
||||
['H40', 'MH40_006'],
|
||||
['H40', 'MH40_007'],
|
||||
['H40', 'MH40_008'],
|
||||
['H40', 'MH40_009'],
|
||||
['H40', 'MH40_010'],
|
||||
['H40', 'MH40_011'],
|
||||
['H40', 'MH40_012'],
|
||||
['H40', 'MH40_013'],
|
||||
['H40', 'MH40_014'],
|
||||
['H40', 'MH40_015'],
|
||||
['H40', 'MH70_001'],
|
||||
['H40', 'MH70_006'],
|
||||
['H50', 'KH50_001'],
|
||||
['H50', 'KH50_002'],
|
||||
['H50', 'KH50_003'],
|
||||
['H50', 'KH50_004'],
|
||||
['H50', 'KH50_005'],
|
||||
['H50', 'KH50_006'],
|
||||
['H50', 'KH50_007'],
|
||||
['H50', 'KH50_008'],
|
||||
['H50', 'MH50_001'],
|
||||
['H50', 'MH50_002'],
|
||||
['H50', 'MH50_003'],
|
||||
['H50', 'MH50_004'],
|
||||
['H50', 'MH50_005'],
|
||||
['H50', 'MH50_006'],
|
||||
['H50', 'MH50_007'],
|
||||
['H50', 'MH50_008'],
|
||||
['H50', 'MH50_009'],
|
||||
['H50', 'MH50_010'],
|
||||
['H50', 'MH50_011'],
|
||||
['H50', 'MH50_012'],
|
||||
['H50', 'MH50_013'],
|
||||
['H50', 'MH50_014'],
|
||||
['H55', 'KH55_001'],
|
||||
['H55', 'MH55_001'],
|
||||
['H55', 'MH55_002'],
|
||||
['H57', 'KH57_001'],
|
||||
['H57', 'KH57_002'],
|
||||
['H57', 'KH57_003'],
|
||||
['H57', 'MH50_002'],
|
||||
['H57', 'MH50_013'],
|
||||
['H57', 'MH57_001'],
|
||||
['H57', 'MH57_002'],
|
||||
['H57', 'MH57_003'],
|
||||
['H57', 'MH57_004'],
|
||||
['H57', 'MH57_005'],
|
||||
['H57', 'MH80_007'],
|
||||
['H60', 'KH60_001'],
|
||||
['H60', 'MH60_001'],
|
||||
['H60', 'MH60_002'],
|
||||
['H70', 'KH40_001'],
|
||||
['H70', 'KH50_001'],
|
||||
['H70', 'KH70_001'],
|
||||
['H70', 'KH70_002'],
|
||||
['H70', 'KH70_003'],
|
||||
['H70', 'KH70_004'],
|
||||
['H70', 'MH50_001'],
|
||||
['H70', 'MH57_005'],
|
||||
['H70', 'MH70_001'],
|
||||
['H70', 'MH70_002'],
|
||||
['H70', 'MH70_003'],
|
||||
['H70', 'MH70_004'],
|
||||
['H70', 'MH70_005'],
|
||||
['H70', 'MH70_006'],
|
||||
['H70', 'MH70_007'],
|
||||
['H70', 'MH70_008'],
|
||||
['H70', 'MH70_009'],
|
||||
['H70', 'MH70_010'],
|
||||
['H70', 'MH70_011'],
|
||||
['H74', 'KH74_001'],
|
||||
['H80', 'KH57_002'],
|
||||
['H80', 'KH80_001'],
|
||||
['H80', 'KH80_002'],
|
||||
['H80', 'KH80_003'],
|
||||
['H80', 'KH80_004'],
|
||||
['H80', 'MH57_002'],
|
||||
['H80', 'MH57_003'],
|
||||
['H80', 'MH57_005'],
|
||||
['H80', 'MH80_001'],
|
||||
['H80', 'MH80_002'],
|
||||
['H80', 'MH80_003'],
|
||||
['H80', 'MH80_004'],
|
||||
['H80', 'MH80_005'],
|
||||
['H80', 'MH80_006'],
|
||||
['H80', 'MH80_007'],
|
||||
['H80', 'MH80_008'],
|
||||
['H90', 'KH90_001'],
|
||||
['H90', 'MH90_001'],
|
||||
['H92', 'T920101'],
|
||||
['H92', 'T920102'],
|
||||
['H92', 'T920103'],
|
||||
['H92', 'T920104'],
|
||||
['H92', 'T920105'],
|
||||
['H92', 'T920106'],
|
||||
['H92', 'T920107'],
|
||||
['H92', 'T920108'],
|
||||
['H92', 'T920109'],
|
||||
['H92', 'T920110'],
|
||||
['H92', 'T920111'],
|
||||
['H92', 'T921101'],
|
||||
['H92', 'T921102'],
|
||||
['H92', 'T921103'],
|
||||
['H92', 'T921104'],
|
||||
['H92', 'T921105'],
|
||||
['H92', 'T921106'],
|
||||
['H92', 'T921107'],
|
||||
['H92', 'T921108'],
|
||||
['H92', 'T921109'],
|
||||
['H92', 'T922101'],
|
||||
['H92', 'T922102'],
|
||||
['H92', 'T922103'],
|
||||
['H92', 'T922104'],
|
||||
['H92', 'T922105'],
|
||||
['H92', 'T922106'],
|
||||
['H92', 'T923101'],
|
||||
['H92', 'T923102'],
|
||||
['H92', 'T923103'],
|
||||
['H92', 'T923104'],
|
||||
['H92', 'T923105'],
|
||||
['H92', 'T923106'],
|
||||
['H92', 'T923107'],
|
||||
['H30', '447000'],
|
||||
['H30', '447001'],
|
||||
['H30', '447002'],
|
||||
['H60', 'EDUK730'],
|
||||
['H60', 'K-MUUT-ERIL'],
|
||||
['H60', 'ED400'],
|
||||
['H60', 'ED200'],
|
||||
['H60', 'ED700'],
|
||||
['H60', 'ED100'],
|
||||
['H60', 'ED300'],
|
||||
['H60', 'ED600'],
|
||||
['H60', '8009'],
|
||||
['H60', '8118'],
|
||||
['H60', '8600'],
|
||||
['H60', 'DUK-PED'],
|
||||
['H60', '65'],
|
||||
['H60', '1102'],
|
||||
['H60', '20018'],
|
||||
['H60', '8601'],
|
||||
['H60', '9290'],
|
||||
['H60', '8172'],
|
||||
['H60', '83600'],
|
||||
['H60', 'DUM-PED'],
|
||||
['H60', 'ED500'],
|
||||
['H60', '83601'],
|
||||
['H60', '0130'],
|
||||
['H60', '20005-e'],
|
||||
['H60', '1040'],
|
||||
['H60', '8703'],
|
||||
['H60', '0005'],
|
||||
['H60', '7900'],
|
||||
['H60', '0152 '],
|
||||
]
|
||||
|
||||
const facultiesAndProgrammesForTrends = facultiesAndProgrammesInUserService.map(f => ({
|
||||
faculty_code: f[0],
|
||||
programme_code: f[1],
|
||||
}))
|
||||
|
||||
// Have facultyfetching to work like it worked during oodi-db time
|
||||
const facultiesInOodi = [
|
||||
'H10',
|
||||
@@ -240,5 +51,4 @@ module.exports = {
|
||||
faculties,
|
||||
isFaculty,
|
||||
providersOfFaculty,
|
||||
facultiesAndProgrammesForTrends,
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
raw: true,
|
||||
}),
|
||||
Enrollment.findAll({
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber', 'semestercode'],
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber', 'semestercode', 'studyright_id'],
|
||||
where: {
|
||||
enrollment_date_time: {
|
||||
[Op.between]: [attainmentDateFrom, endDate],
|
||||
@@ -162,6 +162,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
'cancelled',
|
||||
'is_ba_ma',
|
||||
'semester_enrollments',
|
||||
'actual_studyrightid',
|
||||
],
|
||||
separate: true,
|
||||
include: [
|
||||
@@ -207,6 +208,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
'student_studentnumber',
|
||||
'course_code',
|
||||
'language',
|
||||
'studyright_id',
|
||||
],
|
||||
where: creditsOfStudent,
|
||||
raw: true,
|
||||
|
||||
@@ -97,7 +97,16 @@ const formatStudentForPopulationStatistics = (
|
||||
code,
|
||||
currentSemester
|
||||
) => {
|
||||
const toCourse = ({ grade, attainment_date, credits, course_code, credittypecode, isStudyModule, language }) => {
|
||||
const toCourse = ({
|
||||
grade,
|
||||
attainment_date,
|
||||
credits,
|
||||
course_code,
|
||||
credittypecode,
|
||||
isStudyModule,
|
||||
language,
|
||||
studyright_id,
|
||||
}) => {
|
||||
const attainment_date_normailized =
|
||||
attainment_date < startDate ? startDateMoment.clone().add(1, 'day').toISOString() : attainment_date
|
||||
const passed = Credit.passed({ credittypecode })
|
||||
@@ -111,6 +120,7 @@ const formatStudentForPopulationStatistics = (
|
||||
isStudyModuleCredit: isStudyModule,
|
||||
credittypecode,
|
||||
language,
|
||||
studyright_id,
|
||||
}
|
||||
}
|
||||
const criteriaCoursesBySubstitutions = criteria?.allCourses
|
||||
|
||||
@@ -28,19 +28,24 @@ const { getCourseCodesOfProvider } = require('./providers')
|
||||
'separate'
|
||||
Erillisoikeus: opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai
|
||||
"opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
|
||||
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
|
||||
|
||||
'transferred'
|
||||
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
|
||||
Muut hyväksiluetut opintopisteet: kotimaassa suoritetut, hyväksiluetut opintopisteet.
|
||||
|
||||
Possible ambiguities / mistakes:
|
||||
Rapo may use createddate instead of attainment_date to determine if student had active studyright.
|
||||
This is probably very rare to cause differences, only one such was found in MH60_001
|
||||
|
||||
*/
|
||||
|
||||
const getCategory = (extent, hadDegreeStudyright) => {
|
||||
if ([1, 2, 3, 4].includes(extent) || hadDegreeStudyright) return 'basic' // Rapo-kategoria: Perustutkinto-opiskelijat
|
||||
if (extent === 7 || extent === 34) return 'incoming-exchange' // Saapuvat vaihto-opiskelijat
|
||||
if (extent === 9 && !hadDegreeStudyright) return 'open-uni' // Avoimen opiskeluoikeus, ei tutkinto-opiskelija
|
||||
if (extent === 14) return 'agreement' // Korkeakoulujen väliset yhteistyöopinnot
|
||||
if (extent === 99) return 'separate' // Erillisoikeus
|
||||
return 'other'
|
||||
const getCategory = (extent, degreeStudyright) => {
|
||||
if ([7, 34].includes(extent)) return 'incoming-exchange' // Saapuvat vaihto-opiskelijat
|
||||
if ([9, 31].includes(extent) && !degreeStudyright) return 'open-uni' // Avoimen opiskeluoikeus, ei tutkinto-opiskelija
|
||||
if ([14, 16].includes(extent)) return 'agreement' // Korkeakoulujen väliset yhteistyöopinnot
|
||||
if ([13, 18, 22, 23, 99].includes(extent)) return 'separate' // Erillisoikeus
|
||||
if ([1, 2].includes(extent) || (degreeStudyright && [1, 2].includes(degreeStudyright.extentcode))) return 'basic' // Rapo-kategoria: Perustutkinto-opiskelijat
|
||||
return 'other' // TODO: These still happen, but not a lot. Should be investigated which go here and where they should go instead
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -59,16 +64,14 @@ const findRelevantStudyright = (attainmentDate, studyrights) => {
|
||||
})
|
||||
}
|
||||
|
||||
// At the given date, student had SOME degree-studyright.
|
||||
const isDegreeStudent = (studyrights, date) =>
|
||||
studyrights?.some(sr => {
|
||||
const getDegreeStudyright = (studyrights, date, semestercode) =>
|
||||
studyrights?.find(sr => {
|
||||
const rightExtentCode = [1, 2, 3, 4].includes(sr.extentcode)
|
||||
const rightDates =
|
||||
new Date(sr.startdate).getTime() <= new Date(date).getTime() &&
|
||||
new Date(date).getTime() <= new Date(sr.enddate).getTime()
|
||||
// it is possible to also check if student had active semester enrollment to the studyright here.
|
||||
// However, it is unsure if rapo requires this (seems like maybe not). shouldn't make a huge difference.
|
||||
return rightExtentCode && rightDates
|
||||
const enrolled = sr.semesterEnrollments?.some(e => e.semestercode === semestercode && e.enrollmenttype === 1)
|
||||
return rightExtentCode && rightDates && enrolled
|
||||
})
|
||||
|
||||
const getCreditsProduced = async (provider, isAcademicYear, specialIncluded = true) => {
|
||||
@@ -82,9 +85,23 @@ const getCreditsProduced = async (provider, isAcademicYear, specialIncluded = tr
|
||||
/* Calculates credits produced by provider (programme or faculty) */
|
||||
const computeCreditsProduced = async (providerCode, isAcademicYear, specialIncluded = true) => {
|
||||
const since = new Date('2017-01-01')
|
||||
const providercode = mapToProviders([providerCode])[0]
|
||||
const courses = await getCourseCodesOfProvider(providercode)
|
||||
const credits = await getCreditsForProvider(providercode, courses, since)
|
||||
const rapoFormattedProviderCode = mapToProviders([providerCode])[0]
|
||||
const courses = await getCourseCodesOfProvider(rapoFormattedProviderCode)
|
||||
const credits = await getCreditsForProvider(rapoFormattedProviderCode, courses, since)
|
||||
|
||||
// This part also adds oikis vaasa which is provided by different organization.
|
||||
// Uknown if there are other similar cases!
|
||||
const vaasaCodes = {
|
||||
'200-K001': '200-K0012',
|
||||
'200-M001': '200-M0012',
|
||||
}
|
||||
const vaasaProvider = vaasaCodes[rapoFormattedProviderCode]
|
||||
if (vaasaProvider) {
|
||||
const courses = await getCourseCodesOfProvider(vaasaProvider)
|
||||
const vaasaCredits = await getCreditsForProvider(vaasaProvider, courses, since)
|
||||
credits.push(...vaasaCredits)
|
||||
}
|
||||
|
||||
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
|
||||
|
||||
const transfers = (await allTransfers(providerCode, since)).map(t => t.studyrightid)
|
||||
@@ -107,13 +124,17 @@ const computeCreditsProduced = async (providerCode, isAcademicYear, specialInclu
|
||||
|
||||
const stats = {}
|
||||
|
||||
credits.forEach(({ attainmentDate, studentNumber, credits, studyrightId }) => {
|
||||
credits.forEach(({ attainmentDate, studentNumber, credits, studyrightId, semestercode }) => {
|
||||
const relevantStudyright = studyrightId
|
||||
? studyrightIdToStudyrightMap[studyrightId]
|
||||
: findRelevantStudyright(attainmentDate, studentNumberToStudyrightsMap[studentNumber])
|
||||
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
|
||||
const hadDegreeStudyright = isDegreeStudent(studentNumberToStudyrightsMap[studentNumber], attainmentDate)
|
||||
const category = relevantStudyright ? getCategory(relevantStudyright.extentcode, hadDegreeStudyright) : 'other'
|
||||
const degreeStudyright = getDegreeStudyright(
|
||||
studentNumberToStudyrightsMap[studentNumber],
|
||||
attainmentDate,
|
||||
semestercode
|
||||
)
|
||||
const category = relevantStudyright ? getCategory(relevantStudyright.extentcode, degreeStudyright) : 'other'
|
||||
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
|
||||
stats[attainmentYear].total += credits
|
||||
if (!stats[attainmentYear][category]) {
|
||||
@@ -122,10 +143,13 @@ const computeCreditsProduced = async (providerCode, isAcademicYear, specialInclu
|
||||
stats[attainmentYear][category] += credits
|
||||
})
|
||||
|
||||
const transferredCredits = await getTransferredCredits(providercode, since)
|
||||
|
||||
transferredCredits.forEach(({ attainment_date, credits }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
|
||||
if (vaasaProvider) {
|
||||
const vaasaTransferredCredits = await getTransferredCredits(vaasaProvider, since)
|
||||
transferredCredits.push(...vaasaTransferredCredits)
|
||||
}
|
||||
transferredCredits.forEach(({ createdate, credits }) => {
|
||||
const attainmentYear = defineYear(createdate, isAcademicYear)
|
||||
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
|
||||
stats[attainmentYear].transferred += credits || 0
|
||||
// Transferred not counted in total
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const axios = require('axios')
|
||||
const { SIS_UPDATER_URL, SECRET_TOKEN } = require('../conf-backend')
|
||||
const { Studyplan } = require('../models')
|
||||
const { Studyplan, Student, StudyrightElement } = require('../models')
|
||||
const { Op } = require('sequelize')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
|
||||
@@ -38,6 +39,37 @@ const updateSISRedisCache = async () => {
|
||||
const response = await client.get('/v1/rediscache', params)
|
||||
return response.data
|
||||
}
|
||||
const delay = time => {
|
||||
return new Promise(res => {
|
||||
setTimeout(res, time)
|
||||
})
|
||||
}
|
||||
|
||||
const updateStudentsIndividually = async () => {
|
||||
try {
|
||||
const studentNumbers = await Student.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
where: {
|
||||
code: { [Op.regexp]: '(KH*|MH*)' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const uniqueStudents = [...new Set(studentNumbers.map(s => s.studentnumber))]
|
||||
const chunkSize = 4000
|
||||
for (let from = 0; from < uniqueStudents.length - chunkSize; from += chunkSize) {
|
||||
await client.post(
|
||||
'v1/students',
|
||||
{ studentnumbers: uniqueStudents.slice(from, from + chunkSize), individualMode: true },
|
||||
params
|
||||
)
|
||||
await delay(200)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const studyplansUpdate = async days => {
|
||||
const limitDate = new Date()
|
||||
@@ -67,4 +99,5 @@ module.exports = {
|
||||
abort,
|
||||
updateCoursesByCourseCode,
|
||||
studyplansUpdate,
|
||||
updateStudentsIndividually,
|
||||
}
|
||||
|
||||
@@ -1,56 +1,93 @@
|
||||
const { Op } = require('sequelize')
|
||||
const { Credit, Course, Organization } = require('../../models')
|
||||
const { Credit, Course, Organization, CourseProvider } = require('../../models')
|
||||
const { whereStudents } = require('.')
|
||||
const {
|
||||
dbConnections: { sequelize },
|
||||
} = require('../../database/connection')
|
||||
const { formatCredit } = require('./studyprogrammeHelpers')
|
||||
|
||||
const getCreditsForProvider = async (provider, codes, since) =>
|
||||
(
|
||||
await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cr.id,
|
||||
cr.course_code,
|
||||
cr.attainment_date,
|
||||
cr.student_studentnumber,
|
||||
cr.credits * COALESCE(share_element.share::numeric, 1) AS credits,
|
||||
cr.studyright_id,
|
||||
cr.semestercode
|
||||
FROM
|
||||
credit cr
|
||||
JOIN
|
||||
course_providers cp ON cr.course_id = cp.coursecode
|
||||
JOIN
|
||||
organization o ON cp.organizationcode = o.id
|
||||
LEFT JOIN LATERAL
|
||||
(
|
||||
SELECT (jae ->> 'share')::numeric AS share
|
||||
FROM jsonb_array_elements(cp.shares) AS jae
|
||||
WHERE (cr.attainment_date >= (jae ->> 'startDate')::date OR jae ->> 'startDate' IS NULL)
|
||||
AND (cr.attainment_date <= (jae ->> 'endDate')::date OR jae ->> 'endDate' IS NULL)
|
||||
ORDER BY
|
||||
(jae ->> 'startDate')::date DESC NULLS LAST,
|
||||
(jae ->> 'endDate')::date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) AS share_element ON TRUE
|
||||
WHERE
|
||||
(o.code = :provider)
|
||||
AND cr.attainment_date >= :since
|
||||
AND cr.credittypecode = 4
|
||||
AND cr."isStudyModule" IS NOT TRUE
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { provider, codes, since },
|
||||
}
|
||||
)
|
||||
).map(formatCredit)
|
||||
const getCreditsForProvider = async (provider, codes, since) => {
|
||||
const credits = await Credit.findAll({
|
||||
where: {
|
||||
course_code: { [Op.in]: codes },
|
||||
attainment_date: { [Op.gte]: since },
|
||||
isStudyModule: { [Op.not]: true },
|
||||
credittypecode: 4,
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const courseIds = credits.map(cr => cr.course_id)
|
||||
|
||||
const providers = await CourseProvider.findAll({
|
||||
where: {
|
||||
coursecode: { [Op.in]: courseIds },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
const organizationIds = providers.map(p => p.organizationcode)
|
||||
const organizations = await Organization.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: organizationIds,
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const organizationIdToCodeMap = organizations.reduce((obj, org) => {
|
||||
obj[org.id] = org.code
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const courseIdToShareMap = providers.reduce((obj, provider) => {
|
||||
if (!obj[provider.coursecode]) obj[provider.coursecode] = []
|
||||
obj[provider.coursecode].push(provider)
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const courseIdToShare = (courseId, attainmentDate) => {
|
||||
const providers = courseIdToShareMap[courseId]
|
||||
const relevantProvider = providers.find(p => organizationIdToCodeMap[p.organizationcode] === provider)
|
||||
if (!relevantProvider?.shares) return 0
|
||||
const relevantShare = relevantProvider.shares
|
||||
.filter(share => {
|
||||
const startMatches = new Date(share.startDate) <= attainmentDate || !share.startDate
|
||||
const endMatches = new Date(share.endDate) >= attainmentDate || !share.endDate
|
||||
const bothArentNull = share.startDate || share.endDate
|
||||
return startMatches && endMatches && bothArentNull
|
||||
})
|
||||
// The next mess is for cases where there are multiple shares with only startdate or enddate.
|
||||
// This isn't pretty and it may not be 100% correct but because the
|
||||
// data is all over the place, this is best approximation for now.
|
||||
.sort((a, b) => {
|
||||
if (!b.startDate || !a.startDate) return 0
|
||||
const bTime = b.startDate ? new Date(b.startDate).getTime() : 0
|
||||
const aTime = a.startDate ? new Date(a.startDate).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (!b.endDate || !a.endDate) return 0
|
||||
const bTime = b.endDate ? new Date(b.endDate).getTime() : 0
|
||||
const aTime = a.endDate ? new Date(a.endDate).getTime() : 0
|
||||
return bTime - aTime
|
||||
})[0]
|
||||
|
||||
// Odd logic, but if there are multiple providers for same course code, if no fitting dates are
|
||||
// not found for our relevant provider, we can assume the share of that date is of some other provider.
|
||||
// But if only 1 provider exists, we can assume it has share of 1.
|
||||
if (!relevantShare) {
|
||||
if (providers.length > 1) return 0
|
||||
return 1
|
||||
}
|
||||
return relevantShare.share
|
||||
}
|
||||
|
||||
return credits
|
||||
.map(cr => ({ ...cr, credits: cr.credits * courseIdToShare(cr.course_id, cr.attainment_date, cr.id) }))
|
||||
.map(formatCredit)
|
||||
}
|
||||
|
||||
const getTransferredCredits = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date'],
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'createdate'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
|
||||
@@ -42,6 +42,7 @@ const updateBasicView = async (code, combinedProgramme) => {
|
||||
option.yearType === 'ACADEMIC_YEAR',
|
||||
option.specialGroups === 'SPECIAL_INCLUDED'
|
||||
)
|
||||
|
||||
await setCreditStats(
|
||||
creditStats,
|
||||
option.yearType === 'ACADEMIC_YEAR',
|
||||
|
||||
@@ -3,12 +3,12 @@ const LRU = require('lru-cache')
|
||||
const _ = require('lodash')
|
||||
|
||||
const { getStudentnumbersByElementdetails } = require('./students')
|
||||
const { facultiesAndProgrammesForTrends } = require('../services/organisations')
|
||||
const { sendNotificationAboutNewUser } = require('../services/mailservice')
|
||||
const { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
|
||||
const { User, UserElementDetails, AccessGroup, UserFaculties, sequelizeUser } = require('../models/models_user')
|
||||
const { User, UserElementDetails, AccessGroup } = require('../models/models_user')
|
||||
const { getUserIams, getAllUserAccess, getUserIamAccess } = require('../util/jami')
|
||||
const { createLocaleComparator } = require('../util/utils')
|
||||
const { createLocaleComparator, getFullStudyProgrammeRights } = require('../util/utils')
|
||||
const { sequelizeUser } = require('../database/connection')
|
||||
|
||||
const courseStatisticsGroup = 'grp-oodikone-basic-users'
|
||||
const facultyStatisticsGroup = 'grp-oodikone-users'
|
||||
@@ -32,11 +32,6 @@ const userIncludes = [
|
||||
as: 'accessgroup',
|
||||
attributes: ['id', 'group_code', 'group_info'],
|
||||
},
|
||||
{
|
||||
separate: true,
|
||||
model: UserFaculties,
|
||||
as: 'faculty',
|
||||
},
|
||||
]
|
||||
|
||||
const accessGroupsByCodes = codes =>
|
||||
@@ -64,13 +59,33 @@ const byId = async id =>
|
||||
include: userIncludes,
|
||||
})
|
||||
|
||||
// Crud things from old userservice
|
||||
const deleteOutdatedUsers = async () =>
|
||||
await sequelizeUser.transaction(async t => {
|
||||
const outdatedUsersCTE = `
|
||||
WITH outdated_users AS (
|
||||
SELECT id FROM users
|
||||
WHERE last_login IS NULL OR last_login < CURRENT_DATE - INTERVAL '18 months'
|
||||
)`
|
||||
|
||||
// some fields that frontend needs
|
||||
const formatUserForAdminView = user => ({
|
||||
...user.get(),
|
||||
elementdetails: user.programme.map(p => p.elementDetailCode),
|
||||
})
|
||||
await sequelizeUser.query(
|
||||
`${outdatedUsersCTE} DELETE FROM user_faculties WHERE "userId" IN (SELECT id FROM outdated_users)`,
|
||||
{ transaction: t }
|
||||
)
|
||||
await sequelizeUser.query(
|
||||
`${outdatedUsersCTE} DELETE FROM user_accessgroup WHERE "userId" IN (SELECT id FROM outdated_users)`,
|
||||
{ transaction: t }
|
||||
)
|
||||
await sequelizeUser.query(
|
||||
`${outdatedUsersCTE} DELETE FROM user_elementdetails WHERE "userId" IN (SELECT id FROM outdated_users)`,
|
||||
{ transaction: t }
|
||||
)
|
||||
const [results] = await sequelizeUser.query(
|
||||
`${outdatedUsersCTE} DELETE FROM users WHERE id IN (SELECT id FROM outdated_users) RETURNING id`,
|
||||
{ transaction: t }
|
||||
)
|
||||
|
||||
return results.length
|
||||
})
|
||||
|
||||
const addProgrammes = async (id, codes) => {
|
||||
for (const code of codes) {
|
||||
@@ -119,33 +134,22 @@ const modifyAccess = async body => {
|
||||
const { username, accessgroups } = body
|
||||
await modifyRights(username, accessgroups)
|
||||
userDataCache.del(username)
|
||||
return formatUserForAdminView(await byUsername(username))
|
||||
const updatedUser = await byUsername(username)
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
const enableElementDetails = async (id, codes) => {
|
||||
await addProgrammes(id, codes)
|
||||
const user = await byId(id)
|
||||
userDataCache.del(user.username)
|
||||
return formatUserForAdminView(user)
|
||||
}
|
||||
|
||||
const setFaculties = async (id, faculties) => {
|
||||
await sequelizeUser.transaction(async transaction => {
|
||||
await UserFaculties.destroy({ where: { userId: id }, transaction })
|
||||
for (const faculty of faculties) {
|
||||
await UserFaculties.create({ userId: id, faculty_code: faculty }, { transaction })
|
||||
}
|
||||
})
|
||||
const user = await byId(id)
|
||||
userDataCache.del(user.username)
|
||||
return formatUserForAdminView(user)
|
||||
return user
|
||||
}
|
||||
|
||||
const removeElementDetails = async (id, codes) => {
|
||||
await removeProgrammes(id, codes)
|
||||
const user = await byId(id)
|
||||
userDataCache.del(user.userId)
|
||||
return formatUserForAdminView(user)
|
||||
return user
|
||||
}
|
||||
|
||||
const updateUser = async (username, fields) => {
|
||||
@@ -158,16 +162,38 @@ const updateUser = async (username, fields) => {
|
||||
|
||||
userDataCache.del(username)
|
||||
|
||||
return formatUserForAdminView(await byUsername(username))
|
||||
const updatedUser = await byUsername(username)
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
const getAccessGroups = async () => await AccessGroup.findAll()
|
||||
|
||||
const getStudyProgrammeRights = (iamAccess, specialGroup, userProgrammes) => {
|
||||
const iamBasedProgrammes = []
|
||||
|
||||
for (const [programmeCode, rights] of Object.entries(iamAccess || {})) {
|
||||
if (specialGroup.kosu || rights.admin) {
|
||||
iamBasedProgrammes.push({ code: programmeCode, limited: false, isIamBased: true })
|
||||
} else {
|
||||
iamBasedProgrammes.push({ code: programmeCode, limited: true, isIamBased: true })
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...iamBasedProgrammes,
|
||||
...userProgrammes.map(({ elementDetailCode }) => ({
|
||||
code: elementDetailCode,
|
||||
limited: false,
|
||||
isIamBased: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const updateAccessGroups = async (username, iamGroups = [], specialGroup = {}, sisId, user) => {
|
||||
const { jory, hyOne, superAdmin, openUni, katselmusViewer } = specialGroup
|
||||
|
||||
const userFromDb = user || (await byUsername(username))
|
||||
const formattedUser = await formatUser(userFromDb, [], Boolean(sisId))
|
||||
const formattedUser = await formatUser(userFromDb, [], [], Boolean(sisId))
|
||||
|
||||
const { roles: currentAccessGroups } = formattedUser
|
||||
|
||||
@@ -202,10 +228,6 @@ const updateAccessGroups = async (username, iamGroups = [], specialGroup = {}, s
|
||||
return { newAccessGroups, accessGroups }
|
||||
}
|
||||
|
||||
// newer logic
|
||||
const enrichProgrammesFromFaculties = faculties =>
|
||||
facultiesAndProgrammesForTrends.filter(f => faculties.includes(f.faculty_code)).map(f => f.programme_code)
|
||||
|
||||
const findAll = async () => {
|
||||
const allUsers = await User.findAll({
|
||||
include: userIncludes,
|
||||
@@ -220,18 +242,15 @@ const findAll = async () => {
|
||||
|
||||
const formattedUsers = await Promise.all(
|
||||
allUsers.map(async user => {
|
||||
const { iamGroups, specialGroup } = userAccessMap[user.sisu_person_id] || {}
|
||||
const { iamGroups, specialGroup, access } = userAccessMap[user.sisu_person_id] || {}
|
||||
|
||||
const { accessGroups } = await updateAccessGroups(user.username, iamGroups, specialGroup, null, user)
|
||||
|
||||
return {
|
||||
...user.get(),
|
||||
iam_groups: iamGroups || [],
|
||||
elementdetails: _.uniqBy([
|
||||
...user.programme.map(p => p.elementDetailCode),
|
||||
...enrichProgrammesFromFaculties(user.faculty.map(f => f.faculty_code)),
|
||||
]),
|
||||
accessgroup: accessGroups,
|
||||
programmeRights: getStudyProgrammeRights(access, specialGroup, user.programme),
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -255,22 +274,15 @@ const findOne = async id => {
|
||||
return {
|
||||
...user.get(),
|
||||
iam_groups: iamAccess || [],
|
||||
elementdetails: _.uniqBy([
|
||||
...user.programme.map(p => p.elementDetailCode),
|
||||
...enrichProgrammesFromFaculties(user.faculty.map(f => f.faculty_code)),
|
||||
]),
|
||||
accessgroup: accessGroups,
|
||||
programmeRights: getStudyProgrammeRights(iamAccess, specialGroup, user.programme),
|
||||
}
|
||||
}
|
||||
|
||||
const formatUser = async (userFromDb, extraRights, getStudentAccess = true) => {
|
||||
const [accessGroups, programmes, faculties] = [
|
||||
['accessgroup', 'group_code'],
|
||||
['programme', 'elementDetailCode'],
|
||||
['faculty', 'faculty_code'],
|
||||
].map(([field, code]) => userFromDb[field].map(entry => entry[code]))
|
||||
const formatUser = async (userFromDb, extraRights, programmeRights, getStudentAccess = true) => {
|
||||
const accessGroups = userFromDb.accessgroup.map(entry => entry.group_code)
|
||||
|
||||
const rights = _.uniqBy([...programmes, ...enrichProgrammesFromFaculties(faculties)]).concat(extraRights)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const {
|
||||
dataValues: {
|
||||
@@ -287,7 +299,10 @@ const formatUser = async (userFromDb, extraRights, getStudentAccess = true) => {
|
||||
const studentsUserCanAccess = getStudentAccess
|
||||
? _.uniqBy(
|
||||
(
|
||||
await Promise.all([getStudentnumbersByElementdetails(rights), getAllStudentsUserHasInGroups(sisPersonId)])
|
||||
await Promise.all([
|
||||
getStudentnumbersByElementdetails(fullStudyProgrammeRights),
|
||||
getAllStudentsUserHasInGroups(sisPersonId),
|
||||
])
|
||||
).flat()
|
||||
)
|
||||
: []
|
||||
@@ -301,10 +316,10 @@ const formatUser = async (userFromDb, extraRights, getStudentAccess = true) => {
|
||||
sisPersonId, //
|
||||
iamGroups,
|
||||
email,
|
||||
rights,
|
||||
roles: accessGroups,
|
||||
studentsUserCanAccess, // studentnumbers used in various parts of backend. For admin this is usually empty, since no programmes / faculties are set.
|
||||
isAdmin: accessGroups.includes('admin'),
|
||||
programmeRights,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +360,9 @@ const getMockedUser = async ({ userToMock, mockedBy }) => {
|
||||
|
||||
const iamRights = Object.keys(access)
|
||||
|
||||
const formattedUser = await formatUser(userFromDb, specialGroup.kosu ? iamRights : [])
|
||||
const programmeRights = getStudyProgrammeRights(access, specialGroup, userFromDb.programme)
|
||||
|
||||
const formattedUser = await formatUser(userFromDb, specialGroup.kosu ? iamRights : [], programmeRights)
|
||||
|
||||
const toReturn = {
|
||||
...formattedUser,
|
||||
@@ -359,7 +376,7 @@ const getMockedUser = async ({ userToMock, mockedBy }) => {
|
||||
return toReturn
|
||||
}
|
||||
|
||||
const getUser = async ({ username, name, email, iamGroups, iamRights, specialGroup, sisId }) => {
|
||||
const getUser = async ({ username, name, email, iamGroups, iamRights, specialGroup, sisId, access }) => {
|
||||
if (userDataCache.has(username)) {
|
||||
return userDataCache.get(username)
|
||||
}
|
||||
@@ -378,7 +395,8 @@ const getUser = async ({ username, name, email, iamGroups, iamRights, specialGro
|
||||
|
||||
const userFromDb = await byUsername(username)
|
||||
userFromDb.iamGroups = await getUserIams(userFromDb.sisu_person_id)
|
||||
const formattedUser = await formatUser(userFromDb, specialGroup.kosu ? iamRights : [])
|
||||
const programmeRights = getStudyProgrammeRights(access, specialGroup, userFromDb.programme)
|
||||
const formattedUser = await formatUser(userFromDb, specialGroup.kosu ? iamRights : [], programmeRights)
|
||||
|
||||
const { newAccessGroups } = await updateAccessGroups(username, iamGroups, specialGroup, sisId)
|
||||
|
||||
@@ -402,8 +420,8 @@ module.exports = {
|
||||
findOne,
|
||||
modifyAccess,
|
||||
getAccessGroups,
|
||||
setFaculties,
|
||||
getUser,
|
||||
getMockedUser,
|
||||
getOrganizationAccess,
|
||||
deleteOutdatedUsers,
|
||||
}
|
||||
|
||||
@@ -59,9 +59,13 @@ const createLocaleComparator = (field = null) => {
|
||||
return (val1, val2) => val1[field].localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
|
||||
const getFullStudyProgrammeRights = programmeRights =>
|
||||
programmeRights.filter(({ limited }) => !limited).map(({ code }) => code)
|
||||
|
||||
module.exports = {
|
||||
mapToProviders,
|
||||
sortMainCode,
|
||||
getSortRank,
|
||||
createLocaleComparator,
|
||||
getFullStudyProgrammeRights,
|
||||
}
|
||||
|
||||
@@ -10,37 +10,46 @@
|
||||
},
|
||||
"extends": ["airbnb", "plugin:prettier/recommended", "plugin:import/recommended"],
|
||||
"rules": {
|
||||
"react/forbid-prop-types": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"default-param-last": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/label-has-for": "off",
|
||||
"jsx-a11y/label-has-associated-control": "off",
|
||||
"jsx-a11y/label-has-for": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"no-console": "error",
|
||||
"no-continue": "off",
|
||||
"no-implied-eval": "off",
|
||||
"no-param-reassign": ["error", { "props": false }],
|
||||
"no-plusplus": "off",
|
||||
"no-shadow": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/jsx-filename-extension": ["error", { "allow": "as-needed" }],
|
||||
"quotes": ["error", "single", { "avoidEscape": true }],
|
||||
"react/forbid-prop-types": "off",
|
||||
"react/function-component-definition": [
|
||||
"error",
|
||||
{ "namedComponents": "arrow-function", "unnamedComponents": "arrow-function" }
|
||||
],
|
||||
"default-param-last": "off",
|
||||
"no-implied-eval": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/prefer-stateless-function": "error",
|
||||
"react/no-unstable-nested-components": "off",
|
||||
"react/jsx-no-constructed-context-values": "off",
|
||||
"react/jsx-filename-extension": ["error", { "allow": "as-needed" }],
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-constructed-context-values": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/jsx-sort-props": ["error", { "reservedFirst": false }],
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/no-unknown-property": "off",
|
||||
"react/no-unstable-nested-components": "off",
|
||||
"react/prefer-stateless-function": "error",
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"import/no-default-export": "error",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/no-unused-modules": ["error", { "missingExports": true, "unusedExports": true }],
|
||||
"import/no-commonjs": "error",
|
||||
"quotes": ["error", "single", { "avoidEscape": true }],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"no-console": "error",
|
||||
"no-param-reassign": ["error", { "props": false }],
|
||||
"no-continue": "off"
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": { "order": "asc", "caseInsensitive": true },
|
||||
"groups": ["builtin", "external", "internal", "parent", "sibling"],
|
||||
"pathGroups": [{ "pattern": "@/**", "group": "internal", "position": "before" }]
|
||||
}
|
||||
],
|
||||
"import/prefer-default-export": "off"
|
||||
},
|
||||
"plugins": ["react", "react-hooks"],
|
||||
"settings": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
Generated
+30
-204
@@ -10,9 +10,6 @@
|
||||
"@sentry/browser": "^6.18.2",
|
||||
"@sentry/tracing": "^6.18.2",
|
||||
"axios": "^0.21.4",
|
||||
"chroma-js": "^2.4.2",
|
||||
"highcharts": "^9.3.3",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"immer": "^9.0.12",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
@@ -25,19 +22,17 @@
|
||||
"react-markdown": "^6.0.3",
|
||||
"react-range": "^1.8.12",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"scheduler": "^0.23.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^2.1.5",
|
||||
"unfuck-spa-shibboleth-session": "^3.1.4",
|
||||
"use-context-selector": "^1.3.9",
|
||||
"uuid": "^3.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"vite": "^5.1.3",
|
||||
"vite-tsconfig-paths": "^4.3.1"
|
||||
"vite": "^5.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -1037,14 +1032,6 @@
|
||||
"vite": "^4 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||
@@ -1062,18 +1049,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
|
||||
@@ -1101,11 +1076,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chroma-js": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
|
||||
"integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
@@ -1114,14 +1084,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
|
||||
@@ -1131,17 +1093,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
|
||||
@@ -1246,14 +1197,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1268,25 +1211,11 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/highcharts": {
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-9.3.3.tgz",
|
||||
"integrity": "sha512-QeOvm6cifeZYYdTLm4IxZsXcOE9c4xqfs0z0OJJ0z7hhA9WG0rmcVAyuIp5HBl/znjA/ayYHmpYjBYD/9PG4Fg=="
|
||||
},
|
||||
"node_modules/highcharts-custom-events": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/highcharts-custom-events/-/highcharts-custom-events-3.0.10.tgz",
|
||||
"integrity": "sha512-swD0v8qovr9tzW6wNdebJKWQS5xTUclU2XznZw7+U6zMpedzEw7nxMZCog+P3JBDvp2cLO8ySiy7EC29DayfFw==",
|
||||
"dependencies": {
|
||||
"highcharts": ">=5.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/history": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
|
||||
@@ -1384,6 +1313,11 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz",
|
||||
@@ -1500,20 +1434,6 @@
|
||||
"parse-entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-create-react-context": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
|
||||
"integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.1",
|
||||
"tiny-warning": "^1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.0.0",
|
||||
"react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
@@ -1570,6 +1490,14 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
|
||||
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
|
||||
"dependencies": {
|
||||
"isarray": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
@@ -1703,11 +1631,6 @@
|
||||
"react-dom": "~0.14 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-highcharts/node_modules/highcharts": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-6.2.0.tgz",
|
||||
"integrity": "sha512-A4E89MA+kto8giic7zyLU6ZxfXnVeCUlKOyzFsah3+n4BROx4bgonl92KIBtwLud/mIWir8ahqhuhe2by9LakQ=="
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -1812,15 +1735,14 @@
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.3.tgz",
|
||||
"integrity": "sha512-mzQGUvS3bM84TnbtMYR8ZjKnuPJ71IjSzR+DE6UkUqvN4czWIqEs17yLL8xkAycv4ev0AiN+IGrWu88vJs/p2w==",
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
|
||||
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
"hoist-non-react-statics": "^3.1.0",
|
||||
"loose-envify": "^1.3.1",
|
||||
"mini-create-react-context": "^0.4.0",
|
||||
"path-to-regexp": "^1.7.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react-is": "^16.6.0",
|
||||
@@ -1832,15 +1754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-Ov0tGPMBgqmbu5CDmN++tv2HQ9HlWDuWIIqn4b88gjlAN5IHI+4ZUZRcpz9Hl0azFIwihbLDYw1OiHGRo7ZIng==",
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
|
||||
"integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
"loose-envify": "^1.3.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"react-router": "5.3.3",
|
||||
"react-router": "5.3.4",
|
||||
"tiny-invariant": "^1.0.2",
|
||||
"tiny-warning": "^1.0.0"
|
||||
},
|
||||
@@ -1848,19 +1770,6 @@
|
||||
"react": ">=15"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
|
||||
},
|
||||
"node_modules/react-router/node_modules/path-to-regexp": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
|
||||
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
|
||||
"dependencies": {
|
||||
"isarray": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz",
|
||||
@@ -2019,17 +1928,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-uri-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||
@@ -2047,9 +1945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
|
||||
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg=="
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
@@ -2065,26 +1963,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfck": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.0.2.tgz",
|
||||
"integrity": "sha512-6lWtFjwuhS3XI4HsX4Zg0izOI3FU/AI9EGVlPEUMDIhvLPMD4wkiof0WCoDgW7qY+Dy198g4d9miAqUHWHFH6Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsconfck": "bin/tsconfck.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -2217,15 +2095,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/value-equal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
|
||||
@@ -2314,54 +2183,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.1.tgz",
|
||||
"integrity": "sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"globrex": "^0.1.2",
|
||||
"tsconfck": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz",
|
||||
"integrity": "sha512-hA7SYmn/H3cJ1VGi7kmSKxbEXCEn5UVJlwYFSFXhLe54wPUvW+80TmR5l+TZh4nJCw8G/e0SuZUfvqlTIh40hw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
"@sentry/browser": "^6.18.2",
|
||||
"@sentry/tracing": "^6.18.2",
|
||||
"axios": "^0.21.4",
|
||||
"chroma-js": "^2.4.2",
|
||||
"highcharts": "^9.3.3",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"immer": "^9.0.12",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
@@ -26,18 +23,21 @@
|
||||
"react-markdown": "^6.0.3",
|
||||
"react-range": "^1.8.12",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"scheduler": "^0.23.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^2.1.5",
|
||||
"unfuck-spa-shibboleth-session": "^3.1.4",
|
||||
"use-context-selector": "^1.3.9",
|
||||
"uuid": "^3.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"vite": "^5.1.3",
|
||||
"vite-tsconfig-paths": "^4.3.1"
|
||||
"vite": "^5.1.3"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
"highcharts": "^9.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { showAsUserKey } from 'common'
|
||||
import { apiBasePath, isDev } from '../conf'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import axios from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
|
||||
const getHeaders = () => {
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
|
||||
@@ -28,7 +28,7 @@ export const courseStatisticsToolTips = {
|
||||
},
|
||||
PassRate: {
|
||||
ATTEMPTS: `
|
||||
**Pass rate chart - Attempts**
|
||||
**Pass rate - Attempts**
|
||||
|
||||
Graafi näyttää suorituskerrat lukuvuositasolla jaoteltuina hyväksyttyihin ja hylättyihin suorituksiin.
|
||||
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv.
|
||||
@@ -36,7 +36,7 @@ export const courseStatisticsToolTips = {
|
||||
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin).
|
||||
`,
|
||||
STUDENTS: `
|
||||
**Pass rate chart - Students**
|
||||
**Pass rate - Students**
|
||||
|
||||
Graafi näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
|
||||
Jokainen opiskelija on jaoteltu luokkiin: "läpäissyt ensiyrittämällä", "läpäissyt lopulta" tai "ei koskaan läpäissyt kurssia".
|
||||
|
||||
@@ -19,27 +19,32 @@ export const facultyToolTips = {
|
||||
|
||||
HUOM! Siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
|
||||
`,
|
||||
CreditsProducedByTheFaculty: `
|
||||
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan.
|
||||
CreditsProducedByTheFaculty: `Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan. Kategoriat vastaavat Rapon kategorioita. Pistemäärät ovat osin epätarkkoja ennen vuotta 2022 sisu-oodi-erojen vuoksi.
|
||||
|
||||
**Major students credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus johonkin tiedekunnan koulutusohjelmaan. Sisältää myös tiedekunnan ohjelmaan siirtyneet ensisijaiset opiskelijat.
|
||||
Kurssin järjestäjä voi olla joko tiedekunnan koulutusohjelma tai tiedekunta itse. Tiedekunnan pisteet on laskettu yhteen lukemiin, ja näkyy erillisenä koulutusohjelmien kanssa tarkasteltaessa taulukosta aukeavia vuosia.
|
||||
|
||||
**Non-major faculty students credits**: Sisältää tiedekunnan koulutusohjelmien tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan,
|
||||
MUTTA hänellä on sellainen johonkin toiseen tiedekunnan ohjelmaan.
|
||||
Total-kategoriaan ei lasketa mukaan hyväksiluettuja opintopisteitä.
|
||||
|
||||
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
|
||||
|
||||
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
|
||||
|
||||
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia.
|
||||
Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
|
||||
|
||||
**Non-major other faculty students credits**: Sisältää tiedekunnan koulutusohjelmien tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan tai mihinkään muuhun kyseisen tiedekunnan koulutusohjelmaan.
|
||||
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
|
||||
|
||||
**Non-degree student credits**: Sisältää tiedekuntaan suoritetut opintopisteet, joiden suorittajilla ei ole tutkinto-oikeutta Helsingin yliopistossa **ja jotka on suoritettu**
|
||||
- Avoimessa yliopistossa
|
||||
- erikoistumiskoulutuksessa
|
||||
- erillisoikeudella
|
||||
- korkeakoulujen välisessä yhteistyöopinnoissa
|
||||
Lisäksi painamalla "Show special categories" -valintaa, saat näkyviin myös harvinaisemmat kategoriat:
|
||||
|
||||
**Other non-degree credits**: Sisältää tiedekuntaan suoritetut opintopisteet, joiden suorittajilla ei ole tutkinto-oikeutta Helsingin yliopistossa **ja jotka on suoritettu**
|
||||
- täydennyskoulutuksessa
|
||||
- erikoislääkärikoulutuksessa
|
||||
- vaihto-opiskelijat
|
||||
**Special**:
|
||||
Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
|
||||
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
|
||||
|
||||
**Other university**:
|
||||
Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
|
||||
|
||||
`,
|
||||
|
||||
GraduatedOfTheFaculty:
|
||||
'Sisältää kyseisenä vuonna tiedekunnasta valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.',
|
||||
ThesisWritersOfTheFaculty:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export { populationStatisticsToolTips } from './populationStatistics'
|
||||
export { courseStatisticsToolTips } from './courseStatistics'
|
||||
export { coursePopulationToolTips } from './coursePopulation'
|
||||
export { studyProgrammeToolTips } from './studyprogramme'
|
||||
export { facultyToolTips } from './faculty'
|
||||
export { filterToolTips } from './filters'
|
||||
export { populationStatisticsToolTips } from './populationStatistics'
|
||||
export { studentToolTips } from './students'
|
||||
export { studyProgrammeToolTips } from './studyprogramme'
|
||||
export { userToolTips } from './users'
|
||||
|
||||
@@ -36,7 +36,7 @@ export const studyProgrammeToolTips = {
|
||||
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia.
|
||||
Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
|
||||
|
||||
**Transferred**: Hyväksiluetut opintopisteet
|
||||
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
|
||||
|
||||
Lisäksi painamalla "Show special categories" -valintaa, saat näkyviin myös harvinaisemmat kategoriat:
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const userToolTips = {
|
||||
IamGroupBasedAccess: `
|
||||
# Currently this description is more like how things should be, not how they are. In particular, full rights based on IAM groups are not implemented.
|
||||
|
||||
The exclamation mark means that the user has **limited rights** to the study programme. The check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
|
||||
|
||||
Both of these icons are displayed: the relevant one is green, the irrelevant one is greyed out.
|
||||
|
||||
**Limited rights** are granted to the following people:
|
||||
- member of the steering group (johtoryhmä) of the study programme.
|
||||
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty.
|
||||
- The following people get limited rights to **all** study programmes:
|
||||
- heads of academic affairs (opintoasiainpäälliköt)
|
||||
- employees of ospa (opetuksen strategiset palvelut)
|
||||
- rector, vice-rectors and director of administration (rehtori, vararehtorit ja hallintojohtaja)
|
||||
- members of the academic affairs council (opintoasiainneuvosto)
|
||||
|
||||
**Full rights** are granted to the following people:
|
||||
- education planning officer (koulutussuunnittelija) of the corresponding faculty.
|
||||
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty.
|
||||
- head **and** member of the steering group of the studyprogramme (johtoryhmän jäsen ja koulutusohjelman johtaja).
|
||||
`,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Popup, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { StudentInfoItem } from 'components/common/StudentInfoItem'
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
|
||||
export const getCopyableStudentNumberColumn = ({
|
||||
popupStates,
|
||||
@@ -18,28 +18,28 @@ export const getCopyableStudentNumberColumn = ({
|
||||
<>
|
||||
Student number
|
||||
<Popup
|
||||
content="Copied student numbers!"
|
||||
on="click"
|
||||
onClose={() => handlePopupClose('studentnumbers')}
|
||||
onOpen={() => handlePopupOpen('studentnumbers')}
|
||||
open={popupStates.studentnumbers}
|
||||
position="top right"
|
||||
trigger={
|
||||
<Icon
|
||||
size="large"
|
||||
color="grey"
|
||||
link
|
||||
name="copy"
|
||||
onClick={event => copyItemsToClipboard(event, fieldName)}
|
||||
size="large"
|
||||
style={{ float: 'right', marginLeft: '0.25em' }}
|
||||
color="grey"
|
||||
title="Click here to copy all student numbers onto your clipboard"
|
||||
/>
|
||||
}
|
||||
content="Copied student numbers!"
|
||||
on="click"
|
||||
open={popupStates.studentnumbers}
|
||||
onClose={() => handlePopupClose('studentnumbers')}
|
||||
onOpen={() => handlePopupOpen('studentnumbers')}
|
||||
position="top right"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
getRowContent: s => <StudentInfoItem student={s} showSisuLink tab="General Tab" />,
|
||||
getRowContent: s => <StudentInfoItem showSisuLink student={s} tab="General Tab" />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,26 +64,26 @@ export const getCopyableEmailColumn = ({
|
||||
<>
|
||||
Email
|
||||
<Popup
|
||||
content="Copied email list!"
|
||||
on="click"
|
||||
onClose={() => handlePopupClose('emails')}
|
||||
onOpen={() => handlePopupOpen('emails')}
|
||||
open={popupStates.emails}
|
||||
position="top right"
|
||||
trigger={
|
||||
<Icon
|
||||
size="large"
|
||||
color="grey"
|
||||
link
|
||||
name="copy"
|
||||
onClick={event => copyItemsToClipboard(event, fieldName)}
|
||||
size="large"
|
||||
style={{
|
||||
float: 'right',
|
||||
marginLeft: '0.25em',
|
||||
}}
|
||||
color="grey"
|
||||
title="Click here to copy all emails onto your clipboard"
|
||||
/>
|
||||
}
|
||||
content="Copied email list!"
|
||||
on="click"
|
||||
open={popupStates.emails}
|
||||
onClose={() => handlePopupClose('emails')}
|
||||
onOpen={() => handlePopupOpen('emails')}
|
||||
position="top right"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -98,6 +98,12 @@ export const getCopyableEmailColumn = ({
|
||||
getRowContent: s =>
|
||||
s.email && !s.obfuscated ? (
|
||||
<Popup
|
||||
content="Email copied!"
|
||||
on="click"
|
||||
onClose={() => handlePopupClose(s.studentNumber)}
|
||||
onOpen={() => handlePopupOpen(s.studentNumber)}
|
||||
open={popupStates[s.studentNumber]}
|
||||
position="top right"
|
||||
trigger={
|
||||
<Icon
|
||||
link
|
||||
@@ -106,12 +112,6 @@ export const getCopyableEmailColumn = ({
|
||||
style={{ float: 'right' }}
|
||||
/>
|
||||
}
|
||||
content="Email copied!"
|
||||
on="click"
|
||||
open={popupStates[s.studentNumber]}
|
||||
onClose={() => handlePopupClose(s.studentNumber)}
|
||||
onOpen={() => handlePopupOpen(s.studentNumber)}
|
||||
position="top right"
|
||||
/>
|
||||
) : null,
|
||||
cellProps: { className: 'iconCellNoPointer' },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { SEARCH_HISTORY_VERSION } from '../constants'
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
|
||||
import { SEARCH_HISTORY_VERSION } from '@/constants'
|
||||
|
||||
export const useTabChangeAnalytics = () => {
|
||||
const previousTabIndex = React.useRef(0)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { filter, intersection, maxBy, range } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { range, filter, maxBy, intersection } from 'lodash'
|
||||
import toskaLogo from '../assets/toska.png'
|
||||
import irtomikko from '../assets/irtomikko.png'
|
||||
|
||||
import irtomikko from '@/assets/irtomikko.png'
|
||||
import toskaLogo from '@/assets/toska.png'
|
||||
|
||||
export const textAndDescriptionSearch = (dropDownOptions, param) =>
|
||||
filter(dropDownOptions, option =>
|
||||
@@ -409,9 +410,40 @@ export const getCurrentSemester = allSemesters => {
|
||||
*/
|
||||
export const createLocaleComparator = (field = null) => {
|
||||
if (!field) return (val1, val2) => val1.localeCompare(val2, 'fi', { sensitivity: 'accent' })
|
||||
return (val1, val2) => val1[field].localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
|
||||
return (val1, val2) => val1[field]?.localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
|
||||
const interpolateColor = (color1, color2, factor) =>
|
||||
color1.map((component, index) => Math.round(component + factor * (color2[index] - component)))
|
||||
|
||||
const convertRgbToHex = rgb => `#${rgb.map(x => x.toString(16).padStart(2, '0')).join('')}`
|
||||
|
||||
export const generateGradientColors = steps => {
|
||||
const startColor = [230, 96, 103] // red
|
||||
const midColor = [245, 233, 132] // yellow
|
||||
const endColor = [0, 140, 89] // green
|
||||
const gradientColors = []
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const factor = i / (steps - 1)
|
||||
let color
|
||||
|
||||
if (i <= (steps - 1) / 2) {
|
||||
color = interpolateColor(startColor, midColor, factor * 2)
|
||||
} else {
|
||||
color = interpolateColor(midColor, endColor, (factor - 0.5) * 2)
|
||||
}
|
||||
|
||||
gradientColors.push(convertRgbToHex(color))
|
||||
}
|
||||
return gradientColors
|
||||
}
|
||||
|
||||
export const getFullStudyProgrammeRights = programmeRights =>
|
||||
programmeRights ? programmeRights.filter(({ limited }) => !limited).map(({ code }) => code) : []
|
||||
|
||||
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
|
||||
|
||||
// These are the new Bachelor's programmes in Matlu, that have BH possibility
|
||||
export const bachelorHonoursProgrammes = [
|
||||
'KH50_001',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import { string, arrayOf } from 'prop-types'
|
||||
import Highcharts from 'highcharts'
|
||||
import React from 'react'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { graphDataType } from '../../../constants/types'
|
||||
|
||||
import { graphDataType } from '@/constants/types'
|
||||
import './multicolorBarChart.css'
|
||||
|
||||
export const MulticolorBarChart = props => {
|
||||
@@ -10,9 +10,6 @@ export const MulticolorBarChart = props => {
|
||||
if (chartData.length > 0) {
|
||||
return (
|
||||
<ReactHighchart
|
||||
highcharts={Highcharts}
|
||||
constructorType="barChart"
|
||||
neverReflow
|
||||
config={{
|
||||
title: {
|
||||
text: chartTitle,
|
||||
@@ -30,6 +27,7 @@ export const MulticolorBarChart = props => {
|
||||
},
|
||||
series: chartData,
|
||||
}}
|
||||
neverReflow
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Transition, Dimmer, Header, Image, Container, Button } from 'semantic-ui-react'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { random } from 'lodash'
|
||||
import { useLogoutMutation } from 'redux/auth'
|
||||
import { images } from '../../common'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { Transition, Dimmer, Header, Image, Container, Button } from 'semantic-ui-react'
|
||||
|
||||
import { images } from '@/common'
|
||||
import { useLogoutMutation } from '@/redux/auth'
|
||||
import { MulticolorBarChart } from './MulticolorBarChart'
|
||||
|
||||
// add github handles of oodikone devs here
|
||||
@@ -58,19 +58,15 @@ export const AccessDenied = ({ notEnabled }) => {
|
||||
<div>
|
||||
<Container style={{ display: 'flex', alignItems: 'stretch', height: '100vh', justifyContent: 'space-evenly' }}>
|
||||
<ReactHighchart
|
||||
highcharts={Highcharts}
|
||||
constructorType="stockChart"
|
||||
config={{
|
||||
title: {
|
||||
text: 'Students of Computer Science 2018-2020',
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
title: {
|
||||
text: 'Cumulative credits',
|
||||
},
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
series: {
|
||||
label: {
|
||||
@@ -79,35 +75,32 @@ export const AccessDenied = ({ notEnabled }) => {
|
||||
pointStart: 2010,
|
||||
},
|
||||
},
|
||||
|
||||
series: dummyData,
|
||||
}}
|
||||
/>
|
||||
|
||||
<MulticolorBarChart
|
||||
chartTitle="Your students' future"
|
||||
chartData={dummyData.map(e => ({ name: e.name, data: [Math.max(...e.data)] }))}
|
||||
chartTitle="Your students' future"
|
||||
/>
|
||||
</Container>
|
||||
<Dimmer active page>
|
||||
<Image src={images.toskaLogo} size="medium" centered style={{ paddingTop: '2%' }} />
|
||||
<Image centered size="medium" src={images.toskaLogo} style={{ paddingTop: '2%' }} />
|
||||
<Header as="h2" inverted>
|
||||
<p>{header}</p>
|
||||
<Header.Subheader>{subheader}</Header.Subheader>
|
||||
<br />
|
||||
<Button onClick={() => logout()} color="pink">
|
||||
{' '}
|
||||
Log out{' '}
|
||||
<Button color="pink" onClick={() => logout()}>
|
||||
Log out
|
||||
</Button>
|
||||
</Header>
|
||||
</Dimmer>
|
||||
<Transition visible={easterEgg} animation="fly up" duration={10000}>
|
||||
<Transition animation="fly up" duration={10000} visible={easterEgg}>
|
||||
<Image
|
||||
src={images.irtomikko}
|
||||
size="huge"
|
||||
verticalAlign="top"
|
||||
inline
|
||||
size="huge"
|
||||
src={images.irtomikko}
|
||||
style={{ position: 'absolute', top: '350px', right: '10px' }}
|
||||
verticalAlign="top"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import React, { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
|
||||
import { NavigationBar } from 'components/NavigationBar'
|
||||
import { Routes } from 'components/Routes'
|
||||
import { SegmentDimmer } from 'components/SegmentDimmer'
|
||||
import { AccessDenied } from '@/components/AccessDenied'
|
||||
import { NavigationBar } from '@/components/NavigationBar'
|
||||
import { Routes } from '@/components/Routes'
|
||||
import { SegmentDimmer } from '@/components/SegmentDimmer'
|
||||
import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import './app.css'
|
||||
import { isProduction } from 'conf'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { AccessDenied } from 'components/AccessDenied'
|
||||
|
||||
const addUserDetailsToLoggers = ({ id, userId, mockedBy }) => {
|
||||
if (!isProduction || !id || !userId) return
|
||||
@@ -22,7 +22,7 @@ const addUserDetailsToLoggers = ({ id, userId, mockedBy }) => {
|
||||
const Layout = ({ children }) => (
|
||||
<div className="appContainer">
|
||||
<main className="routeViewContainer">
|
||||
<header className="header" role="banner" id="main-menu">
|
||||
<header className="header" id="main-menu" role="banner">
|
||||
<NavigationBar />
|
||||
</header>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { bool, func, number, string } from 'prop-types'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Input } from 'semantic-ui-react'
|
||||
import { func, string, number, bool } from 'prop-types'
|
||||
import { Timeout } from '../Timeout'
|
||||
|
||||
import { Timeout } from '@/components/Timeout'
|
||||
|
||||
const TIMEOUTS = {
|
||||
FETCH: 'fetch',
|
||||
@@ -57,13 +58,13 @@ const AutoSubmitSearchInput = ({
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
disabled={disabled}
|
||||
fluid
|
||||
icon={icon}
|
||||
value={value}
|
||||
loading={loading}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import _ from 'lodash'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import { SortableTable, row } from 'components/SortableTable'
|
||||
import {
|
||||
calculateTotals,
|
||||
emptyFields,
|
||||
useColorizedCoursesTableContext,
|
||||
} from '@/components/ColorizedCoursesTable/common'
|
||||
import '@/components/ColorizedCoursesTable/index.css'
|
||||
import {
|
||||
ColorModeSelector,
|
||||
NumberModeSelector,
|
||||
SemesterRangeSelector,
|
||||
} from '@/components/ColorizedCoursesTable/selectorComponents'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { getColumns } from './logic'
|
||||
import { calculateTotals, useColorizedCoursesTableContext, emptyFields } from '../common'
|
||||
import { ColorModeSelector, NumberModeSelector, SemesterRangeSelector } from '../selectorComponents'
|
||||
import '../index.css'
|
||||
|
||||
export const FacultiesTab = () => {
|
||||
const { numberMode, colorMode, semesterFilter, setSemesterFilter, selectedSemesters, data, facultyMap } =
|
||||
const { numberMode, colorMode, semesterFilter, setSemesterFilter, selectedSemesters, data } =
|
||||
useColorizedCoursesTableContext()
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
const facultyQuery = useGetFacultiesQuery()
|
||||
|
||||
const facultyMap = useMemo(
|
||||
() =>
|
||||
facultyQuery.data?.reduce((obj, cur) => {
|
||||
obj[cur.code] = cur.name
|
||||
return obj
|
||||
}, {}),
|
||||
[facultyQuery?.data]
|
||||
)
|
||||
|
||||
const totalRow = useMemo(() => {
|
||||
const totals = calculateTotals(data.tableData, selectedSemesters, data.faculties)
|
||||
@@ -45,10 +64,12 @@ export const FacultiesTab = () => {
|
||||
return tableData
|
||||
}, [selectedSemesters, data])
|
||||
|
||||
if (!facultyMap) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="options-container">
|
||||
<SemesterRangeSelector setSemesterFilter={setSemesterFilter} semesterFilter={semesterFilter} />
|
||||
<SemesterRangeSelector semesterFilter={semesterFilter} setSemesterFilter={setSemesterFilter} />
|
||||
<NumberModeSelector />
|
||||
<ColorModeSelector />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { courseNameColumn, getColor } from '../common'
|
||||
import { courseNameColumn, getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
export const getColumns = (getTextIn, faculties, numberMode, colorMode, facultyMap, allTotal) => {
|
||||
const getFacultyTitle = code => {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { SortableTable, row } from 'components/SortableTable'
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
calculateNewTotalColumnValues,
|
||||
calculateTotals,
|
||||
useColorizedCoursesTableContext,
|
||||
} from '@/components/ColorizedCoursesTable/common'
|
||||
import {
|
||||
ColorModeSelector,
|
||||
NumberModeSelector,
|
||||
SemesterRangeSelector,
|
||||
} from '@/components/ColorizedCoursesTable/selectorComponents'
|
||||
import '@/components/ColorizedCoursesTable/index.css'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { getColumns } from './logic'
|
||||
import { useColorizedCoursesTableContext, calculateTotals, calculateNewTotalColumnValues } from '../common'
|
||||
import '../index.css'
|
||||
import { ColorModeSelector, NumberModeSelector, SemesterRangeSelector } from '../selectorComponents'
|
||||
|
||||
export const SemestersTab = () => {
|
||||
const { getTextIn } = useLanguage()
|
||||
@@ -28,7 +36,7 @@ export const SemestersTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="options-container">
|
||||
<SemesterRangeSelector setSemesterFilter={setSemesterFilter} semesterFilter={semesterFilter} />
|
||||
<SemesterRangeSelector semesterFilter={semesterFilter} setSemesterFilter={setSemesterFilter} />
|
||||
<NumberModeSelector />
|
||||
<ColorModeSelector />
|
||||
</div>
|
||||
@@ -41,8 +49,8 @@ export const SemestersTab = () => {
|
||||
totalRow.bySemesters[numberMode]
|
||||
)}
|
||||
data={tableData}
|
||||
striped={colorMode === 'none'}
|
||||
firstColumnSticky
|
||||
striped={colorMode === 'none'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { courseNameColumn, getColor } from '../common'
|
||||
import { courseNameColumn, getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
export const getColumns = (getTextIn, semesters, numberMode, colorMode, allTotal) => {
|
||||
const columns = [
|
||||
@@ -7,14 +7,17 @@ export const getColumns = (getTextIn, semesters, numberMode, colorMode, allTotal
|
||||
key: `${semester.semestercode}`,
|
||||
title: `${getTextIn(semester.name).replace(' ', '\n')}`,
|
||||
cellProps: row => ({
|
||||
style: getColor(
|
||||
row.bySemesters[semester.semestercode],
|
||||
semesters.length,
|
||||
colorMode,
|
||||
numberMode,
|
||||
row.bySemesters[numberMode],
|
||||
allTotal
|
||||
),
|
||||
style: {
|
||||
...getColor(
|
||||
row.bySemesters[semester.semestercode],
|
||||
semesters.length,
|
||||
colorMode,
|
||||
numberMode,
|
||||
row.bySemesters[numberMode],
|
||||
allTotal
|
||||
),
|
||||
textAlign: 'right',
|
||||
},
|
||||
}),
|
||||
getRowVal: row => row.bySemesters[semester.semestercode]?.[numberMode] ?? 0,
|
||||
filterType: 'range',
|
||||
@@ -24,10 +27,12 @@ export const getColumns = (getTextIn, semesters, numberMode, colorMode, allTotal
|
||||
title: 'Total',
|
||||
getRowVal: row => row.bySemesters[numberMode] ?? 0,
|
||||
cellProps: row => ({
|
||||
style:
|
||||
colorMode === 'course'
|
||||
style: {
|
||||
...(colorMode === 'course'
|
||||
? {}
|
||||
: getColor(row.bySemesters, semesters.length, colorMode, numberMode, null, allTotal),
|
||||
: getColor(row.bySemesters, semesters.length, colorMode, numberMode, null, allTotal)),
|
||||
textAlign: 'right',
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ export const courseNameColumn = getTextIn => ({
|
||||
title: 'Course',
|
||||
getRowVal: row => row.code,
|
||||
getRowContent: row => (
|
||||
<div title={getTextIn(row.name)} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }} title={getTextIn(row.name)}>
|
||||
<b>{row.code}</b>
|
||||
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name && getTextIn(row.name)}</i>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { useTitle } from 'common/hooks'
|
||||
import { useGetFacultiesQuery } from 'redux/facultyStats'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import { getCurrentSemester } from 'common'
|
||||
import { getCurrentSemester } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
import { SemestersTab } from './SemestersTab'
|
||||
@@ -20,16 +19,6 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
// 135 = Fall 2017
|
||||
sem => sem.semestercode >= 135 && sem.semestercode <= currentSemester.semestercode
|
||||
)
|
||||
const facultyQuery = useGetFacultiesQuery()
|
||||
|
||||
const facultyMap = useMemo(
|
||||
() =>
|
||||
facultyQuery.data?.reduce((obj, cur) => {
|
||||
obj[cur.code] = cur.name
|
||||
return obj
|
||||
}, {}),
|
||||
[facultyQuery?.data]
|
||||
)
|
||||
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
|
||||
|
||||
@@ -75,7 +64,7 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
|
||||
if (!data || isFetching || isLoading || !semesterFilter || !semesters?.length || !facultyMap) {
|
||||
if (!data || isFetching || isLoading || !semesterFilter || !semesters?.length) {
|
||||
return <Loader active style={{ marginTop: '15em' }} />
|
||||
}
|
||||
|
||||
@@ -89,7 +78,6 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
setColorMode,
|
||||
selectedSemesters,
|
||||
data,
|
||||
facultyMap,
|
||||
filterEmptyCourses,
|
||||
setFilterEmptyCourses,
|
||||
}
|
||||
@@ -98,7 +86,7 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
<div className="colorized-courses-table">
|
||||
{dividerText && (
|
||||
<Divider style={{ marginTop: '3em' }} horizontal>
|
||||
<Divider horizontal style={{ marginTop: '3em' }}>
|
||||
{dividerText}
|
||||
</Divider>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Dropdown, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useColorizedCoursesTableContext } from './common'
|
||||
import './index.css'
|
||||
|
||||
@@ -18,13 +19,13 @@ export const NumberModeSelector = () => {
|
||||
<b>Show number of</b>
|
||||
{modes.map(mode => (
|
||||
<Radio
|
||||
data-cy={`${mode.value}-button`}
|
||||
name="modeRadioGroup"
|
||||
value={mode.value}
|
||||
label={mode.label}
|
||||
key={mode.value}
|
||||
onChange={() => setNumberMode(mode.value)}
|
||||
checked={numberMode === mode.value}
|
||||
data-cy={`${mode.value}-button`}
|
||||
key={mode.value}
|
||||
label={mode.label}
|
||||
name="modeRadioGroup"
|
||||
onChange={() => setNumberMode(mode.value)}
|
||||
value={mode.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -38,25 +39,25 @@ export const ColorModeSelector = () => {
|
||||
<div className="selector-container">
|
||||
<b>Coloring mode</b>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="course"
|
||||
label="Compare to average of course"
|
||||
onChange={() => setColorMode('course')}
|
||||
checked={colorMode === 'course'}
|
||||
label="Compare to average of course"
|
||||
name="colorModeGroup"
|
||||
onChange={() => setColorMode('course')}
|
||||
value="course"
|
||||
/>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="total"
|
||||
label="Compare to other courses"
|
||||
onChange={() => setColorMode('total')}
|
||||
checked={colorMode === 'total'}
|
||||
label="Compare to other courses"
|
||||
name="colorModeGroup"
|
||||
onChange={() => setColorMode('total')}
|
||||
value="total"
|
||||
/>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="none"
|
||||
label="No colors"
|
||||
onChange={() => setColorMode('none')}
|
||||
checked={colorMode === 'none'}
|
||||
label="No colors"
|
||||
name="colorModeGroup"
|
||||
onChange={() => setColorMode('none')}
|
||||
value="none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -77,8 +78,8 @@ const SemesterSelector = ({ allSemesters, semester, setSemester, dataCy }) => {
|
||||
onChange={(_, { value }) =>
|
||||
setSemester(allSemesters.find(({ semestercode }) => semestercode === value).semestercode)
|
||||
}
|
||||
value={currentValue.semestercode}
|
||||
options={options}
|
||||
value={currentValue.semestercode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -92,29 +93,29 @@ export const SemesterRangeSelector = () => {
|
||||
<div>
|
||||
<b>From</b>
|
||||
<SemesterSelector
|
||||
allSemesters={semesters}
|
||||
dataCy="semester-from"
|
||||
semester={semesterFilter?.start}
|
||||
setSemester={semester => {
|
||||
setSemesterFilter({
|
||||
end: semesterFilter.end < semester ? semester : semesterFilter.end,
|
||||
start: semester,
|
||||
})
|
||||
}}
|
||||
semester={semesterFilter?.start}
|
||||
allSemesters={semesters}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>Until</b>
|
||||
{semesters && (
|
||||
<SemesterSelector
|
||||
dataCy="semester-to"
|
||||
allSemesters={semesters?.filter(s => {
|
||||
return semesterFilter.start <= s.semestercode
|
||||
})}
|
||||
dataCy="semester-to"
|
||||
semester={semesterFilter?.end}
|
||||
setSemester={semester => {
|
||||
setSemesterFilter({ ...semesterFilter, end: semester })
|
||||
}}
|
||||
semester={semesterFilter?.end}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+22
-22
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Form, Button, TextArea, Loader } from 'semantic-ui-react'
|
||||
import qs from 'query-string'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Modal, Form, Button, TextArea, Loader } from 'semantic-ui-react'
|
||||
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import {
|
||||
useGetSavedCourseListsQuery,
|
||||
useCreateCourseListMutation,
|
||||
useUpdateCourseListMutation,
|
||||
useDeleteCourseListMutation,
|
||||
} from 'redux/completedCoursesSearch'
|
||||
import { SearchHistory } from 'components/SearchHistory'
|
||||
} from '@/redux/completedCoursesSearch'
|
||||
|
||||
export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
const location = useLocation()
|
||||
@@ -146,14 +146,14 @@ export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={modal}
|
||||
size="small"
|
||||
trigger={
|
||||
<Button size="small" color="blue" onClick={() => setModal(true)} data-cy="open-completed-courses-modal-button">
|
||||
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
|
||||
Search completed courses of students
|
||||
</Button>
|
||||
}
|
||||
open={modal}
|
||||
onClose={handleClose}
|
||||
size="small"
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
@@ -161,19 +161,19 @@ export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
<Form.Field>
|
||||
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
|
||||
<TextArea
|
||||
value={studentInput}
|
||||
placeholder="012345678, 12345678"
|
||||
onChange={e => setStudentInput(e.target.value)}
|
||||
data-cy="student-no-input"
|
||||
onChange={e => setStudentInput(e.target.value)}
|
||||
placeholder="012345678, 12345678"
|
||||
value={studentInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
|
||||
<TextArea
|
||||
value={courseInput}
|
||||
placeholder="TKT12345, PSYK-123"
|
||||
onChange={e => setCourseInput(e.target.value)}
|
||||
data-cy="course-list-input"
|
||||
onChange={e => setCourseInput(e.target.value)}
|
||||
placeholder="TKT12345, PSYK-123"
|
||||
value={courseInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
@@ -181,13 +181,14 @@ export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
<em> Insert name for this course list if you wish to save it </em>
|
||||
<Form.Input
|
||||
disabled={selectedSearchId !== ''}
|
||||
value={name}
|
||||
placeholder="name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="name"
|
||||
value={name}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Field>
|
||||
<SearchHistory
|
||||
handleSearch={onSelectSearch}
|
||||
header="Saved courselists"
|
||||
items={
|
||||
searchList
|
||||
@@ -200,29 +201,28 @@ export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
: []
|
||||
}
|
||||
updateItem={() => null}
|
||||
handleSearch={onSelectSearch}
|
||||
/>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Save"
|
||||
data-cy="save-courselist"
|
||||
disabled={!name || updateIsLoading || createIsLoading}
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
floated="left"
|
||||
icon="save"
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
onClick={onSave}
|
||||
content="Save"
|
||||
/>
|
||||
<Button
|
||||
content="Delete"
|
||||
disabled={!selectedSearchId || deleteIsLoading}
|
||||
negative
|
||||
floated="left"
|
||||
icon="trash"
|
||||
negative
|
||||
onClick={onDelete}
|
||||
content="Delete"
|
||||
/>
|
||||
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button positive onClick={e => onClicker(e)} data-cy="completed-courses-search-button">
|
||||
<Button data-cy="completed-courses-search-button" onClick={e => onClicker(e)} positive>
|
||||
Search
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
|
||||
+16
-16
@@ -1,14 +1,14 @@
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { Icon, Loader } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
|
||||
import { SortableTable } from 'components/SortableTable'
|
||||
import { useGetCompletedCoursesQuery } from 'redux/completedCoursesSearch'
|
||||
import { StudentNameVisibilityToggle, useStudentNameVisibility } from 'components/StudentNameVisibilityToggle'
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import { RightsNotification } from 'components/RightsNotification'
|
||||
import { StudentInfoItem } from 'components/common/StudentInfoItem'
|
||||
import { hiddenNameAndEmailForExcel } from 'common/columns'
|
||||
import { hiddenNameAndEmailForExcel } from '@/common/columns'
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { RightsNotification } from '@/components/RightsNotification'
|
||||
import { SortableTable } from '@/components/SortableTable'
|
||||
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
|
||||
import { useGetCompletedCoursesQuery } from '@/redux/completedCoursesSearch'
|
||||
|
||||
const getColumns = (courses, showStudentNames, getTextIn) => {
|
||||
const isPassed = credit => [4, 7, 9].includes(credit)
|
||||
@@ -23,9 +23,9 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
|
||||
}
|
||||
if (icon) {
|
||||
if (moment(enrollment.date) > moment().subtract(6, 'months')) {
|
||||
return <Icon fitted name="minus" color="yellow" />
|
||||
return <Icon color="yellow" fitted name="minus" />
|
||||
}
|
||||
return <Icon fitted name="minus" color="grey" />
|
||||
return <Icon color="grey" fitted name="minus" />
|
||||
}
|
||||
|
||||
return `Latest enrollment: ${moment(enrollment.date).format('YYYY-MM-DD')}`
|
||||
@@ -33,7 +33,7 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
|
||||
|
||||
const substitutionString = completion.substitution ? ` as ${completion.substitution}` : ''
|
||||
|
||||
return icon ? <Icon fitted name="check" color="green" /> : `Passed${substitutionString}`
|
||||
return icon ? <Icon color="green" fitted name="check" /> : `Passed${substitutionString}`
|
||||
}
|
||||
|
||||
const getTotalPassed = s => s.credits.filter(c => isPassed(c.creditType)).length
|
||||
@@ -51,7 +51,7 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
|
||||
title: 'Student Number',
|
||||
cellProps: { style },
|
||||
getRowVal: s => s.studentNumber,
|
||||
getRowContent: s => <StudentInfoItem student={s} view="Completed courses search tool" showSisuLink />,
|
||||
getRowContent: s => <StudentInfoItem showSisuLink student={s} view="Completed courses search tool" />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -166,15 +166,15 @@ export const CompletedCoursesSearchResults = ({ searchValues }) => {
|
||||
<RightsNotification discardedStudentNumbers={data.discardedStudentNumbers} />
|
||||
)}
|
||||
<div
|
||||
style={{ maxWidth: '100vh', overflowX: 'auto', paddingBottom: '50px', padding: '0.5em' }}
|
||||
data-cy="completed-courses-table-div"
|
||||
style={{ maxWidth: '100vh', overflowX: 'auto', paddingBottom: '50px', padding: '0.5em' }}
|
||||
>
|
||||
<SortableTable
|
||||
title="Completed courses search"
|
||||
featureName="completed_courses"
|
||||
columns={getColumns(data.courses, showStudentNames.visible, getTextIn)}
|
||||
onlyExportColumns={hiddenNameAndEmailForExcel}
|
||||
data={data.students}
|
||||
featureName="completed_courses"
|
||||
onlyExportColumns={hiddenNameAndEmailForExcel}
|
||||
title="Completed courses search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Message, Icon } from 'semantic-ui-react'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { Icon, Message } from 'semantic-ui-react'
|
||||
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { CompletedCoursesSearch } from './CompletedCoursesSearch'
|
||||
import { CompletedCoursesSearchResults } from './CompletedCoursesSearchResults'
|
||||
|
||||
@@ -18,10 +19,10 @@ export const CompletedCourses = () => {
|
||||
it. Course substitutions are taken into account.
|
||||
</p>
|
||||
<p>
|
||||
<Icon fitted name="check" color="green" />: Student has completed the course with a passing grade. <br />
|
||||
<Icon fitted name="minus" color="yellow" />: Student has not completed the course, but has an active
|
||||
<Icon color="green" fitted name="check" />: Student has completed the course with a passing grade. <br />
|
||||
<Icon color="yellow" fitted name="minus" />: Student has not completed the course, but has an active
|
||||
enrollment from less than 6 months ago. <br />
|
||||
<Icon fitted name="minus" color="grey" />: Student has not completed the course, but has an enrollment from
|
||||
<Icon color="grey" fitted name="minus" />: Student has not completed the course, but has an enrollment from
|
||||
more than 6 months ago. <br />
|
||||
<b>Empty cell</b>: Student has no completion or enrollment for the course.
|
||||
</p>
|
||||
|
||||
+52
-55
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Table, Tab } from 'semantic-ui-react'
|
||||
import { maxBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { Tab, Table } from 'semantic-ui-react'
|
||||
|
||||
import { useGetFacultiesQuery } from 'redux/facultyStats'
|
||||
import { populationStatisticsToolTips } from 'common/InfoToolTips'
|
||||
import { getNewestProgramme } from 'common'
|
||||
import { InfoBox } from '../../Info/InfoBox'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { getNewestProgramme } from '@/common'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import { InfoBox } from '@/components/Info/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
const CreditGainTableRow = ({ statistics, code }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
@@ -28,7 +30,7 @@ const CreditGainTable = ({ data, totalCredits, headerText }) => {
|
||||
.sort()
|
||||
.map(code => {
|
||||
if (data[code].credits === 0) return null
|
||||
return <CreditGainTableRow key={code} statistics={data[code]} code={code} />
|
||||
return <CreditGainTableRow code={code} key={code} statistics={data[code]} />
|
||||
})
|
||||
|
||||
const totalStudents = Object.values(data).reduce((acc, code) => {
|
||||
@@ -66,70 +68,63 @@ export const CoursePopulationCreditGainTable = ({
|
||||
populationStatistics,
|
||||
}) => {
|
||||
const { data: faculties } = useGetFacultiesQuery()
|
||||
const [programmeCreditsStatistics, setStatistics] = useState({})
|
||||
const [facultyCreditsStatistics, setFacStatistics] = useState({})
|
||||
const [totalCredits, setTotalCredits] = useState(0)
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const programmeCredits = {}
|
||||
const facultyCredits = {}
|
||||
|
||||
useEffect(() => {
|
||||
if (!faculties || !students || Object.keys(populationStatistics ?? {}).length === 0) return
|
||||
const programmeCredits = {}
|
||||
const facultyCredits = {}
|
||||
|
||||
let tempTotal = 0
|
||||
students.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const programme = getNewestProgramme(
|
||||
let totalCredits = 0
|
||||
students.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const programme =
|
||||
findCorrectProgramme(student, codes, semesters, from, to) ??
|
||||
getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics.elementdetails.data
|
||||
)
|
||||
|
||||
if (!programmeCredits[programme.code]) {
|
||||
programmeCredits[programme.code] = { name: programme.name, students: [], credits: 0 }
|
||||
}
|
||||
if (!programmeCredits[programme.code]) {
|
||||
programmeCredits[programme.code] = { name: programme.name, students: [], credits: 0 }
|
||||
}
|
||||
|
||||
const faculty = faculties?.find(fac => fac.code === programme.facultyCode) || {
|
||||
// in case there isn't a faculty associated with studyright
|
||||
code: '0000',
|
||||
name: { fi: 'No associated faculty' },
|
||||
}
|
||||
const faculty = faculties?.find(fac => fac.code === programme.facultyCode) || {
|
||||
// in case there isn't a faculty associated with studyright
|
||||
code: '0000',
|
||||
name: { en: 'No associated faculty', fi: 'Ei tiedekuntaa suorituksen hetkellä' },
|
||||
}
|
||||
|
||||
if (!facultyCredits[faculty.code]) {
|
||||
facultyCredits[faculty.code] = { name: faculty.name, students: [], credits: 0 }
|
||||
}
|
||||
if (!facultyCredits[faculty.code]) {
|
||||
facultyCredits[faculty.code] = { name: faculty.name, students: [], credits: 0 }
|
||||
}
|
||||
|
||||
const coursesBetween = []
|
||||
courses.forEach(course => {
|
||||
if (moment(course.date).isBetween(moment(from), moment(to)) && course.passed) {
|
||||
if (course.grade === 'Hyv.') {
|
||||
coursesBetween.push({ grade: course.grade, value: 1, credits: course.credits })
|
||||
} else {
|
||||
coursesBetween.push({ grade: course.grade, value: Number(course.grade), credits: course.credits })
|
||||
}
|
||||
const coursesBetween = []
|
||||
courses.forEach(course => {
|
||||
if (moment(course.date).isBetween(moment(from), moment(to)) && course.passed) {
|
||||
if (course.grade === 'Hyv.') {
|
||||
coursesBetween.push({ grade: course.grade, value: 1, credits: course.credits })
|
||||
} else {
|
||||
coursesBetween.push({ grade: course.grade, value: Number(course.grade), credits: course.credits })
|
||||
}
|
||||
})
|
||||
if (maxBy(coursesBetween, course => course.value)) {
|
||||
programmeCredits[programme.code].students.push(student.studentNumber)
|
||||
facultyCredits[faculty.code].students.push(student.studentNumber)
|
||||
|
||||
const maxCredits = maxBy(coursesBetween, course => course.value).credits
|
||||
programmeCredits[programme.code].credits += maxCredits
|
||||
facultyCredits[faculty.code].credits += maxCredits
|
||||
tempTotal += maxBy(coursesBetween, course => course.value).credits
|
||||
}
|
||||
})
|
||||
setTotalCredits(tempTotal)
|
||||
setStatistics(programmeCredits)
|
||||
setFacStatistics(facultyCredits)
|
||||
}, [students, faculties])
|
||||
if (maxBy(coursesBetween, course => course.value)) {
|
||||
programmeCredits[programme.code].students.push(student.studentNumber)
|
||||
facultyCredits[faculty.code].students.push(student.studentNumber)
|
||||
|
||||
const maxCredits = maxBy(coursesBetween, course => course.value).credits
|
||||
programmeCredits[programme.code].credits += maxCredits
|
||||
facultyCredits[faculty.code].credits += maxCredits
|
||||
totalCredits += maxBy(coursesBetween, course => course.value).credits
|
||||
}
|
||||
})
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'Faculty',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<CreditGainTable data={facultyCreditsStatistics} totalCredits={totalCredits} headerText="Faculty" />
|
||||
<CreditGainTable data={facultyCredits} headerText="Faculty" totalCredits={totalCredits} />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
@@ -137,7 +132,7 @@ export const CoursePopulationCreditGainTable = ({
|
||||
menuItem: 'Programme',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<CreditGainTable data={programmeCreditsStatistics} totalCredits={totalCredits} headerText="Programme" />
|
||||
<CreditGainTable data={programmeCredits} headerText="Programme" totalCredits={totalCredits} />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
@@ -145,7 +140,9 @@ export const CoursePopulationCreditGainTable = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoBox content={populationStatisticsToolTips.CreditDistributionCoursePopulation} />
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<InfoBox content={populationStatisticsToolTips.CreditDistributionCoursePopulation} />
|
||||
</div>
|
||||
<Tab panes={panes} />
|
||||
</>
|
||||
)
|
||||
|
||||
+5
-4
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { FilterToggle } from '../../FilterView/FilterToggle'
|
||||
import { useFilters } from '../../FilterView/useFilters'
|
||||
import { gradeFilter } from '../../FilterView/filters'
|
||||
|
||||
import { gradeFilter } from '@/components/FilterView/filters'
|
||||
import { FilterToggle } from '@/components/FilterView/FilterToggle'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
|
||||
export const ExternalGradeFilterToggle = ({ grade }) => {
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
@@ -10,10 +11,10 @@ export const ExternalGradeFilterToggle = ({ grade }) => {
|
||||
|
||||
return (
|
||||
<FilterToggle
|
||||
filter={gradeFilter}
|
||||
active={isActive}
|
||||
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
|
||||
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
|
||||
filter={gradeFilter}
|
||||
filterName="Grade Filter"
|
||||
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
|
||||
/>
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import { intersection, orderBy } from 'lodash'
|
||||
import React from 'react'
|
||||
import { Progress, Table } from 'semantic-ui-react'
|
||||
import { intersection, orderBy } from 'lodash'
|
||||
|
||||
import { getHighestGradeOfCourseBetweenRange } from 'common'
|
||||
import { getHighestGradeOfCourseBetweenRange } from '@/common'
|
||||
import { ExternalGradeFilterToggle } from './ExternalGradeFilterToggle'
|
||||
|
||||
export const CoursePopulationGradeDist = ({ singleCourseStats, students, courseCodes, from, to }) => {
|
||||
@@ -62,11 +62,11 @@ export const CoursePopulationGradeDist = ({ singleCourseStats, students, courseC
|
||||
<Table.Cell>{grade.amount}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Progress
|
||||
value={grade.amount}
|
||||
total={students.length}
|
||||
progress="percent"
|
||||
precision={0}
|
||||
progress="percent"
|
||||
style={{ margin: 0 }}
|
||||
total={students.length}
|
||||
value={grade.amount}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { Table, Progress } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { languageAbbreviations } from '../../../common'
|
||||
|
||||
import { languageAbbreviations } from '@/common'
|
||||
|
||||
export const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
|
||||
let total = 0
|
||||
@@ -46,7 +47,7 @@ export const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
|
||||
<Table.Cell>{getLanguage(language[0])}</Table.Cell>
|
||||
<Table.Cell>{language[1]}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Progress value={language[1]} total={total} progress="percent" precision={0} style={{ margin: 0 }} />
|
||||
<Progress precision={0} progress="percent" style={{ margin: 0 }} total={total} value={language[1]} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import moment from 'moment'
|
||||
import _ from 'lodash'
|
||||
import { Segment, Header, Form, Input } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { InfoBox } from 'components/Info/InfoBox'
|
||||
import { PopulationCourseStatsFlat } from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { PanelView } from 'components/common/PanelView'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from 'redux/populations'
|
||||
import { useGetSingleCourseStatsQuery } from 'redux/singleCourseStats'
|
||||
import { useGetStudentListCourseStatisticsQuery } from 'redux/populationCourses'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import { populationStatisticsToolTips } from 'common/InfoToolTips'
|
||||
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from 'common'
|
||||
import { useProgress, useTitle } from 'common/hooks'
|
||||
import { queryParamsFromUrl } from 'common/query'
|
||||
import { PopulationStudentsContainer as PopulationStudents } from '../PopulationStudents'
|
||||
import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
|
||||
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
|
||||
import { CoursePopulationCreditGainTable } from './CoursePopulationCreditGainTable'
|
||||
import { CustomPopulationProgrammeDist } from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import { ProgressBar } from '../ProgressBar'
|
||||
import { Form, Header, Input, Segment } from 'semantic-ui-react'
|
||||
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '@/common'
|
||||
import { useProgress, useTitle } from '@/common/hooks'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { queryParamsFromUrl } from '@/common/query'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { CustomPopulationProgrammeDist } from '@/components/CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import { FilterView } from '@/components/FilterView'
|
||||
import {
|
||||
ageFilter,
|
||||
gradeFilter,
|
||||
genderFilter,
|
||||
courseFilter,
|
||||
creditsEarnedFilter,
|
||||
startYearAtUniFilter,
|
||||
genderFilter,
|
||||
gradeFilter,
|
||||
programmeFilter,
|
||||
studyTrackFilter,
|
||||
startYearAtUniFilter,
|
||||
studentNumberFilter,
|
||||
} from '../FilterView/filters'
|
||||
import { FilterView } from '../FilterView'
|
||||
import { useLanguage } from '../LanguagePicker/useLanguage'
|
||||
studyTrackFilter,
|
||||
} from '@/components/FilterView/filters'
|
||||
import { InfoBox } from '@/components/Info/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useGetSingleCourseStatsQuery } from '@/redux/singleCourseStats'
|
||||
import { CoursePopulationCreditGainTable } from './CoursePopulationCreditGainTable'
|
||||
import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
|
||||
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
|
||||
|
||||
const NO_PROGRAMME = {
|
||||
code: '00000',
|
||||
@@ -117,11 +116,11 @@ export const CoursePopulation = () => {
|
||||
<div>
|
||||
<InfoBox content={populationStatisticsToolTips.GradeDistributionCoursePopulation} />
|
||||
<CoursePopulationGradeDist
|
||||
singleCourseStats={courseData}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
students={filtered}
|
||||
courseCodes={codes}
|
||||
from={dateFrom}
|
||||
singleCourseStats={courseData}
|
||||
students={filtered}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -131,7 +130,7 @@ export const CoursePopulation = () => {
|
||||
content: (
|
||||
<div>
|
||||
<InfoBox content={populationStatisticsToolTips.LanguageDistributionCoursePopulation} />
|
||||
<CoursePopulationLanguageDist from={dateFrom} to={dateTo} samples={filtered} codes={codes} />
|
||||
<CoursePopulationLanguageDist codes={codes} from={dateFrom} samples={filtered} to={dateTo} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -141,10 +140,12 @@ export const CoursePopulation = () => {
|
||||
<div>
|
||||
<InfoBox content={populationStatisticsToolTips.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
coursecode={codes}
|
||||
from={dateFrom}
|
||||
studentData={populationStatistics}
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
students={filtered}
|
||||
coursecode={codes}
|
||||
studentData={populationStatistics}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -157,12 +158,12 @@ export const CoursePopulation = () => {
|
||||
title: 'Credit gains',
|
||||
content: (
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
students={filtered}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
populationStatistics={populationStatistics}
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
students={filtered}
|
||||
to={dateTo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -170,12 +171,12 @@ export const CoursePopulation = () => {
|
||||
title: `Students (${filtered.length})`,
|
||||
content: (
|
||||
<PopulationStudents
|
||||
variant="coursePopulation"
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={filtered}
|
||||
coursecode={codes}
|
||||
filteredStudents={filtered}
|
||||
from={dateFrom}
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
to={dateTo}
|
||||
variant="coursePopulation"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -194,7 +195,6 @@ export const CoursePopulation = () => {
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
name="CoursePopulation"
|
||||
filters={[
|
||||
genderFilter,
|
||||
studentNumberFilter,
|
||||
@@ -224,6 +224,7 @@ export const CoursePopulation = () => {
|
||||
initialOptions={{
|
||||
[programmeFilter.key]: { mode: 'attainment', selectedProgrammes: [] },
|
||||
}}
|
||||
name="CoursePopulation"
|
||||
students={populationStatistics.students ?? []}
|
||||
>
|
||||
{filtered => (
|
||||
@@ -267,16 +268,16 @@ const CustomPopulationCoursesWrapper = ({ filteredStudents }) => {
|
||||
<Form.Field inline>
|
||||
<label>Limit to courses where student number is at least</label>
|
||||
<Input
|
||||
value={studentAmountLimit}
|
||||
onChange={e => onStudentAmountLimitChange(e.target.value)}
|
||||
style={{ width: '70px' }}
|
||||
value={studentAmountLimit}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<PopulationCourseStatsFlat
|
||||
courses={courseStatistics}
|
||||
filteredStudents={filteredStudents}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
courses={courseStatistics}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { bool, func } from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import { Table, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { courseDataWithRealisationsType } from '../../../constants/types'
|
||||
import { courseDataWithRealisationsType } from '@/constants/types'
|
||||
import './foldableRow.css'
|
||||
|
||||
export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats }) => {
|
||||
@@ -13,14 +13,14 @@ export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats })
|
||||
const showCourseRealisations = hasRealisations && isUnfolded
|
||||
|
||||
const getCell = (content, obfuscated) => (
|
||||
<Table.Cell style={{ color: obfuscated && 'gray' }} content={obfuscated ? '5 or less students' : content} />
|
||||
<Table.Cell content={obfuscated ? '5 or less students' : content} style={{ color: obfuscated && 'gray' }} />
|
||||
)
|
||||
|
||||
const getRow = (rowId, rowData, isMainRow = true) => {
|
||||
const { passed, failed, passrate, realisation, obfuscated } = rowData
|
||||
const showFoldIcon = isMainRow && hasRealisations
|
||||
return (
|
||||
<Table.Row key={rowId} className={!isMainRow ? 'subRow' : ''}>
|
||||
<Table.Row className={!isMainRow ? 'subRow' : ''} key={rowId}>
|
||||
<Table.Cell
|
||||
className={showFoldIcon ? 'foldControlCell' : ''}
|
||||
onClick={() => isMainRow && setIsUnfolded(isUnfolded => !isUnfolded)}
|
||||
@@ -28,6 +28,7 @@ export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats })
|
||||
{showFoldIcon ? <Icon name={`angle ${isUnfolded ? 'down' : 'right'}`} /> : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
className={isMainRow ? 'courseNameCell' : 'courseRealisationCell'}
|
||||
content={
|
||||
isMainRow ? (
|
||||
<>
|
||||
@@ -38,9 +39,8 @@ export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats })
|
||||
realisation
|
||||
)
|
||||
}
|
||||
style={{ color: obfuscated && 'gray' }}
|
||||
className={isMainRow ? 'courseNameCell' : 'courseRealisationCell'}
|
||||
onClick={() => onClickFn(id)}
|
||||
style={{ color: obfuscated && 'gray' }}
|
||||
/>
|
||||
{getCell(passed, obfuscated)}
|
||||
{getCell(failed, obfuscated)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { arrayOf, bool, func, string } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { string, arrayOf, func, bool } from 'prop-types'
|
||||
|
||||
import { courseDataWithRealisationsType } from '../../../constants/types'
|
||||
import { courseDataWithRealisationsType } from '@/constants/types'
|
||||
import { FoldableRow } from './foldableRow'
|
||||
|
||||
const getHeader = categoryName => {
|
||||
@@ -19,13 +18,13 @@ const getHeader = categoryName => {
|
||||
}
|
||||
|
||||
export const AttemptsTable = ({ categoryName, data, onClickCourse, userHasAccessToAllStats }) => (
|
||||
<Table style={{ cursor: 'pointer' }} selectable className="fixed-header">
|
||||
<Table className="fixed-header" selectable style={{ cursor: 'pointer' }}>
|
||||
{getHeader(categoryName)}
|
||||
<Table.Body>
|
||||
{data.map(course => (
|
||||
<FoldableRow
|
||||
key={course.id}
|
||||
courseData={course}
|
||||
key={course.id}
|
||||
onClickFn={onClickCourse}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import { sortBy } from 'lodash'
|
||||
import { arrayOf, bool, func, shape, string } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { func, arrayOf, shape, string, bool } from 'prop-types'
|
||||
import { getActiveYears } from '../courseStatisticsUtils'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
|
||||
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import './courseTable.css'
|
||||
|
||||
const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, mandatory = false, controlIcon }) => {
|
||||
@@ -19,9 +20,9 @@ const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, ma
|
||||
|
||||
const toCourseRow = course => (
|
||||
<Table.Row
|
||||
style={{ cursor: 'pointer' }}
|
||||
key={course.code}
|
||||
onClick={() => (course.min_attainment_date || mandatory ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Cell width={10}>
|
||||
<div>{getTextIn(course.name)}</div>
|
||||
@@ -34,7 +35,7 @@ const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, ma
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<Table selectable className="fixed-header">
|
||||
<Table className="fixed-header" selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||
|
||||
+13
-12
@@ -1,9 +1,10 @@
|
||||
import { flatten, uniq } from 'lodash'
|
||||
import { number, shape, string } from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Table, Header, Dropdown } from 'semantic-ui-react'
|
||||
import { flatten, uniq } from 'lodash'
|
||||
import { shape, string, number } from 'prop-types'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { Dropdown, Header, Table } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
const CourseTableRow = ({ facultyCode, students, credits, facultyName }) => {
|
||||
return (
|
||||
@@ -28,11 +29,11 @@ const CourseTable = ({ course, courseInstance, selectedYear }) => {
|
||||
.sort(([facultyCodeA], [facultyCodeB]) => facultyCodeA.localeCompare(facultyCodeB))
|
||||
.map(([facultyCode, instanceFaculty]) => (
|
||||
<CourseTableRow
|
||||
key={`${coursecode}-${facultyCode}`}
|
||||
credits={instanceFaculty.credits}
|
||||
facultyCode={facultyCode}
|
||||
facultyName={getTextIn(instanceFaculty.name)}
|
||||
key={`${coursecode}-${facultyCode}`}
|
||||
students={instanceFaculty.students.length}
|
||||
credits={instanceFaculty.credits}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -66,13 +67,13 @@ const CourseTable = ({ course, courseInstance, selectedYear }) => {
|
||||
{courseInstance ? (
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell width={13} style={{ fontWeight: '700' }}>
|
||||
<Table.HeaderCell style={{ fontWeight: '700' }} width={13}>
|
||||
Total
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell width={2} style={{ fontWeight: '700' }}>
|
||||
<Table.HeaderCell style={{ fontWeight: '700' }} width={2}>
|
||||
{courseInstance ? courseInstance.allStudents.length : 0}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell width={1} style={{ fontWeight: '700' }}>
|
||||
<Table.HeaderCell style={{ fontWeight: '700' }} width={1}>
|
||||
{courseInstance ? courseInstance.allCredits : 0}
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
@@ -123,13 +124,13 @@ export const FacultyLevelStatistics = () => {
|
||||
<div>
|
||||
Select year
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
defaultValue={selectedYear}
|
||||
options={dropdownOptions}
|
||||
fluid
|
||||
onChange={(e, data) => {
|
||||
setSelectedYear(data.value)
|
||||
}}
|
||||
options={dropdownOptions}
|
||||
selection
|
||||
/>
|
||||
{courseTables}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { string, func } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Icon } from 'semantic-ui-react'
|
||||
import { string, func } from 'prop-types'
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
@@ -25,7 +25,7 @@ const handler = onClick => e => {
|
||||
export const ClearableItem = ({ name, onClear, icon, ...props }) => (
|
||||
<div style={styles.container} {...props}>
|
||||
<div style={styles.clear}>
|
||||
<Icon name={icon} disabled={!onClear} onClick={handler(onClear)} />
|
||||
<Icon disabled={!onClear} name={icon} onClick={handler(onClear)} />
|
||||
</div>
|
||||
<div style={styles.text}>
|
||||
<span style={{ paddingLeft: '0.5rem' }}>{name}</span>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { string, oneOfType, number } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Label, Popup } from 'semantic-ui-react'
|
||||
import { string, oneOfType, number } from 'prop-types'
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
@@ -19,8 +19,8 @@ const styles = {
|
||||
export const DropdownItem = ({ name, code, size, description }) =>
|
||||
['ALL', 'OTHER', 'EXCLUDED'].includes(code) ? (
|
||||
<Popup
|
||||
size="small"
|
||||
content={description}
|
||||
size="small"
|
||||
trigger={
|
||||
<div style={styles.container}>
|
||||
<div style={styles.text}>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { string, arrayOf, shape, func, oneOfType, number } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
import { string, arrayOf, shape, func, oneOfType, number } from 'prop-types'
|
||||
import { orderBy } from 'lodash'
|
||||
import { DropdownItem } from './DropdownItem'
|
||||
|
||||
import { ClearableItem } from './ClearableItem'
|
||||
import { DropdownItem } from './DropdownItem'
|
||||
|
||||
export const ProgrammeDropdown = ({ options, label, name, onChange, onClear, value, ...props }) => (
|
||||
<Form.Dropdown
|
||||
fluid
|
||||
label={label}
|
||||
multiple
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
options={orderBy(options, ['size'], ['desc']).map(({ key, size, value: v, text, description }) => ({
|
||||
key,
|
||||
content: <DropdownItem name={text} code={key} size={size} description={description} />,
|
||||
content: <DropdownItem code={key} description={description} name={text} size={size} />,
|
||||
text: !onClear ? text : <ClearableItem name={text} onClear={onClear} />,
|
||||
value: v,
|
||||
}))}
|
||||
selection
|
||||
multiple
|
||||
fluid
|
||||
label={label}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
search
|
||||
selection
|
||||
value={value}
|
||||
{...props}
|
||||
selectOnBlur={false}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import qs from 'query-string'
|
||||
import React from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useProgress, useTabs } from 'common/hooks'
|
||||
import { getCourseStats } from 'redux/coursestats'
|
||||
|
||||
import { useProgress, useTabs } from '@/common/hooks'
|
||||
import { getCourseStats } from '@/redux/coursestats'
|
||||
import { AttemptsPane } from './panes/AttemptsPane'
|
||||
import { StudentsPane } from './panes/StudentsPane'
|
||||
import './resultTabs.css'
|
||||
@@ -54,16 +55,16 @@ export const ResultTabs = ({ primary, comparison, separate, availableStats }) =>
|
||||
<Component
|
||||
availableStats={availableStats}
|
||||
datasets={[primary, comparison]}
|
||||
separate={separate}
|
||||
updateQuery={updateSeparate}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
separate={separate}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<Tab id="CourseStatPanes" panes={panes} onTabChange={handleTabChange} activeIndex={tab} />
|
||||
<Tab activeIndex={tab} id="CourseStatPanes" onTabChange={handleTabChange} panes={panes} />
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Pane } from './common/Pane'
|
||||
|
||||
import { Pane } from './Pane'
|
||||
import { AttemptsTable } from './tables/AttemptsTable'
|
||||
|
||||
export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
|
||||
@@ -9,8 +10,8 @@ export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccess
|
||||
datasets={datasets}
|
||||
initialSettings={{ viewMode: 'ATTEMPTS', separate, splitDirection: 'row' }}
|
||||
tableComponent={AttemptsTable}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
updateQuery={updateQuery}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+37
-35
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import { GradeDistributionChart } from '../charts/GradeDistributionChart'
|
||||
import { PassRateChart } from '../charts/PassRateChart'
|
||||
import { ChartSettings } from '../settings/ChartSettings'
|
||||
import { TableSettings } from '../settings/TableSettings'
|
||||
import { PaneContent } from './PaneContent'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
import { GradeDistributionChart } from './charts/GradeDistributionChart'
|
||||
import { PassRateChart } from './charts/PassRateChart'
|
||||
import { ChartSettings } from './settings/ChartSettings'
|
||||
import { TableSettings } from './settings/TableSettings'
|
||||
|
||||
const TableSection = ({
|
||||
availableStats,
|
||||
@@ -104,7 +104,7 @@ const GradeDistributionChartSection = ({
|
||||
* A generalized pane component that is used on the Students and the Attempts tabs.
|
||||
* Contains settings and table for the corresponding tab and the pass rate chart.
|
||||
*
|
||||
* @param tableComponent Should be either StudentsTable or AttemptsTable
|
||||
* @param tableComponent - Should be either StudentsTable or AttemptsTable
|
||||
*/
|
||||
export const Pane = ({
|
||||
availableStats,
|
||||
@@ -130,39 +130,41 @@ export const Pane = ({
|
||||
const styleData = { flexGrow: 1, flexBasis: 1, maxWidth: halfWidth ? '49%' : '100%' }
|
||||
|
||||
return (
|
||||
<PaneContent>
|
||||
<TableSection
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
setSplitDirection={setSplitDirection}
|
||||
tableComponent={tableComponent}
|
||||
toggleSeparate={toggleSeparate}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
{settings.showGrades ? (
|
||||
<GradeDistributionChartSection
|
||||
<Tab.Pane>
|
||||
<Segment basic>
|
||||
<TableSection
|
||||
availableStats={availableStats}
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
setSettings={setSettings}
|
||||
setSplitDirection={setSplitDirection}
|
||||
settings={settings}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
tableComponent={tableComponent}
|
||||
toggleSeparate={toggleSeparate}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
) : (
|
||||
<PassRateChartSection
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
viewMode={settings.viewMode}
|
||||
/>
|
||||
)}
|
||||
</PaneContent>
|
||||
{settings.showGrades ? (
|
||||
<GradeDistributionChartSection
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
) : (
|
||||
<PassRateChartSection
|
||||
datasets={datasets}
|
||||
isRelative={settings.isRelative}
|
||||
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
|
||||
styleContainer={styleContainer}
|
||||
styleData={styleData}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
viewMode={settings.viewMode}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Pane } from './common/Pane'
|
||||
|
||||
import { Pane } from './Pane'
|
||||
import { StudentsTable } from './tables/StudentsTable'
|
||||
|
||||
export const StudentsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
|
||||
@@ -9,8 +10,8 @@ export const StudentsPane = ({ availableStats, datasets, separate, userHasAccess
|
||||
datasets={datasets}
|
||||
initialSettings={{ viewMode: 'STUDENTS', separate, splitDirection: 'row' }}
|
||||
tableComponent={StudentsTable}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
updateQuery={updateQuery}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+50
-2
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { gradeGraphOptions } from '../../../../../constants'
|
||||
|
||||
import {
|
||||
absoluteToRelative,
|
||||
getDataObject,
|
||||
@@ -8,7 +8,55 @@ import {
|
||||
getMaxValueOfSeries,
|
||||
getThesisGradeSpread,
|
||||
isThesisSeries,
|
||||
} from '../util'
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { chartColor, color } from '@/styles/colors'
|
||||
|
||||
const gradeGraphOptions = (isRelative, categories, max, title) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors: [
|
||||
color.red,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
chartColor.blue,
|
||||
color.green,
|
||||
color.green,
|
||||
],
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of Students' : 'Number of Students',
|
||||
},
|
||||
max,
|
||||
floor: -max,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 2,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getGradeSeries = series => {
|
||||
const isGradeSeries = !isThesisSeries(series)
|
||||
|
||||
+86
-10
@@ -1,7 +1,84 @@
|
||||
import React from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { passRateAttemptGraphOptions, passRateStudentGraphOptions } from '../../../../../constants'
|
||||
import { absoluteToRelative, getDataObject, getMaxValueOfSeries } from '../util'
|
||||
|
||||
import {
|
||||
absoluteToRelative,
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { chartColor, color } from '@/styles/colors'
|
||||
|
||||
const passRateAttemptGraphOptions = (isRelative, categories, max, title) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors: isRelative ? [color.green, color.red] : [chartColor.blue, color.green, color.red],
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of Students' : 'Number of Students',
|
||||
},
|
||||
max,
|
||||
floor: -max,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 3,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const passRateStudentGraphOptions = (isRelative, categories, max, title) => ({
|
||||
chart: {
|
||||
type: 'column',
|
||||
},
|
||||
colors: isRelative
|
||||
? [chartColor.greenLight, chartColor.greenDark, chartColor.redLight, chartColor.redDark]
|
||||
: [chartColor.blue, chartColor.greenLight, chartColor.greenDark, chartColor.redLight, chartColor.redDark],
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
title: {
|
||||
text: isRelative ? 'Share of Students' : 'Number of Students',
|
||||
},
|
||||
max,
|
||||
floor: -max,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
borderRadius: 1,
|
||||
},
|
||||
series: {
|
||||
tooltip: {
|
||||
valueSuffix: isRelative ? '%' : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getPassRateAttemptSeriesFromStats = stats => {
|
||||
const all = []
|
||||
@@ -33,14 +110,17 @@ const getPassRateStudentSeriesFromStats = stats => {
|
||||
const passedFirst = []
|
||||
const passedEventually = []
|
||||
const neverPassed = []
|
||||
const enrolledNoGrade = []
|
||||
|
||||
stats.forEach(year => {
|
||||
const { passedFirst: pf, passedEventually: pe, neverPassed: np } = year.students.categories
|
||||
const enrolledWithNoGrade = year.students.enrolledStudentsWithNoGrade
|
||||
|
||||
all.push((pf || 0) + (pe || 0) + (np || 0))
|
||||
all.push((pf || 0) + (pe || 0) + (np || 0) + (enrolledWithNoGrade || 0))
|
||||
passedFirst.push(pf || 0)
|
||||
passedEventually.push(pe || 0)
|
||||
neverPassed.push(np || 0)
|
||||
enrolledNoGrade.push(enrolledWithNoGrade || 0)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -49,11 +129,13 @@ const getPassRateStudentSeriesFromStats = stats => {
|
||||
getDataObject('passed on first try', passedFirst, 'b'),
|
||||
getDataObject('passed eventually', passedEventually, 'b'),
|
||||
getDataObject('never passed', neverPassed, 'c'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
|
||||
],
|
||||
relative: [
|
||||
getDataObject('passed on first try', passedFirst.map(absoluteToRelative(all)), 'b'),
|
||||
getDataObject('passed eventually', passedEventually.map(absoluteToRelative(all)), 'b'),
|
||||
getDataObject('never passed', neverPassed.map(absoluteToRelative(all)), 'c'),
|
||||
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'c'),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -69,13 +151,7 @@ export const PassRateChart = ({ data, isRelative, userHasAccessToAllStats, viewM
|
||||
|
||||
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(passGraphSeries.absolute)
|
||||
const graphOptionsFn = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudentGraphOptions
|
||||
const primaryGraphOptions = graphOptionsFn(
|
||||
isRelative,
|
||||
statYears,
|
||||
maxPassRateVal,
|
||||
`Pass rate for group ${data.name}`,
|
||||
isRelative
|
||||
)
|
||||
const primaryGraphOptions = graphOptionsFn(isRelative, statYears, maxPassRateVal, `Pass rate for group ${data.name}`)
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
export const PaneContent = ({ children }) => {
|
||||
return (
|
||||
<Tab.Pane>
|
||||
<Segment basic>{children}</Segment>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
+11
-3
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
import { HelpButton } from './common/HelpButton'
|
||||
|
||||
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { InfoBox } from '@/components/Info/InfoBox'
|
||||
import { Setting } from './common/Setting'
|
||||
|
||||
export const ChartSettings = ({ isRelative, setIsRelative, tab, viewMode }) => {
|
||||
@@ -9,11 +11,17 @@ export const ChartSettings = ({ isRelative, setIsRelative, tab, viewMode }) => {
|
||||
<SegmentGroup horizontal>
|
||||
<Segment>
|
||||
<Setting labelText="Show relative">
|
||||
<Radio toggle checked={isRelative} onChange={() => setIsRelative(!isRelative)} />
|
||||
<Radio checked={isRelative} onChange={() => setIsRelative(!isRelative)} toggle />
|
||||
</Setting>
|
||||
</Segment>
|
||||
</SegmentGroup>
|
||||
<HelpButton tab={tab} viewMode={viewMode} />
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
{tab === 'GradeDistribution' ? (
|
||||
<InfoBox content={courseStatisticsToolTips.GradeDistribution} popup />
|
||||
) : (
|
||||
<InfoBox content={courseStatisticsToolTips.PassRate[viewMode]} popup />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,7 +1,9 @@
|
||||
import React from 'react'
|
||||
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
|
||||
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { InfoBox } from '@/components/Info/InfoBox'
|
||||
import { DirectionToggle } from './common/DirectionToggle'
|
||||
import { HelpButton } from './common/HelpButton'
|
||||
import { ProviderOrganization } from './common/ProviderOrganization'
|
||||
import { Setting } from './common/Setting'
|
||||
|
||||
@@ -50,11 +52,13 @@ export const TableSettings = ({ availableStats, datasets, onChange, onSeparateCh
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<SegmentGroup horizontal>
|
||||
{settings.map(element => (
|
||||
<Segment key={element.key}>{element}</Segment>
|
||||
{settings.map(setting => (
|
||||
<Segment key={setting.key}>{setting}</Segment>
|
||||
))}
|
||||
</SegmentGroup>
|
||||
<HelpButton tab="Tables" viewMode="STUDENTS" />
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
<InfoBox content={courseStatisticsToolTips.Tables[viewMode]} popup />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button, Icon, Popup } from 'semantic-ui-react'
|
||||
import { courseStatisticsToolTips } from 'common/InfoToolTips'
|
||||
|
||||
export const HelpButton = ({ tab, viewMode }) => {
|
||||
let content = courseStatisticsToolTips[tab]
|
||||
|
||||
if (viewMode) {
|
||||
content = content[viewMode]
|
||||
}
|
||||
|
||||
content = content.replace(/\n +/g, '\n')
|
||||
|
||||
return (
|
||||
<Popup
|
||||
on="click"
|
||||
wide="very"
|
||||
trigger={
|
||||
<Button icon labelPosition="left" style={{ height: '50px', marginLeft: '30px' }}>
|
||||
<Icon name="question circle outline" />
|
||||
Help
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Checkbox, Segment, SegmentGroup } from 'semantic-ui-react'
|
||||
import { toggleOpenAndRegularCourses } from 'redux/coursesearch'
|
||||
|
||||
import { toggleOpenAndRegularCourses } from '@/redux/coursesearch'
|
||||
|
||||
export const ProviderOrganization = ({ availableStats }) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
+17
-8
@@ -4,9 +4,16 @@ import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
import { SortableTable, row } from 'components/SortableTable'
|
||||
import { getCourseAlternatives } from 'selectors/courseStats'
|
||||
import { getGradeSpread, getSortableColumn, getThesisGradeSpread, isThesisGrades, resolveGrades } from '../util'
|
||||
|
||||
import {
|
||||
getGradeSpread,
|
||||
getSortableColumn,
|
||||
getThesisGradeSpread,
|
||||
isThesisGrades,
|
||||
resolveGrades,
|
||||
} from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { getCourseAlternatives } from '@/selectors/courseStats'
|
||||
|
||||
const getTableData = (stats, useThesisGrades, isRelative) =>
|
||||
stats.map(stat => {
|
||||
@@ -65,14 +72,16 @@ export const AttemptsTable = ({
|
||||
} = stats[0]
|
||||
const useThesisGrades = isThesisGrades(grades)
|
||||
const alternatives = useSelector(getCourseAlternatives)
|
||||
const unifyCourses = useSelector(state => state.courseSearch.openOrRegular)
|
||||
|
||||
const showPopulation = (yearcode, years) => {
|
||||
const showPopulation = (yearcode, years, unifyCourses) => {
|
||||
const queryObject = {
|
||||
from: yearcode,
|
||||
to: yearcode,
|
||||
coursecodes: JSON.stringify(uniq(alternatives)),
|
||||
years,
|
||||
separate,
|
||||
unifyCourses,
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
return `/coursepopulation?${searchString}`
|
||||
@@ -103,7 +112,7 @@ export const AttemptsTable = ({
|
||||
getRowContent: s =>
|
||||
s.name !== 'Total' &&
|
||||
userHasAccessToAllStats && (
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, unifyCourses)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
@@ -181,12 +190,12 @@ export const AttemptsTable = ({
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
title={`Yearly attempt statistics for group ${name}`}
|
||||
featureName="yearly_attempts"
|
||||
defaultSort={['TIME', 'desc']}
|
||||
columns={columns}
|
||||
data={data}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
featureName="yearly_attempts"
|
||||
maxHeight="40vh"
|
||||
title={`Yearly attempt statistics for group ${name}`}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are NOT included in the total</span>
|
||||
|
||||
+7
-6
@@ -5,9 +5,10 @@ import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
import { SortableTable, row } from 'components/SortableTable'
|
||||
import { getCourseAlternatives } from 'selectors/courseStats'
|
||||
import { defineCellColor, resolveGrades, getSortableColumn } from '../util'
|
||||
|
||||
import { defineCellColor, getSortableColumn, resolveGrades } from '@/components/CourseStatistics/ResultTabs/panes/util'
|
||||
import { SortableTable, row } from '@/components/SortableTable'
|
||||
import { getCourseAlternatives } from '@/selectors/courseStats'
|
||||
|
||||
const formatPercentage = passRate => (Number.isNaN(passRate) ? '–' : `${(passRate * 100).toFixed(2)} %`)
|
||||
|
||||
@@ -220,12 +221,12 @@ export const StudentsTable = ({
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
title={`Student statistics for group ${name}`}
|
||||
featureName="group_statistics"
|
||||
defaultSort={['TIME', 'desc']}
|
||||
columns={columns}
|
||||
data={data}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
featureName="group_statistics"
|
||||
maxHeight="40vh"
|
||||
title={`Student statistics for group ${name}`}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are NOT included in the total</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flatten } from 'lodash'
|
||||
import { calculatePercentage } from 'common'
|
||||
import { calculatePercentage } from '@/common'
|
||||
|
||||
const gradesMap = {
|
||||
0: 0,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import React from 'react'
|
||||
import { arrayOf, func, number, shape } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
|
||||
export const YearFilter = ({ years, fromYear, toYear, handleChange }) => (
|
||||
<Form>
|
||||
<Form.Group inline>
|
||||
<Form.Dropdown
|
||||
inline
|
||||
label="From:"
|
||||
name="fromYear"
|
||||
options={toYear ? years.filter(({ value }) => value <= toYear) : years}
|
||||
selection
|
||||
inline
|
||||
placeholder="Select academic year"
|
||||
onChange={handleChange}
|
||||
value={fromYear}
|
||||
options={toYear ? years.filter(({ value }) => value <= toYear) : years}
|
||||
placeholder="Select academic year"
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
selection
|
||||
value={fromYear}
|
||||
/>
|
||||
<Form.Dropdown
|
||||
inline
|
||||
label="To:"
|
||||
name="toYear"
|
||||
options={fromYear ? years.filter(({ value }) => value >= fromYear) : years}
|
||||
inline
|
||||
selection
|
||||
placeholder="Select academic year"
|
||||
onChange={handleChange}
|
||||
value={toYear}
|
||||
options={fromYear ? years.filter(({ value }) => value >= fromYear) : years}
|
||||
placeholder="Select academic year"
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
selection
|
||||
value={toYear}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Segment, Header, Form, Radio, Popup, Message } from 'semantic-ui-react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
import { sortBy } from 'lodash'
|
||||
import { func } from 'prop-types'
|
||||
import qs from 'query-string'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Form, Header, Message, Popup, Radio, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { clearCourses, findCoursesV2 } from 'redux/coursesearch'
|
||||
import { getCourseStats, clearCourseStats } from 'redux/coursestats'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { getCourseSearchResults } from 'selectors/courses'
|
||||
import { useSearchHistory, useToggle } from 'common/hooks'
|
||||
import { validateInputLength } from 'common'
|
||||
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '../../AutoSubmitSearchInput'
|
||||
import { MemoizedCourseTable as CourseTable } from '../CourseTable'
|
||||
import { SearchHistory } from '../../SearchHistory'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { validateInputLength } from '@/common'
|
||||
import { useSearchHistory, useToggle } from '@/common/hooks'
|
||||
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '@/components/AutoSubmitSearchInput'
|
||||
import { MemoizedCourseTable as CourseTable } from '@/components/CourseStatistics/CourseTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { clearCourses, findCoursesV2 } from '@/redux/coursesearch'
|
||||
import { getCourseStats, clearCourseStats } from '@/redux/coursestats'
|
||||
import { getCourseSearchResults } from '@/selectors/courses'
|
||||
|
||||
const INITIAL = {
|
||||
courseName: '',
|
||||
@@ -52,13 +52,19 @@ export const SearchForm = ({ onProgress }) => {
|
||||
const { courseName, courseCode, selectedCourses, separate } = state
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { courseCodes, separate, unifyOpenUniCourses, ...rest } = qs.parse(location.search)
|
||||
const {
|
||||
courseCodes,
|
||||
separate,
|
||||
unifyOpenUniCourses,
|
||||
combineSubstitutions: combineSubstitutionsFromUrl,
|
||||
...rest
|
||||
} = qs.parse(location.search)
|
||||
const query = {
|
||||
...INITIAL,
|
||||
...rest,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate: JSON.parse(separate),
|
||||
combineSubstitutions: JSON.parse(combineSubstitutions),
|
||||
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -177,16 +183,16 @@ export const SearchForm = ({ onProgress }) => {
|
||||
const renderFetchStatisticsButton = () => {
|
||||
const FetchStatisticsButton = (
|
||||
<Form.Button
|
||||
type="button"
|
||||
basic
|
||||
content="Fetch statistics"
|
||||
data-cy="fetch-stats-button"
|
||||
disabled={disabled}
|
||||
fluid
|
||||
size="huge"
|
||||
primary
|
||||
basic
|
||||
positive
|
||||
content="Fetch statistics"
|
||||
onClick={onSubmitFormClick}
|
||||
data-cy="fetch-stats-button"
|
||||
positive
|
||||
primary
|
||||
size="huge"
|
||||
type="button"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -195,12 +201,12 @@ export const SearchForm = ({ onProgress }) => {
|
||||
if (disabled) {
|
||||
return (
|
||||
<Popup
|
||||
on="hover"
|
||||
position="top center"
|
||||
content={getTextIn({
|
||||
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
|
||||
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
|
||||
})}
|
||||
on="hover"
|
||||
position="top center"
|
||||
trigger={<span>{FetchStatisticsButton}</span>}
|
||||
/>
|
||||
)
|
||||
@@ -256,23 +262,23 @@ export const SearchForm = ({ onProgress }) => {
|
||||
<label>Name:</label>
|
||||
<AutoSubmitSearchInput
|
||||
doSearch={fetchCourses}
|
||||
placeholder="Search by entering a course name"
|
||||
value={courseName}
|
||||
onChange={cn => setState({ ...state, courseName: cn })}
|
||||
loading={isLoading}
|
||||
minSearchLength={0}
|
||||
onChange={cn => setState({ ...state, courseName: cn })}
|
||||
placeholder="Search by entering a course name"
|
||||
value={courseName}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field width={3}>
|
||||
<label>Code:</label>
|
||||
<AutoSubmitSearchInput
|
||||
data-cy="course-code-input"
|
||||
doSearch={fetchCourses}
|
||||
placeholder="Search by a course code"
|
||||
value={courseCode}
|
||||
onChange={cc => setState({ ...state, courseCode: cc })}
|
||||
loading={isLoading}
|
||||
minSearchLength={0}
|
||||
data-cy="course-code-input"
|
||||
onChange={cc => setState({ ...state, courseCode: cc })}
|
||||
placeholder="Search by a course code"
|
||||
value={courseCode}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
@@ -287,38 +293,38 @@ export const SearchForm = ({ onProgress }) => {
|
||||
}}
|
||||
>
|
||||
<Popup
|
||||
on="hover"
|
||||
content={getTextIn({
|
||||
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
|
||||
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
|
||||
})}
|
||||
size="tiny"
|
||||
on="hover"
|
||||
position="top center"
|
||||
size="tiny"
|
||||
trigger={
|
||||
<Radio
|
||||
toggle
|
||||
label="Combine substitutions"
|
||||
checked={combineSubstitutions}
|
||||
onChange={toggleCombineSubstitutions}
|
||||
data-cy="combine-substitutions-toggle"
|
||||
label="Combine substitutions"
|
||||
onChange={toggleCombineSubstitutions}
|
||||
toggle
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popup
|
||||
on="hover"
|
||||
content={getTextIn({
|
||||
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
|
||||
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
|
||||
})}
|
||||
size="tiny"
|
||||
on="hover"
|
||||
position="top center"
|
||||
size="tiny"
|
||||
trigger={
|
||||
<Radio
|
||||
toggle
|
||||
label="Select multiple courses"
|
||||
checked={selectMultipleCoursesEnabled}
|
||||
onChange={toggleSelectMultipleCoursesEnabled}
|
||||
data-cy="select-multiple-courses-toggle"
|
||||
label="Select multiple courses"
|
||||
onChange={toggleSelectMultipleCoursesEnabled}
|
||||
toggle
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -334,30 +340,30 @@ export const SearchForm = ({ onProgress }) => {
|
||||
)}
|
||||
<div style={!noSelectedCourses ? searchBoxStyle : null}>
|
||||
<CourseTable
|
||||
title="Selected courses"
|
||||
hidden={noSelectedCourses}
|
||||
courses={selected}
|
||||
onSelectCourse={onSelectCourse}
|
||||
controlIcon="trash alternate outline"
|
||||
courses={selected}
|
||||
hidden={noSelectedCourses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
selectedTable
|
||||
title="Selected courses"
|
||||
/>
|
||||
{renderFetchStatisticsButton()}
|
||||
</div>
|
||||
<CourseTable
|
||||
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
|
||||
courses={courses}
|
||||
title="Searched courses"
|
||||
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
|
||||
onSelectCourse={onSelectCourse}
|
||||
title="Searched courses"
|
||||
/>
|
||||
{courses.length && selectMultipleCoursesEnabled ? (
|
||||
<div className="select-all-container">
|
||||
<Form.Button
|
||||
type="button"
|
||||
size="large"
|
||||
content="Select all search results"
|
||||
basic
|
||||
color="green"
|
||||
content="Select all search results"
|
||||
onClick={addAllCourses}
|
||||
size="large"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { Segment, Header, Form, Grid, Button, Popup } from 'semantic-ui-react'
|
||||
import { shape, string, arrayOf, objectOf, oneOfType, number, func, bool } from 'prop-types'
|
||||
import { difference, min, max, flatten, pickBy, uniq } from 'lodash'
|
||||
import { arrayOf, bool, func, number, objectOf, oneOfType, shape, string } from 'prop-types'
|
||||
import qs from 'query-string'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { difference, min, max, flatten, pickBy, uniq } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { Button, Form, Grid, Header, Popup, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useGetMaxYearsToCreatePopulationFromQuery } from 'redux/populations'
|
||||
import { setSelectedCourse, clearSelectedCourse } from 'redux/singleCourseStats'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import { ResultTabs } from '../ResultTabs'
|
||||
import { ProgrammeDropdown } from '../ProgrammeDropdown'
|
||||
import { ALL, getAllStudyProgrammes } from '../../../selectors/courseStats'
|
||||
import { YearFilter } from '../SearchForm/YearFilter'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
|
||||
import { ResultTabs } from '@/components/CourseStatistics/ResultTabs'
|
||||
import { YearFilter } from '@/components/CourseStatistics/SearchForm/YearFilter'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetMaxYearsToCreatePopulationFromQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { setSelectedCourse, clearSelectedCourse } from '@/redux/singleCourseStats'
|
||||
import { ALL, getAllStudyProgrammes } from '@/selectors/courseStats'
|
||||
import { countTotalStats } from './countTotalStats'
|
||||
|
||||
const countFilteredStudents = (stat, filter) =>
|
||||
@@ -415,7 +415,7 @@ const SingleCourseStats = ({
|
||||
|
||||
const renderShowPopulation = (disabled = false) => {
|
||||
if (userHasAccessToAllStats) {
|
||||
return <Button disabled={disabled} onClick={showPopulation} content="Show population" />
|
||||
return <Button content="Show population" disabled={disabled} onClick={showPopulation} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -441,7 +441,7 @@ const SingleCourseStats = ({
|
||||
<div>
|
||||
<Segment>
|
||||
<Header as="h4">Statistics by time range</Header>
|
||||
<YearFilter years={filteredYears} fromYear={fromYear} toYear={toYear} handleChange={handleYearChange} />
|
||||
<YearFilter fromYear={fromYear} handleChange={handleYearChange} toYear={toYear} years={filteredYears} />
|
||||
<Form>
|
||||
{maxYearsToCreatePopulationFrom < toYear - fromYear + 1 ? (
|
||||
<Popup
|
||||
@@ -463,22 +463,22 @@ const SingleCourseStats = ({
|
||||
<Grid>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="primary"
|
||||
options={options}
|
||||
label="Primary group"
|
||||
name="primary"
|
||||
onChange={handleSelect}
|
||||
options={options}
|
||||
placeholder="Select study programmes"
|
||||
value={primary}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="comparison"
|
||||
options={comparisonProgrammes(options)}
|
||||
label="Comparison group"
|
||||
name="comparison"
|
||||
onChange={handleSelect}
|
||||
options={comparisonProgrammes(options)}
|
||||
placeholder="Optional"
|
||||
value={comparison}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8} />
|
||||
@@ -486,8 +486,8 @@ const SingleCourseStats = ({
|
||||
<Form.Group>
|
||||
<Form.Button
|
||||
content="Select excluded study programmes"
|
||||
onClick={setExcludedToComparison}
|
||||
disabled={primary.length === 1 && primary[0] === ALL.value}
|
||||
onClick={setExcludedToComparison}
|
||||
/>
|
||||
<Form.Button content="Clear" onClick={clearComparison} />
|
||||
</Form.Group>
|
||||
@@ -497,10 +497,10 @@ const SingleCourseStats = ({
|
||||
</Segment>
|
||||
)}
|
||||
<ResultTabs
|
||||
separate={separate}
|
||||
primary={statistics.primary}
|
||||
comparison={statistics.comparison}
|
||||
availableStats={availableStats}
|
||||
comparison={statistics.comparison}
|
||||
primary={statistics.primary}
|
||||
separate={separate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { oneOfType, number, string, bool } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Segment, Label, Header, Divider, Form } from 'semantic-ui-react'
|
||||
import { oneOfType, number, string, bool } from 'prop-types'
|
||||
|
||||
import { getCourseStats, getAvailableStats, getCourses } from 'selectors/courseStats'
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import { setSelectedCourse } from 'redux/singleCourseStats'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { setSelectedCourse } from '@/redux/singleCourseStats'
|
||||
import { getCourseStats, getAvailableStats, getCourses } from '@/selectors/courseStats'
|
||||
import { ConnectedSingleCourseStats as SingleCourseStats } from '../SingleCourseStats'
|
||||
|
||||
const CourseSelector = ({ courses, selected, setSelected }) => {
|
||||
@@ -19,7 +19,7 @@ const CourseSelector = ({ courses, selected, setSelected }) => {
|
||||
<>
|
||||
<Header as="h4">Select course</Header>
|
||||
<Form>
|
||||
<Form.Select data-cy="course-selector" fluid options={courses} onChange={onCourseChange} value={selected} />
|
||||
<Form.Select data-cy="course-selector" fluid onChange={onCourseChange} options={courses} value={selected} />
|
||||
</Form>
|
||||
<Divider />
|
||||
</>
|
||||
@@ -43,14 +43,14 @@ export const SingleCourseTab = ({ selected, setSelected, userHasAccessToAllStats
|
||||
<Segment>
|
||||
{courses.length > 1 && <CourseSelector courses={courses} selected={selected} setSelected={setSelected} />}
|
||||
<Label
|
||||
key={stats[selected].coursecode}
|
||||
content={`${stats[selected].alternatives.map(code => ` ${code}`)} ${getTextIn(stats[selected].name)} `}
|
||||
key={stats[selected].coursecode}
|
||||
/>
|
||||
</Segment>
|
||||
<SingleCourseStats
|
||||
availableStats={availableStats[selected]}
|
||||
stats={stats[selected]}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
availableStats={availableStats[selected]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Button, Icon } from 'semantic-ui-react'
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
import { getTimestamp } from 'common'
|
||||
|
||||
import { getTimestamp } from '@/common'
|
||||
|
||||
export const DataExport = ({ data }) => {
|
||||
const jsonItems = data.reduce((arr, cur) => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Form, Label, Segment, Header } from 'semantic-ui-react'
|
||||
import { connect, useSelector } from 'react-redux'
|
||||
import { flatten } from 'lodash'
|
||||
import React from 'react'
|
||||
import { connect, useSelector } from 'react-redux'
|
||||
import { Form, Header, Label, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { fields, setValue } from 'redux/coursesSummaryForm'
|
||||
import { ALL, getAllStudyProgrammes, summaryStatistics, getQueryInfo } from '../../../selectors/courseStats'
|
||||
import { AttemptsTable } from '../AttemptsTable'
|
||||
import { ProgrammeDropdown } from '../ProgrammeDropdown'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { userHasAccessToAllCourseStats } from '../courseStatisticsUtils'
|
||||
import { getFullStudyProgrammeRights } from '@/common'
|
||||
import { AttemptsTable } from '@/components/CourseStatistics/AttemptsTable'
|
||||
import { userHasAccessToAllCourseStats } from '@/components/CourseStatistics/courseStatisticsUtils'
|
||||
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { fields, setValue } from '@/redux/coursesSummaryForm'
|
||||
import { ALL, getAllStudyProgrammes, getQueryInfo, summaryStatistics } from '@/selectors/courseStats'
|
||||
import { DataExport } from './DataExport'
|
||||
|
||||
// Certified JavaScript moment but basically this was crashing
|
||||
@@ -29,8 +30,9 @@ const unObjectifyProperty = ({ obj, property }) => {
|
||||
}
|
||||
|
||||
const SummaryTab = ({ setValue, onClickCourse }) => {
|
||||
const { roles, rights } = useGetAuthorizedUserQuery()
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, rights)
|
||||
const { roles, programmeRights } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
const programmes = useSelector(state => getAllStudyProgrammes(state))
|
||||
const programmeCodes = useSelector(({ courseSummaryForm }) => courseSummaryForm[fields.programmes])
|
||||
const form = useSelector(({ courseSummaryForm }) => courseSummaryForm)
|
||||
@@ -77,10 +79,10 @@ const SummaryTab = ({ setValue, onClickCourse }) => {
|
||||
<>
|
||||
<Header as="h4">Filter statistics by study programmes</Header>
|
||||
<ProgrammeDropdown
|
||||
options={options}
|
||||
label="Study programmes:"
|
||||
name={fields.programmes}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
value={form[fields.programmes]}
|
||||
/>
|
||||
</>
|
||||
@@ -91,7 +93,7 @@ const SummaryTab = ({ setValue, onClickCourse }) => {
|
||||
{queryInfo.timeframe.map(objBeforeUbObjectifying => {
|
||||
const obj = unObjectifyProperty({ obj: objBeforeUbObjectifying, property: 'name' })
|
||||
const { code, name } = obj
|
||||
return <Label key={code} content={name} />
|
||||
return <Label content={name} key={code} />
|
||||
})}
|
||||
</Label.Group>
|
||||
</Form.Field>
|
||||
@@ -102,8 +104,8 @@ const SummaryTab = ({ setValue, onClickCourse }) => {
|
||||
</div>
|
||||
<AttemptsTable
|
||||
categoryName="Course"
|
||||
onClickCourse={onClickCourse}
|
||||
data={data}
|
||||
onClickCourse={onClickCourse}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Header, Segment, Tab, Message } from 'semantic-ui-react'
|
||||
import qs from 'query-string'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import qs from 'query-string'
|
||||
import { Header, Message, Segment, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { clearCourseStats, getCourseStats } from 'redux/coursestats'
|
||||
import { checkUserAccess } from 'common'
|
||||
import { useProgress, useTitle } from 'common/hooks'
|
||||
import { checkUserAccess, getFullStudyProgrammeRights } from '@/common'
|
||||
import { useProgress, useTitle } from '@/common/hooks'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { clearCourseStats, getCourseStats } from '@/redux/coursestats'
|
||||
import { userHasAccessToAllCourseStats } from './courseStatisticsUtils'
|
||||
import { FacultyLevelStatistics } from './FacultyLevelStatistics'
|
||||
import { SearchForm } from './SearchForm'
|
||||
import { SingleCourseTab } from './SingleCourseTab'
|
||||
import { FacultyLevelStatistics } from './FacultyLevelStatistics'
|
||||
import { ConnectedSummaryTab as SummaryTab } from './SummaryTab'
|
||||
import { ProgressBar } from '../ProgressBar'
|
||||
import { userHasAccessToAllCourseStats } from './courseStatisticsUtils'
|
||||
import './courseStatistics.css'
|
||||
|
||||
const MENU = {
|
||||
@@ -26,7 +26,7 @@ const MENU = {
|
||||
export const CourseStatistics = () => {
|
||||
const history = useHistory()
|
||||
const dispatch = useDispatch()
|
||||
const { rights, roles } = useGetAuthorizedUserQuery()
|
||||
const { programmeRights, roles } = useGetAuthorizedUserQuery()
|
||||
const state = useSelector(state => state)
|
||||
const { courseStats } = state
|
||||
const { pending: loading } = courseStats
|
||||
@@ -66,7 +66,9 @@ export const CourseStatistics = () => {
|
||||
setSelected(coursecode)
|
||||
}
|
||||
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, rights)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
|
||||
|
||||
const getPanes = () => {
|
||||
let panes = [
|
||||
@@ -122,12 +124,12 @@ export const CourseStatistics = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkUserAccess(['courseStatistics', 'admin'], roles) && rights.length < 1)
|
||||
if (!checkUserAccess(['courseStatistics', 'admin'], roles) && fullStudyProgrammeRights.length < 1)
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message
|
||||
error
|
||||
color="red"
|
||||
error
|
||||
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
|
||||
/>
|
||||
</div>
|
||||
@@ -142,10 +144,10 @@ export const CourseStatistics = () => {
|
||||
|
||||
return (
|
||||
<Tab
|
||||
menu={{ attached: false, borderless: false }}
|
||||
panes={panes}
|
||||
activeIndex={activeIndex}
|
||||
menu={{ attached: false, borderless: false }}
|
||||
onTabChange={handleTabChange}
|
||||
panes={panes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { arrayOf, object } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Card, Icon } from 'semantic-ui-react'
|
||||
import { arrayOf, object } from 'prop-types'
|
||||
|
||||
import './creditGraphTooltip.css'
|
||||
|
||||
@@ -41,11 +41,11 @@ const getCardDescription = (credits, grade, passed, isStudyModuleCredit) => (
|
||||
<div className="tooltipBodyTitle">{isStudyModuleCredit ? 'module' : 'Passed'}</div>
|
||||
<div className="tooltipBodyValue">
|
||||
{isStudyModuleCredit ? ( // eslint-disable-line
|
||||
<Icon name="certificate" color="purple" />
|
||||
<Icon color="purple" name="certificate" />
|
||||
) : passed ? (
|
||||
<Icon name="check circle outline" color="green" />
|
||||
<Icon color="green" name="check circle outline" />
|
||||
) : (
|
||||
<Icon name="circle outline" color="red" />
|
||||
<Icon color="red" name="circle outline" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
/* eslint-disable camelcase */
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import React, { useState, useRef, useMemo } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import moment from 'moment'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import { Button, Radio } from 'semantic-ui-react'
|
||||
import boostcanvas from 'highcharts/modules/boost-canvas'
|
||||
import _ from 'lodash'
|
||||
import boost from 'highcharts/modules/boost'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import { Button, Radio } from 'semantic-ui-react'
|
||||
|
||||
import './creditAccumulationGraphHC.css'
|
||||
import { reformatDate, getStudyRightElementTargetDates } from 'common'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
import { reformatDate, getStudyRightElementTargetDates } from '@/common'
|
||||
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '@/constants'
|
||||
import { useLanguage } from '../LanguagePicker/useLanguage'
|
||||
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
|
||||
|
||||
// boost canvas needed because tests break with large population
|
||||
// https://www.highcharts.com/errors/26/
|
||||
boostcanvas(Highcharts)
|
||||
boost(Highcharts)
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
import './creditAccumulationGraphHC.css'
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
@@ -61,25 +53,6 @@ const createGraphOptions = ({
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
mouseOver() {
|
||||
if (this.series.halo) {
|
||||
this.series.halo
|
||||
.attr({
|
||||
class: 'highcharts-tracker',
|
||||
})
|
||||
.toFront()
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
halo: {
|
||||
size: 9,
|
||||
attributes: {
|
||||
fill: Highcharts.getOptions().colors[2],
|
||||
'stroke-width': 2,
|
||||
stroke: Highcharts.getOptions().colors[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
@@ -569,7 +542,7 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button active={graphHeight === height} onClick={() => setGraphHeight(height)} content={label} />
|
||||
<Button active={graphHeight === height} content={label} onClick={() => setGraphHeight(height)} />
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -579,8 +552,8 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
{!singleStudent && studyPlanFilterIsActive ? (
|
||||
<Radio
|
||||
checked={cutStudyPlanCredits}
|
||||
onChange={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
|
||||
label="Display credits from study right start"
|
||||
onChange={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
|
||||
toggle
|
||||
/>
|
||||
) : null}
|
||||
@@ -593,7 +566,7 @@ export const CreditAccumulationGraphHighCharts = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReactHighstock highcharts={Highcharts} ref={chartRef} constructorType="stockChart" config={options} />
|
||||
<ReactHighstock config={options} ref={chartRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { Modal, Form, Button, TextArea } from 'semantic-ui-react'
|
||||
import qs from 'query-string'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Modal, Form, Button, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { SearchHistory } from 'components/SearchHistory'
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import {
|
||||
useCreateOpenUniCourseSearchMutation,
|
||||
useDeleteOpenUniCourseSearchMutation,
|
||||
useUpdateOpenUniCourseSearchMutation,
|
||||
} from 'redux/openUniPopulations'
|
||||
} from '@/redux/openUniPopulations'
|
||||
|
||||
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
const location = useLocation()
|
||||
@@ -147,14 +147,14 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={modal}
|
||||
size="small"
|
||||
trigger={
|
||||
<Button size="small" color="blue" onClick={() => setModal(true)} data-cy="open-uni-search-button">
|
||||
<Button color="blue" data-cy="open-uni-search-button" onClick={() => setModal(true)} size="small">
|
||||
Fetch Open Uni Students
|
||||
</Button>
|
||||
}
|
||||
open={modal}
|
||||
onClose={handleClose}
|
||||
size="small"
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
@@ -164,15 +164,16 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
<em> Insert name for this population if you wish to save it </em>
|
||||
<Form.Input
|
||||
disabled={selectedSearchId !== ''}
|
||||
value={name}
|
||||
placeholder="name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="name"
|
||||
value={name}
|
||||
/>
|
||||
</Form.Field>
|
||||
<em>Insert course code(s)</em>
|
||||
<TextArea value={input} placeholder="TKT12345, PSYK-123" onChange={e => setInput(e.target.value)} />
|
||||
<TextArea onChange={e => setInput(e.target.value)} placeholder="TKT12345, PSYK-123" value={input} />
|
||||
</Form.Field>
|
||||
<SearchHistory
|
||||
handleSearch={onSelectSearch}
|
||||
header="Saved populations"
|
||||
items={searchList?.map(s => ({
|
||||
...s,
|
||||
@@ -181,17 +182,16 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
params: { id: s.id },
|
||||
}))}
|
||||
updateItem={() => null}
|
||||
handleSearch={onSelectSearch}
|
||||
/>
|
||||
<Form.Field data-cy="begin-of-search">
|
||||
<em>Select beginning</em>
|
||||
<Datetime
|
||||
value={startdate}
|
||||
closeOnSelect
|
||||
isValidDate={date => date.isBefore(enddate)}
|
||||
locale="fi"
|
||||
onChange={value => setStartdate(value)}
|
||||
timeFormat={false}
|
||||
locale="fi"
|
||||
isValidDate={date => date.isBefore(enddate)}
|
||||
closeOnSelect
|
||||
value={startdate}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field data-cy="end-of-search">
|
||||
@@ -199,36 +199,36 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
<br />
|
||||
<em>Attainments are fetched until today.</em>
|
||||
<Datetime
|
||||
value={enddate}
|
||||
closeOnSelect
|
||||
isValidDate={date => date.isAfter(startdate)}
|
||||
locale="fi"
|
||||
onChange={value => setEnddate(value)}
|
||||
timeFormat={false}
|
||||
locale="fi"
|
||||
isValidDate={date => date.isAfter(startdate)}
|
||||
closeOnSelect
|
||||
value={enddate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Save"
|
||||
data-cy="save-search"
|
||||
disabled={!name || updateIsLoading || createIsLoading}
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
floated="left"
|
||||
icon="save"
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
onClick={onSave}
|
||||
content="Save"
|
||||
/>
|
||||
<Button
|
||||
content="Delete"
|
||||
disabled={!selectedSearchId || deleteIsLoading}
|
||||
negative
|
||||
floated="left"
|
||||
icon="trash"
|
||||
negative
|
||||
onClick={onDelete}
|
||||
content="Delete"
|
||||
/>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button positive onClick={e => onClicker(e)} data-cy="search-button">
|
||||
<Button data-cy="search-button" onClick={e => onClicker(e)} positive>
|
||||
Search population
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import moment from 'moment'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader, Icon } from 'semantic-ui-react'
|
||||
|
||||
import { useGetOpenUniCourseStudentsQuery } from 'redux/openUniPopulations'
|
||||
import { SortableTable } from 'components/SortableTable'
|
||||
import { useLanguage } from 'components/LanguagePicker/useLanguage'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SortableTable } from '@/components/SortableTable'
|
||||
import { useGetOpenUniCourseStudentsQuery } from '@/redux/openUniPopulations'
|
||||
|
||||
const getTableData = studentsData => {
|
||||
return Object.keys(studentsData).reduce(
|
||||
@@ -90,9 +90,9 @@ const getColumns = (labelsToCourses, getTextIn) => {
|
||||
]
|
||||
const findRowContent = (s, courseCode) => {
|
||||
if (s.courseInfo[courseCode] === undefined) return null
|
||||
if (s.courseInfo[courseCode].status.passed) return <Icon fitted name="check" color="green" />
|
||||
if (s.courseInfo[courseCode].status.failed) return <Icon fitted name="times" color="red" />
|
||||
if (s.courseInfo[courseCode].status.unfinished) return <Icon fitted name="minus" color="grey" />
|
||||
if (s.courseInfo[courseCode].status.passed) return <Icon color="green" fitted name="check" />
|
||||
if (s.courseInfo[courseCode].status.failed) return <Icon color="red" fitted name="times" />
|
||||
if (s.courseInfo[courseCode].status.unfinished) return <Icon color="grey" fitted name="minus" />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -231,13 +231,13 @@ export const OpenUniPopulationResults = ({ fieldValues }) => {
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: '50px' }}>
|
||||
<div style={{ maxHeight: '80vh', width: '100%' }} data-cy="open-uni-table-div">
|
||||
<div data-cy="open-uni-table-div" style={{ maxHeight: '80vh', width: '100%' }}>
|
||||
{courseList.length > 0 && (
|
||||
<SortableTable
|
||||
title={`Open Uni Student Population (${Object.keys(openUniStudentStats?.data.students).length} students)`}
|
||||
featureName="open_uni"
|
||||
columns={tableData.columns}
|
||||
data={tableData.data}
|
||||
featureName="open_uni"
|
||||
title={`Open Uni Student Population (${Object.keys(openUniStudentStats?.data.students).length} students)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import moment from 'moment'
|
||||
import React, { useState } from 'react'
|
||||
import { Message, Icon, Loader } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { useGetSavedSearchesQuery } from 'redux/openUniPopulations'
|
||||
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
|
||||
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
|
||||
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
|
||||
|
||||
export const CustomOpenUniPopulation = () => {
|
||||
useTitle('Custom open uni population')
|
||||
@@ -22,14 +23,14 @@ export const CustomOpenUniPopulation = () => {
|
||||
<p>
|
||||
Here you can create custom population using a list of courses. Clicking the blue button will open a modal
|
||||
where you can enter a list of courses. <br />
|
||||
<Icon fitted name="check" color="green" />: Student has passed the course. <br />
|
||||
<Icon fitted name="times" color="red" />: Student has failed the course. <br />
|
||||
<Icon fitted name="minus" color="grey" />: Student has enrolled, but has not received any grade from the
|
||||
<Icon color="green" fitted name="check" />: Student has passed the course. <br />
|
||||
<Icon color="red" fitted name="times" />: Student has failed the course. <br />
|
||||
<Icon color="grey" fitted name="minus" />: Student has enrolled, but has not received any grade from the
|
||||
course. <br />
|
||||
<b>Empty cell</b>: Student has no enrollments for the course. <br />
|
||||
</p>
|
||||
</Message>
|
||||
<CustomOpenUniSearch setValues={setValues} savedSearches={savedSearches.data} />
|
||||
<CustomOpenUniSearch savedSearches={savedSearches.data} setValues={setValues} />
|
||||
<div style={{ paddingTop: '25px', paddingBottom: '10px', fontSize: '20px' }}>
|
||||
{fieldValues && fieldValues.courseList?.length > 0 && (
|
||||
<p>
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { array, arrayOf, bool, func, string } from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Table, Segment } from 'semantic-ui-react'
|
||||
import { arrayOf, array, string, func, bool } from 'prop-types'
|
||||
import { Segment, Table } from 'semantic-ui-react'
|
||||
|
||||
import './searchResultsTable.css'
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SearchResultTable = ({ actionTrigger, headers, rows, noResultText,
|
||||
if (rows.length > 0) {
|
||||
return (
|
||||
<Segment style={{ maxHeight: '80vh', overflowY: 'auto', padding: 0 }}>
|
||||
<Table unstackable selectable={selectable} definition={false} className="fixed-header">
|
||||
<Table className="fixed-header" definition={false} selectable={selectable} unstackable>
|
||||
{getHeaderRow(headers)}
|
||||
{getTableBody(rows, selectable, actionTrigger)}
|
||||
</Table>
|
||||
|
||||
+146
-59
@@ -1,81 +1,168 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { Progress } from 'semantic-ui-react'
|
||||
|
||||
import { getNewestProgramme } from 'common'
|
||||
import { FilterToggleIcon } from 'components/common/FilterToggleIcon'
|
||||
import { getNewestProgramme } from '@/common'
|
||||
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { SearchResultTable } from './SearchResultTable'
|
||||
import { useLanguage } from '../../LanguagePicker/useLanguage'
|
||||
import { useFilters } from '../../FilterView/useFilters'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '../../FilterView/filters/programmes'
|
||||
|
||||
export const CustomPopulationProgrammeDist = ({ students, studentToTargetCourseDateMap, coursecode, studentData }) => {
|
||||
const formatStudyright = (studyright, date) => {
|
||||
if (!studyright) return undefined
|
||||
const studyrightElementsDuringCourse =
|
||||
studyright?.studyright_elements.filter(
|
||||
elem => elem.element_detail.type === 20 && moment(date).isBetween(elem.startdate, elem.enddate, 'day', '[]')
|
||||
) || []
|
||||
if (studyrightElementsDuringCourse.length === 0) {
|
||||
return {
|
||||
name: { en: 'No programme at the time of attainment', fi: 'Ei ohjelmaa suorituksen hetkellä' },
|
||||
code: '00000',
|
||||
facultyCode: '00000',
|
||||
}
|
||||
}
|
||||
const { element_detail: elementDetail, code } = studyrightElementsDuringCourse.sort(
|
||||
(a, b) => new Date(b.startdate) - new Date(a.startdate)
|
||||
)[0]
|
||||
return { name: elementDetail.name, code, facultyCode: studyright.faculty_code }
|
||||
}
|
||||
|
||||
export const findCorrectProgramme = (student, coursecodes, semesters, startDate, endDate) => {
|
||||
const yearcode = Object.values(semesters.years || {}).find(
|
||||
year => moment(year.startdate).isSame(startDate, 'day') && moment(year.enddate).isSame(endDate, 'day')
|
||||
)?.yearcode
|
||||
const correctSemesters = Object.values(semesters.semesters || {})
|
||||
.filter(sem => sem.yearcode === yearcode)
|
||||
.map(sem => sem.semestercode)
|
||||
let programme
|
||||
const courseAttainment = student.courses.filter(
|
||||
a =>
|
||||
coursecodes.includes(a.course_code) &&
|
||||
moment(a.date).isBetween(startDate, endDate, undefined, '[]') &&
|
||||
a.credittypecode !== 7
|
||||
)[0]
|
||||
const courseEnrollments = student.enrollments?.filter(e => coursecodes.includes(e.course_code)) || []
|
||||
let studyrightIdOfCourse
|
||||
const findStudyrightAssociatedWithCourse = (studyright, date) =>
|
||||
studyright.studyright_elements.some(
|
||||
e => e.element_detail.type === 20 && moment(date).isBetween(e.startdate, e.enddate, undefined, '[]')
|
||||
)
|
||||
|
||||
// First check if there's a studyright associated with the course attainment
|
||||
if (courseAttainment) {
|
||||
studyrightIdOfCourse = courseAttainment?.studyright_id
|
||||
if (!studyrightIdOfCourse) {
|
||||
const studyplanStudyrightId = student.studyplans.find(sp =>
|
||||
sp.included_courses.some(c => coursecodes.includes(c))
|
||||
)?.studyrightid
|
||||
if (studyplanStudyrightId) {
|
||||
studyrightIdOfCourse = student.studyrights.find(
|
||||
sr => sr.studyrightid === studyplanStudyrightId
|
||||
).actual_studyrightid
|
||||
}
|
||||
}
|
||||
if (studyrightIdOfCourse) {
|
||||
const correctStudyrights = student.studyrights.filter(sr => sr.actual_studyrightid === studyrightIdOfCourse)
|
||||
const studyrightAssociatedWithCourse = correctStudyrights.find(sr =>
|
||||
findStudyrightAssociatedWithCourse(sr, courseAttainment.date)
|
||||
)
|
||||
programme = formatStudyright(studyrightAssociatedWithCourse, courseAttainment.date)
|
||||
}
|
||||
}
|
||||
|
||||
// If no studyright associated with the course attainment, check if there's a studyright associated with the course enrollment
|
||||
if (!programme) {
|
||||
const courseEnrollment = courseEnrollments.find(e => correctSemesters.includes(e.semestercode))
|
||||
studyrightIdOfCourse = courseEnrollment?.studyright_id
|
||||
if (studyrightIdOfCourse) {
|
||||
const correctStudyrights = student.studyrights.filter(sr => sr.actual_studyrightid === studyrightIdOfCourse)
|
||||
const studyrightAssociatedWithCourse = correctStudyrights.find(sr =>
|
||||
findStudyrightAssociatedWithCourse(sr, courseEnrollment.enrollment_date_time)
|
||||
)
|
||||
programme = formatStudyright(studyrightAssociatedWithCourse, courseEnrollment.enrollment_date_time)
|
||||
}
|
||||
}
|
||||
return programme
|
||||
}
|
||||
|
||||
export const CustomPopulationProgrammeDist = ({
|
||||
students,
|
||||
studentToTargetCourseDateMap,
|
||||
coursecode,
|
||||
studentData,
|
||||
from,
|
||||
to,
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
if (!semesters) return null
|
||||
const allProgrammes = {}
|
||||
|
||||
const [tableRows, setRows] = useState([])
|
||||
students.forEach(student => {
|
||||
let programme
|
||||
if (coursecode) {
|
||||
programme = findCorrectProgramme(student, coursecode, semesters, from, to)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(studentData ?? {}).length === 0) return
|
||||
const allProgrammes = {}
|
||||
|
||||
students.forEach(student => {
|
||||
let programme = getNewestProgramme(
|
||||
// If no studyright associated with the course attainment or enrollment or there are no courseCodes,
|
||||
// (custom population) just get the newest studyright at the time of the course attainment or enrollment
|
||||
if (!programme) {
|
||||
programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
studentData.elementdetails?.data
|
||||
)
|
||||
if (programme && programme.code === '00000' && coursecode) {
|
||||
const filteredEnrollments = (student.enrollments || [])
|
||||
// eslint-disable-next-line camelcase
|
||||
.filter(({ course_code }) => coursecode.includes(course_code))
|
||||
.sort((a, b) => new Date(b.enrollment_date_time) - new Date(a.enrollment_date_time))
|
||||
programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
{ [student.studentNumber]: (filteredEnrollments[0] || {}).enrollment_date_time },
|
||||
studentData.elementdetails?.data
|
||||
)
|
||||
}
|
||||
if (programme && programme.code === '00000' && coursecode) {
|
||||
const filteredEnrollments = (student.enrollments || [])
|
||||
.filter(({ course_code: courseCode }) => coursecode.includes(courseCode))
|
||||
.sort((a, b) => new Date(b.enrollment_date_time) - new Date(a.enrollment_date_time))
|
||||
programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
{ [student.studentNumber]: (filteredEnrollments[0] || {}).enrollment_date_time },
|
||||
studentData.elementdetails?.data
|
||||
)
|
||||
}
|
||||
if (programme) {
|
||||
if (!allProgrammes[programme.code]) {
|
||||
allProgrammes[programme.code] = { programme, students: 0 }
|
||||
}
|
||||
if (programme) {
|
||||
if (allProgrammes[programme.code]) {
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
} else {
|
||||
allProgrammes[programme.code] = { programme, students: [] }
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
}
|
||||
} else {
|
||||
if (!allProgrammes['00000']) {
|
||||
allProgrammes['00000'] = { programme: { name: { en: 'No programme' } }, students: [] }
|
||||
}
|
||||
allProgrammes['00000'].students.push({ studentnumber: student.studentnumber })
|
||||
allProgrammes[programme.code].students += 1
|
||||
} else {
|
||||
if (!allProgrammes['00000']) {
|
||||
allProgrammes['00000'] = { programme: { name: { en: 'No programme', fi: 'Ei ohjelmaa' } }, students: 0 }
|
||||
}
|
||||
})
|
||||
const rows = Object.entries(allProgrammes).map(([code, { programme, students: programmeStudents }]) => [
|
||||
getTextIn(programme.name),
|
||||
code,
|
||||
programmeStudents.length,
|
||||
<Progress
|
||||
key={code}
|
||||
value={programmeStudents.length}
|
||||
total={students.length}
|
||||
progress="percent"
|
||||
precision={0}
|
||||
style={{ margin: 0 }}
|
||||
/>,
|
||||
])
|
||||
const sortedRows = rows.sort((a, b) => b[2] - a[2])
|
||||
setRows(sortedRows)
|
||||
}, [students])
|
||||
allProgrammes['00000'].students += 1
|
||||
}
|
||||
})
|
||||
const rows = Object.entries(allProgrammes).map(([code, { programme, students: programmeStudents }]) => [
|
||||
getTextIn(programme.name),
|
||||
code,
|
||||
programmeStudents,
|
||||
<Progress
|
||||
key={code}
|
||||
precision={0}
|
||||
progress="percent"
|
||||
style={{ margin: 0 }}
|
||||
total={students.length}
|
||||
value={programmeStudents}
|
||||
/>,
|
||||
])
|
||||
const sortedRows = rows.sort((a, b) => b[2] - a[2])
|
||||
|
||||
const headers = ['Programme', 'Code', `Students (all=${students.length})`, 'Percentage of population']
|
||||
|
||||
return (
|
||||
<SearchResultTable
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
selectable
|
||||
noResultText="placeholder"
|
||||
actionTrigger={row => <ProgrammeFilterToggleCell programme={row[1]} />}
|
||||
headers={headers}
|
||||
noResultText="placeholder"
|
||||
rows={sortedRows}
|
||||
selectable
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -87,7 +174,7 @@ const ProgrammeFilterToggleCell = ({ programme }) => {
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
|
||||
<FilterToggleIcon onClick={() => filterDispatch(toggleProgrammeSelection(programme))} isActive={isActive} />
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleProgrammeSelection(programme))} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user