Compare commits

...
105 Commits
Author SHA1 Message Date
arttu 2d0d152fb7 [Updater] Fix resolving 'is_open' in credits, improve code 2024-03-12 18:27:42 +02:00
Valtteri Kantanen 2272890cb4 [Course statistics] Fix page crashing because of missing studyrights 2024-03-12 17:50:49 +02:00
Valtteri Kantanen ba8471df88 Remove the rest of absolute imports without '@' 2024-03-12 17:42:22 +02:00
arttu 575a580120 [OpenUni] Fix credits not showing 2024-03-12 17:35:42 +02:00
Valtteri Kantanen 0a07dedede [Course population] Improve logic for finding the correct programme for student (#4187) 2024-03-12 17:18:24 +02:00
Riku Rauhala ed0f700fc2 [Study programme] Update text aligns for tables 2024-03-12 13:50:57 +02:00
Riku Rauhala 616cc60b39 [Course population] Fix InfoBox margin 2024-03-12 13:30:30 +02:00
ShootingStar91 68b730a747 Fix faculty programme getter returning multiples of some programmes, causing double credits to be count in progress stats and possibly elsewhere 2024-03-12 13:29:30 +02:00
Riku Rauhala b541857980 [Navigation bar] Format navigation items 2024-03-12 13:26:57 +02:00
Valtteri Kantanen 2b3e436979 [Course population] Clean up extra state and a useEffect 2024-03-12 13:20:22 +02:00
Valtteri Kantanen ba1224c983 [Course population] Use the same logic for credit gains as for programme distribution 2024-03-12 13:15:05 +02:00
Valtteri Kantanen aeab65358d [Course population] Extract logic for finding the correct study programme into its own function 2024-03-12 13:08:52 +02:00
Riku Rauhala e865d12676 [Navigation bar] For dropdown menu items, set the top level item as active 2024-03-12 13:05:27 +02:00
Valtteri Kantanen 96760bd0fb [Course population] Display correct programme if a student has many (#4433) 2024-03-12 12:27:10 +02:00
Riku Rauhala eb7b91a959 [Study programme] Align cells to the right in the By semester tab table 2024-03-12 10:40:03 +02:00
Riku Rauhala e07677ecaa [Study programme] Update chart/table styles 2024-03-11 15:29:50 +02:00
Riku Rauhala d1a729804e [Study programme] Fix countries hover not working properly with 0 value 2024-03-11 15:04:50 +02:00
Riku Rauhala bb1a43ac87 [Study programme] Update students table style to match the one in Faculties 2024-03-11 14:46:00 +02:00
ShootingStar91 d493ac53a5 [Progress stats] Show progamme barchart credits from left to right 2024-03-11 13:48:53 +02:00
Riku Rauhala 0695e031bf Sort props 2024-03-11 13:44:01 +02:00
Riku Rauhala f799420b42 [Faculties] Add missing margin on the Graduation times tab 2024-03-11 10:46:30 +02:00
Riku Rauhala 23a5ee7b08 [Faculties] Align starting year cells to the right 2024-03-11 10:31:08 +02:00
arttu 71b5a87ef1 [University view] Fix credit category titles in expanded table view 2024-03-09 16:42:54 +02:00
Valtteri Kantanen e0b15e457e [Users] Fix logic for filtering out old and specialized programmes 2024-03-08 15:06:50 +02:00
Valtteri Kantanen addacd63c9 [Users] Display a message on user page if they don't have a person id 2024-03-08 15:05:41 +02:00
Riku Rauhala fc8c36392e Update imports 2024-03-08 15:05:03 +02:00
Valtteri Kantanen 1dc2c942f3 Revert "[Users] Move language-related stuff to userRouter"
This reverts commit f5ae6d42e9.
2024-03-08 13:06:58 +02:00
Riku Rauhala cd3ccbf5fb [Faculty statistics] Update styles 2024-03-08 12:49:01 +02:00
Valtteri Kantanen 341b04b01a [Course stats] Fix undeterministic behaviour of combineSubstitutions toggle 2024-03-08 11:49:08 +02:00
Riku Rauhala f826f5b93c [FacultyBarChart] Add the div back 2024-03-08 09:58:22 +02:00
Riku Rauhala 137d48a483 [University] Align content cell text to the right 2024-03-07 22:06:33 +02:00
Riku Rauhala 89f31d5cdf [University] Update styles 2024-03-07 21:51:55 +02:00
arttu 1a8ccde9f3 [Open uni] Fix code that checks if student has degreestudyright 2024-03-07 19:07:57 +02:00
arttu 96a185a59e [Users] Log username if no sufficient rights to see oodikone 2024-03-06 15:01:50 +02:00
arttu 678cd070ec [Updater] Small adjustements to individual student schedule 2024-03-06 12:36:17 +02:00
arttu a606117807 [Custom population] Fix notification showing students that could not be shown to user 2024-03-06 10:29:05 +02:00
ShootingStar91 db9543edd8 Add possibility to schedule individual updates of all students in new programmes 2024-03-05 20:47:24 +02:00
arttu eeeeb1c671 Revert "Add explode backend button & route"
This reverts commit b2b2d87ff3.
2024-03-05 16:11:28 +02:00
ShootingStar91 16b18bb92b [University view] Add include/exclude special studyrights toggle 2024-03-05 14:08:12 +02:00
arttu 2c2f98599e [Credits produced] Add tooptip about transferred credit dates 2024-03-05 12:14:18 +02:00
arttu 1f0b91a612 Revert "[Credits produced] Transfers are again counted to the year of original attainment"
This reverts commit e3151f67d1.
2024-03-05 12:12:06 +02:00
ShootingStar91 e3151f67d1 [Credits produced] Transfers are again counted to the year of original attainment 2024-03-04 13:38:42 +02:00
ShootingStar91 59ab60fc5e [Credits produced] Further improve share resolving code 2024-03-04 13:15:09 +02:00
Riku Rauhala 25233fd13e Disable credits in Highcharts 2024-03-04 13:01:07 +02:00
Riku Rauhala a1e0560faa Rename defaultConfig to config for consistency with other files 2024-03-04 12:53:42 +02:00
Riku Rauhala 7c5c785a6e Get Highcharts href and text from constants 2024-03-04 12:53:42 +02:00
Riku Rauhala 61a4073d82 Move course statistics graph options away from constants 2024-03-04 12:53:42 +02:00
Riku Rauhala d19ef59fc3 Replace the Highcharts default link with toska.dev 2024-03-04 12:53:42 +02:00
Riku Rauhala 986a12c27b Make some minor code style tweaks 2024-03-04 12:53:42 +02:00
Valtteri Kantanen 892a366984 [Users] Replace rights with programmeRights both in backend and frontend 2024-03-01 17:18:45 +02:00
Valtteri Kantanen d87d611db7 [Users] Replace elementDetails with programmeRights 2024-03-01 14:45:53 +02:00
Valtteri Kantanen f5ae6d42e9 [Users] Move language-related stuff to userRouter 2024-03-01 14:45:53 +02:00
Valtteri Kantanen 40af5f0f99 [Users] Add new field to user object: programmeRights 2024-03-01 14:45:53 +02:00
ShootingStar91 e139f4cb4e Fix acual tests 2024-03-01 13:34:12 +02:00
ShootingStar91 71d993246b Revert "Run tests on PR and development"
This reverts commit a3067c6b23.
2024-03-01 13:32:16 +02:00
ShootingStar91 a3067c6b23 Run tests on PR and development 2024-03-01 13:17:26 +02:00
ShootingStar91 01841e104e Fix cypress tests again 2024-03-01 12:06:11 +02:00
ShootingStar91 1ae4c0b39e Fix tests of programme overview 2024-03-01 11:52:56 +02:00
Riku Rauhala 32e4a22c00 [University view] Add missing title 2024-03-01 10:51:32 +02:00
ShootingStar91 013da38a6b Added error listener to frontend: sends errors to sentry if they are not catched by error boundary (event handlers etc) 2024-03-01 10:49:38 +02:00
ShootingStar91 b2b2d87ff3 Add explode backend button & route 2024-03-01 10:48:40 +02:00
arttu 7e22fcd856 [Credits produced] Fix logic: Include Vaasa credits, fix provider resolving, fix determining category 2024-02-29 20:44:56 +02:00
arttu 329466dde9 [Rapodiff] Script changes for investigating every category better 2024-02-29 20:44:56 +02:00
Valtteri Kantanen a7ebc4fb99 Fix containers not being removed after setup 2024-02-28 18:05:17 +02:00
Valtteri Kantanen 6e7ee36db1 [Teachers] Fix doctoral programmes not showing up in the course provider dropdown 2024-02-28 18:03:50 +02:00
Valtteri Kantanen ca8dc45ceb Add ESLint rule for import order 2024-02-27 20:42:37 +02:00
Valtteri Kantanen 9998f2600d Add a weekly cron job for deleting users who haven't logged in for 18 months 2024-02-27 14:43:20 +02:00
Riku Rauhala 1f370ea9c5 [Course statistics] Fix the application crashing while entering course population via the Attempts table 2024-02-27 13:33:16 +02:00
Riku Rauhala f319e3e2a0 [Course statistics] Rearrange imports 2024-02-27 13:32:34 +02:00
Riku Rauhala 01e701b770 [Course population] Rearrange imports 2024-02-27 13:05:50 +02:00
Valtteri Kantanen 4542f4179d Clean up the actionHistory object before sending it to Sentry 2024-02-27 12:29:58 +02:00
Riku Rauhala 288ff2bada Remove duplicate parameters 2024-02-26 19:46:27 +02:00
Valtteri Kantanen dce0642f08 [Teachers] Fix course providers not showing 2024-02-26 14:36:22 +02:00
Valtteri Kantanen c0a1cf99f2 Remove unused file to fix ESLint error 2024-02-26 14:19:47 +02:00
Valtteri Kantanen f4f1ed6818 [Users] Add infobox to user page about IAM based access rights 2024-02-26 14:14:27 +02:00
Valtteri Kantanen e0a7ed139f Fix sorting error 2024-02-26 14:13:35 +02:00
Valtteri Kantanen a5fe61a654 [Users] Remove user faculties from frontend and backend 2024-02-26 14:13:20 +02:00
Valtteri Kantanen d02dff573c [Users] Display IAM group based study programme access rights on user page 2024-02-26 10:58:41 +02:00
Valtteri Kantanen 56faf46a1a [Users] Fix sorting by username 2024-02-26 10:57:51 +02:00
Valtteri Kantanen 9e09168b01 [Sortable table] Fix multi select filter 2024-02-26 10:32:22 +02:00
Riku Rauhala 9d474ca61e [Course statistics] Display enrolled students with no grade in the pass rate chart 2024-02-23 17:03:27 +02:00
Riku Rauhala 9c39f00ec3 Rearrange imports 2024-02-23 13:15:30 +02:00
Riku Rauhala 45d3178193 [Course statistics] Refactor panes 2024-02-23 13:12:01 +02:00
ShootingStar91 ea588e2997 [Faculties] Fix redis-key related issues 2024-02-23 12:33:31 +02:00
ShootingStar91 563e39f6b5 [Updater] Explode frontend button 2024-02-23 11:32:42 +02:00
Valtteri Kantanen a46a9c773d [University view] Fix import 2024-02-23 10:54:56 +02:00
Valtteri Kantanen 6227b5fd81 Replace lazy imports with regular imports 2024-02-23 10:51:04 +02:00
Valtteri Kantanen 653fe445a8 Clean up Highcharts code 2024-02-23 10:50:11 +02:00
arttu cf2931c182 Fix faculty credit refresh 2024-02-22 01:41:59 +02:00
arttu 012e863569 Fix credit computations 2024-02-22 01:36:53 +02:00
ShootingStar91 21207adc94 Studyprogramme overview tests: Fix credit years, as they were changed to show empty years too 2024-02-21 13:26:57 +02:00
ShootingStar91 47193678d9 Credits produced -tables: Don't show the next academic year before it has started 2024-02-21 13:26:25 +02:00
ShootingStar91 021ee51ff2 Fix missing imports 2024-02-21 12:39:22 +02:00
ShootingStar91 51779add4f [Faculties] Credits produced: Made to use rapo categories 2024-02-21 12:35:02 +02:00
ShootingStar91 984d95c236 make rapodiff do a percentage-based diff 2024-02-21 12:32:42 +02:00
Valtteri Kantanen 81b0516b37 [Dependencies] Update minor/patch versions of react-router-dom and xlsx 2024-02-20 19:51:41 +02:00
Valtteri Kantanen c734cf0dce Replace uuid with crypto.randomUUID in the frontend 2024-02-20 19:51:41 +02:00
Valtteri Kantanen bbb7c6b059 Extract logic for generating colors for charts into a util function 2024-02-20 19:51:41 +02:00
Valtteri Kantanen db801b76b6 [ColorizedCoursesTable] Move facultyMap away from context 2024-02-20 19:51:41 +02:00
Riku Rauhala 9e8a92366e Fix InfoBox margins 2024-02-20 14:54:01 +02:00
Riku Rauhala e83acc14b3 [Course statistics] Remove the HelpButton component 2024-02-20 14:15:04 +02:00
Riku Rauhala 5aea071132 [Course statistics] Replace the Help button with the Show info button for charts 2024-02-20 14:15:04 +02:00
Riku Rauhala a0ac892069 [Course statistics] Replace the Help button with the Show info button for tables 2024-02-20 14:15:04 +02:00
Riku Rauhala bd56d13bd8 Remove bottom margin from the InfoBox 2024-02-20 14:15:04 +02:00
Riku Rauhala 22c62db449 Add an option for displaying the InfoBox message as a popup instead 2024-02-20 14:15:04 +02:00
312 changed files with 4436 additions and 4709 deletions
+1 -1
View File
@@ -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 ===
+20 -14
View File
@@ -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')
-1
View File
@@ -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"
+6
View File
@@ -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,
}
+91 -35
View File
@@ -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
}, {})
+4 -2
View File
@@ -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))
+5 -3
View File
@@ -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)
+4 -22
View File
@@ -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) => {
+22 -17
View File
@@ -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')
+43 -27
View File
@@ -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' })
+5 -3
View File
@@ -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 -3
View File
@@ -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]))
}
}
+6
View File
@@ -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
-7
View File
@@ -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 -191
View File
@@ -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',
+75 -57
View File
@@ -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,
}
+4
View File
@@ -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,
}
+27 -18
View File
@@ -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": {
+3 -3
View File
@@ -1,9 +1,9 @@
{
"compilerOptions": {
"baseUrl": "src",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src/**/*"]
}
+30 -204
View File
@@ -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"
},
+8 -8
View File
@@ -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"
}
}
}
+5 -4
View File
@@ -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).
`,
}
+24 -24
View File
@@ -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' },
+3 -2
View File
@@ -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)
+36 -4
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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} />
</>
)
@@ -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."
/>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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}
/>
)
}
@@ -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}
/>
)
}
@@ -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)
@@ -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>
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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()
@@ -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>
@@ -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}
/>
)
}
@@ -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>
@@ -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>
@@ -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>
@@ -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