Compare commits

...
260 Commits
Author SHA1 Message Date
Miko Keskimäki 11f9b935b4 Log iam rights status via accessLogger for Grafana 2022-09-13 12:53:50 +03:00
outisa a16896e74d [Faculty] Update updater 2022-09-12 13:33:47 +03:00
outisa 814599d6ea [Faculty] Small refactoring and adjusting numbers 2022-09-10 12:18:01 +03:00
outisa 8f2dcac0f9 Fix bug for updating statistics 2022-09-09 18:24:29 +03:00
outisa 240659afc9 [Faculty] Add include/exclude studywork toggle 2022-09-09 15:12:22 +03:00
outisa b9059e2a52 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-09-09 15:09:22 +03:00
outisa a1688f7cdd [Faculty] Add infotool text 2022-09-09 14:19:05 +03:00
Laura Sinikallio 2c378bfca2 Patch breaking population statistics course filters ('didn't participate' disabled for now) 2022-09-09 11:54:39 +03:00
outisa aec495b161 [Navigation] Fix issue for faculty tab showing wrongly 2022-09-06 09:58:59 +03:00
outisa 486bc1a194 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-09-02 14:19:55 +03:00
outisa 18bcb91d09 Add one more test for faculty view 2022-09-02 14:19:19 +03:00
outisa 5c4d73f555 Add new tests for faculty 2022-09-02 13:21:50 +03:00
Laura Sinikallio 27e8450789 Small clean-ups 2022-09-01 14:40:35 +03:00
Laura Sinikallio 56733f2445 Subtract statutory absences from faculty graduation times 2022-09-01 14:34:41 +03:00
Laura Sinikallio 089b00fcd6 Show graduation times only for levels with data 2022-09-01 11:52:05 +03:00
Laura Sinikallio a709fe70ba Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-31 19:23:14 +03:00
Laura Sinikallio f26e4b4859 Betterments to graduations times 2022-08-31 19:23:06 +03:00
outisa 18a382167b Fix accessrights in navigation bar 2022-08-31 16:25:32 +03:00
outisa b4be0a3e6b [Faculty] Fix accessrights problems 2022-08-31 15:44:41 +03:00
outisa 596ac08ad4 Fix bug related to accessrights 2022-08-31 13:55:40 +03:00
outisa 9c6ad0027a Modify faculty accessrights in navigation bar 2022-08-31 12:05:03 +03:00
outisa 27ce374f35 [faculty] Modify access rights 2022-08-31 11:55:17 +03:00
Laura Sinikallio 7e6f311b62 Faculty - move grad times to new tab 2022-08-31 10:22:43 +03:00
Laura Sinikallio dd77fdff9e Initial pipeline for graduation times, WIP 2022-08-30 17:48:58 +03:00
outisa 0b7adf4888 [Faculty] Add messagebox for incomplete data 2022-08-30 14:12:57 +03:00
outisa 9deee8a384 [Faculty] Change color codes (perhaps) better 2022-08-30 13:58:11 +03:00
outisa 81791381a5 [Faculty] Make betterments for faculty view 2022-08-30 12:45:59 +03:00
outisa 9806639c1b [Faculty] Show total in on-hover tooltip 2022-08-30 12:13:51 +03:00
outisa 5e9a3c4b99 [Faculty] Change others to licentiates (thesis writers) 2022-08-30 12:01:38 +03:00
outisa 12f73f4dee [Faculty] Show numbers with max. 1 decimal 2022-08-30 11:54:04 +03:00
outisa 9d27dfebe5 [Faculty] Make betterments for basic view 2022-08-29 17:15:38 +03:00
outisa 7bebd4c845 Fix updating thesis writers 2022-08-29 17:14:05 +03:00
outisa 43d2e84b32 [Faculty] Add thesis writers to redis 2022-08-29 14:47:25 +03:00
Roni Lindholm ee1cb395a2 Fix custom population crash 2022-08-29 11:45:26 +03:00
Roni Lindholm 4e221d8867 Fix missing students from population of course when no attainment
Enrollments were retrieved by enrollment date instead of semester code
which corresponds to when the course is acually organized.
2022-08-29 11:11:03 +03:00
Roni Lindholm 3aab7b74a3 Fix counting of total enrollments in course stats attempts tab 2022-08-29 10:05:29 +03:00
Roni Lindholm 92651b8bc7 Make course stats search by course code case insenstive 2022-08-29 09:47:30 +03:00
Miko Keskimäki 08b53c34f2 Dependabot updates 2022-08-29 08:52:00 +03:00
Laura Sinikallio 8d6a311936 Add note to update tab 2022-08-27 12:59:05 +03:00
Laura Sinikallio 79b8b92b66 Implement faculty info texts 2022-08-27 12:03:04 +03:00
outisa fa8a07481a [Faculty] Add small adjustment for sorting 2022-08-27 10:45:23 +03:00
outisa f79d6ef0fb [Faculty] Find thesis writers only for new programmes 2022-08-26 21:43:44 +03:00
outisa bb25541202 Disable flaky faculty cypress test 2022-08-26 17:22:21 +03:00
outisa 3530723bd5 [Faculty] Change sort by selection 2022-08-26 16:41:31 +03:00
Laura Sinikallio f2ffaacc6c Info texts for faculty 2022-08-26 16:12:42 +03:00
Laura Sinikallio 14e95fe4df Add update statistics tab to faculty page, refactor programmes tab basis 2022-08-26 15:25:04 +03:00
Laura Sinikallio f69b12a1f9 Add faculty stats refresh to cronjob 2022-08-26 13:13:13 +03:00
Laura Sinikallio e4e527eaeb Imporove faculty filtering 2022-08-26 12:12:53 +03:00
Laura Sinikallio c4c8f6123b Tools for refreshing faculty stats with cronjob (unplugged) 2022-08-25 16:14:03 +03:00
Laura Sinikallio bdb71242a7 Redis edits from previous changes 2022-08-25 14:40:28 +03:00
Laura Sinikallio a3a36fcef1 Put back faculty frontside programme sorting 2022-08-25 14:17:56 +03:00
Laura Sinikallio adbcb1bb88 Faculty toggle for old programmes filters from all stats 2022-08-25 13:51:22 +03:00
outisa 37504f9e97 Fix minor bug for finding thesis writers 2022-08-24 18:29:13 +03:00
outisa 1722bcd7a3 Change order of the navigation bar 2022-08-24 17:10:37 +03:00
outisa a770873da5 [Faculty] Add language options for programme names 2022-08-24 16:45:18 +03:00
Laura Sinikallio a4c3385956 Redis for faculty credits and student info -> speed 2022-08-24 14:04:32 +03:00
Laura Sinikallio 9fe491e7cc Fix error, but better 2022-08-24 13:00:13 +03:00
Laura Sinikallio 42213948b7 Fix redis error 2022-08-24 12:28:10 +03:00
Laura Sinikallio 1014348e5b Redis for faculty programmes 2022-08-24 11:48:25 +03:00
outisa dcecd66e49 [Faculty] Fix sorting bug 2022-08-24 10:33:58 +03:00
Laura Sinikallio 4509e51f66 Refactor file structure, start of redis for faculty 2022-08-24 10:11:38 +03:00
outisa 90841369bb [Faculty] Modify calculation of thesis writers 2022-08-24 09:55:26 +03:00
outisa c3e41c8a60 [Faculty] Add small refactoring 2022-08-24 09:47:39 +03:00
Laura Sinikallio 81ffbf653e Basis for faculty graduation times 2022-08-23 10:20:28 +03:00
Laura Sinikallio b46e30c8fa Switch rest of faculty to new programme search 2022-08-22 15:52:46 +03:00
Miko Keskimäki 3619698744 Fix error with encryption when no selectable students for year exist 2022-08-22 14:21:35 +03:00
Laura Sinikallio 81ee6b8146 Improve faculty programmes retrieval 2022-08-22 14:03:49 +03:00
outisa c9b27fdc64 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-22 12:55:02 +03:00
outisa 6b50c07ed7 [Faculty] Add toggle for showing only new programmes or all programmes 2022-08-22 12:54:47 +03:00
Roni Lindholm d2670e180c Fix empty study guidance group crash 2022-08-22 11:44:30 +03:00
Roni Lindholm 580e1b4ed0 Minor study guidance group tweaks 2022-08-22 11:08:42 +03:00
Laura Sinikallio e84e487307 Small fix to programme names for faculty 2022-08-22 10:02:08 +03:00
Roni Lindholm d0a54c95a6 Refactor UI for editing of study guidance group metadata
Open editing form to a modal and wrap study guidance table on small
screens.
2022-08-22 09:38:41 +03:00
outisa 18ac10f399 [Faculty] Add sorting by column values (not yet pretty) 2022-08-19 19:20:33 +03:00
Roni Lindholm 263e070b13 Fix open uni credits in teacher leaderboard 2022-08-19 11:17:03 +03:00
Roni Lindholm 8cf9ceac6e Try to fix tests 2022-08-19 08:48:56 +03:00
Roni Lindholm 32005ce23f Add transferred credits and students to programme courses 2022-08-19 08:21:54 +03:00
outisa 111a397811 [Faculty] Make charts to look prettier 2022-08-18 14:14:29 +03:00
outisa 454032368a [Faculty] Fix bug: tooltip not showing on thesis writers chart 2022-08-18 13:52:06 +03:00
Laura Sinikallio 5509cd4b9a Faculty backend tweaks 2022-08-17 16:16:01 +03:00
outisa 346c9f9803 [Faculty] Make betterments in programmelevel graphs 2022-08-17 15:21:33 +03:00
Laura Sinikallio 0a738df3d0 Improvements to faculty's programme search 2022-08-17 15:01:00 +03:00
Miko Keskimäki b67da1c2c4 Enable IAM tests
IAM user added to test db image so should work now
2022-08-17 10:14:30 +03:00
outisa e700586588 [Faculty] Add programmestats thesis writers (missing old ones) 2022-08-16 23:39:43 +03:00
outisa 9bb7c1c6f1 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-16 18:28:53 +03:00
Miko Keskimäki 7f70968531 Remove 00:00:00 time from general student information csv data 2022-08-16 11:08:40 +03:00
outisa 9ae35e46db [Faculty] Add backend functionality for finding faculty thesis writers 2022-08-16 10:42:35 +03:00
Miko Keskimäki 3d667fe1c6 Update student progress test data to match credits since start of studyright 2022-08-15 14:03:33 +03:00
Miko Keskimäki 62e07ce571 Show only credits gained since start of studyright in study programme progresstable #3661 2022-08-15 13:33:42 +03:00
Miko Keskimäki 38cd587369 Dependabot updates 2022-08-15 11:49:04 +03:00
outisa 77c9801e4c FacultyStats: Add custom sorting 2022-08-12 14:30:25 +03:00
Miko Keskimäki d3f1e101ce Prevent iam users from hiding degree courses 2022-08-12 13:12:41 +03:00
Miko Keskimäki 10a2d2743e Remove links to course statistics when only iam rights 2022-08-12 13:11:07 +03:00
Laura Sinikallio 4d26f42f25 Fix graduations error 2022-08-12 09:27:02 +03:00
Miko Keskimäki e8f3d9f601 Fix error with encryption when no students selected 2022-08-12 08:45:48 +03:00
outisa 6f3af6f135 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-11 17:02:52 +03:00
outisa 3e08836d4e Faculty: Make study programme level colors to match faculty level ones 2022-08-11 17:00:21 +03:00
Miko Keskimäki efea7cdc9e Encrypt studentnumbers in population's course statistics if iam user 2022-08-11 16:04:27 +03:00
Miko Keskimäki c6232e617f Hide students tab of courses of population from iam users 2022-08-11 16:03:08 +03:00
outisa f360716b8e FacultyStats: Show difference to previous year 2022-08-11 15:02:45 +03:00
Laura Sinikallio 55bf6b0457 General refactoring 2022-08-11 14:29:58 +03:00
Miko Keskimäki 3067b37298 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-11 13:38:03 +03:00
Miko Keskimäki a19a343feb Make courses of population visible to iam users 2022-08-11 13:36:45 +03:00
Miko Keskimäki edb5ca5422 Fix selectedStudentsByYear not being decrypted 2022-08-11 13:04:52 +03:00
Miko Keskimäki 2eb5f10eee Skip iam tests for now 2022-08-11 12:55:32 +03:00
Miko Keskimäki 7ce269d8be Decrypt student numbers 2022-08-11 12:30:58 +03:00
Miko Keskimäki 42554bba7a Send decryptable student selection to backend 2022-08-11 12:29:54 +03:00
Laura Sinikallio be77832f72 Edit column names 2022-08-11 11:58:19 +03:00
Miko Keskimäki f32d29ca34 Encrypt studentnumbers for iam user instead of hashing
Need to decrypt in backend to make courses of population tab work
2022-08-11 11:48:34 +03:00
Laura Sinikallio bb3652f9c4 Fix missing programme names 2022-08-11 10:51:17 +03:00
Laura Sinikallio a6272bff4f [Faculty] programme names for the rest of the tables 2022-08-10 15:27:20 +03:00
Miko Keskimäki 29f8cf110b Fix type error in IAMConfig 2022-08-10 12:06:57 +03:00
Laura Sinikallio 05ccbd6b16 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-10 11:41:19 +03:00
outisa 608f2a921e [Faculty] sort programmes alphabetically by default 2022-08-10 10:53:28 +03:00
Miko Keskimäki 7bd40f7030 Align study track with correct programme in student statistics 2022-08-10 10:32:40 +03:00
Laura Sinikallio 0075a5f994 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-10 09:41:16 +03:00
outisa 06414288f8 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-10 09:29:02 +03:00
outisa ac9b32408c Disable eslint in tooltip formatter() for 'no-this' 2022-08-09 18:26:39 +03:00
Laura Sinikallio 4ad1e4237c Programme details for rest of current faculty tables 2022-08-09 16:37:14 +03:00
Miko Keskimäki 2f0ebd04d0 Remove transfered studytracks from studyright associations 2022-08-09 15:17:55 +03:00
Miko Keskimäki 40264f28d1 Filter transfered students' studytracks from population stats 2022-08-09 15:17:41 +03:00
outisa 95eaef7f8d Faculty: Change button to 'array' 2022-08-09 14:50:21 +03:00
Laura Sinikallio 7a1fa68c94 Provide programme names for faculty credit table 2022-08-09 13:01:56 +03:00
Laura Sinikallio 1d86557ed4 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-09 09:07:53 +03:00
outisa d013809645 Facultystatistics: Add years to x-axis (linegraphs) 2022-08-08 17:25:34 +03:00
Laura Sinikallio 32b860bf0b [Faculty] programme-level numbers for graduations 2022-08-08 16:11:19 +03:00
Laura Sinikallio 02ea189fcb Fix table titles 2022-08-08 15:39:13 +03:00
outisa d38b647fc3 FacultyStatistics: Add new table views 2022-08-08 14:25:42 +03:00
Miko Keskimäki 0eb31cc8ea Reload to maybe make iam test user work 2022-08-08 13:52:29 +03:00
Miko Keskimäki 7aef916e70 Simple tests for iam user studyprogramme and population statistics access 2022-08-08 12:30:57 +03:00
Miko Keskimäki fc84ef78fd Create new test user with only IAM access 2022-08-08 12:28:11 +03:00
Laura Sinikallio 7c31425114 Faculty - separate graduations breakdown from students -table 2022-08-08 11:56:19 +03:00
Laura Sinikallio c88ad2201d Refactor faculty basics data structure 2022-08-08 11:07:38 +03:00
Miko Keskimäki e936510754 Update searching by course code #3457 2022-08-08 10:12:55 +03:00
outisa 825e549b49 Fix faculty stats cypress tests 2022-08-08 09:32:50 +03:00
outisa b8b0ffe34b Disable cypress tests for toggle credits for further investigation 2022-08-05 16:52:16 +03:00
outisa e2018d3e4c Modify faculty statistics cypress tests 2022-08-05 15:58:52 +03:00
outisa c3dbc9682e Add cypress tests for toggling CreditsCharts 2022-08-05 15:06:54 +03:00
Miko Keskimäki 54195f0a85 Add babel/core to dev dependencies 2022-08-05 12:50:30 +03:00
Miko Keskimäki 600795ca48 Fix more lint errors 2022-08-05 12:33:56 +03:00
Miko Keskimäki 3b2b04b10d Update eslint, eslintrc and fix errors 2022-08-05 12:08:29 +03:00
Miko Keskimäki 57ddbcaa36 Update stylelint config and fix errors 2022-08-05 12:00:52 +03:00
Miko Keskimäki 1185793e4d Update dev package major versions 2022-08-05 12:00:39 +03:00
outisa d8a5989ba1 FacultyStatistics: Make barchart look better 2022-08-05 10:54:30 +03:00
Miko Keskimäki 7a8ba85071 Run tests when actions updated 2022-08-05 09:32:12 +03:00
Miko Keskimäki 378b886947 Use newer Cypress action 2022-08-05 09:22:43 +03:00
Miko Keskimäki 167fa3b979 Update Cypress paths in test workflow and readme 2022-08-05 08:53:28 +03:00
Miko Keskimäki 7ddcf94b57 Update Cypress and migrate tests and config to v10 2022-08-05 08:48:52 +03:00
outisa f71c406180 InteractiveTAble: Fix key props 2022-08-04 17:44:47 +03:00
outisa c1a94acb92 FacultyStatistics: Add toggleTableView 2022-08-04 17:11:42 +03:00
Miko Keskimäki 1264d4f3b9 Use run-tests action for all Cypress tests 2022-08-04 15:19:43 +03:00
Miko Keskimäki 3ab87a18e3 Test composite action with failing test 2022-08-04 15:07:58 +03:00
Miko Keskimäki 2a68a9cc9b Pass registry username and password to action 2022-08-04 14:49:31 +03:00
Miko Keskimäki 15f86a85d4 Test composite action for custom population tests 2022-08-04 14:37:53 +03:00
Miko Keskimäki 16952cece6 Try moving repeatitive Cypress test job config into composite action 2022-08-04 14:35:24 +03:00
Miko Keskimäki 4c71092e7f Use actionlint npm package instead of broken docker version 2022-08-04 13:43:59 +03:00
Miko Keskimäki d2558a40d8 [skip ci] Remove test comment 2022-08-04 13:10:40 +03:00
Miko Keskimäki c918c5ffc9 Test that workflow activates 2022-08-04 13:02:49 +03:00
Miko Keskimäki 54ecddc46e Run test-oodikone.yaml when sis-db migrations updated #3729 2022-08-04 13:00:01 +03:00
Miko Keskimäki f2e655bb05 Update remaining actions 2022-08-04 12:36:48 +03:00
Miko Keskimäki 5ee3e7310e Update dependabot.yml 2022-08-04 12:30:33 +03:00
Miko Keskimäki 8bdb30bca5 Fix wrong login-action version 2022-08-04 10:54:45 +03:00
Miko Keskimäki bc06295f97 Update github actions versions
See if anything breaks
2022-08-04 10:49:51 +03:00
outisa 522e9f851e Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-08-04 10:27:45 +03:00
outisa 28e798ff28 FacultyStatistics: Add interactive data view 2022-08-04 10:26:51 +03:00
Miko Keskimäki ca4576e155 Allow usage without oodikone iams if user has iam rights to any programme 2022-08-03 13:58:36 +03:00
Laura Sinikallio 90309143fd Start of programme-level breakdown of faculty stats 2022-08-01 12:42:26 +03:00
Miko KeskimäkiandGitHub 8f4fcb4364 Merge pull request #3632 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/async-2.6.4
Bump async from 2.6.3 to 2.6.4 in /services/frontend
2022-08-01 10:55:07 +03:00
Miko KeskimäkiandGitHub facd2c15b9 Merge pull request #3538 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /services/frontend
2022-08-01 10:54:56 +03:00
Miko KeskimäkiandGitHub 756cadd0e6 Merge pull request #3539 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-worker/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /updater/sis-updater-worker
2022-08-01 10:54:44 +03:00
Miko KeskimäkiandGitHub 842a561f94 Merge pull request #3540 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6
2022-08-01 10:54:26 +03:00
Miko KeskimäkiandGitHub 9346b1e245 Merge pull request #3775 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/moment-2.29.4
Bump moment from 2.29.1 to 2.29.4
2022-08-01 10:54:09 +03:00
Miko KeskimäkiandGitHub b93b430554 Merge pull request #3777 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-worker/moment-2.29.4
Bump moment from 2.29.1 to 2.29.4 in /updater/sis-updater-worker
2022-08-01 10:53:54 +03:00
Miko KeskimäkiandGitHub a90dd779d5 Merge pull request #3778 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/moment-2.29.4
Bump moment from 2.29.3 to 2.29.4 in /updater/sis-updater-scheduler
2022-08-01 10:53:39 +03:00
Miko KeskimäkiandGitHub dafec6bd6b Merge pull request #3779 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/moment-2.29.4
Bump moment from 2.29.1 to 2.29.4 in /services/frontend
2022-08-01 10:53:17 +03:00
Miko Keskimäki b27e23e7fb [skip ci] Ignore major semver updates with Dependabot
Too many ignored PRs
2022-08-01 10:16:51 +03:00
Miko Keskimäki 6fff1f15fb Expand Cypress viewport 2022-08-01 09:46:50 +03:00
Miko Keskimäki 85bfc6041e Fix breaking test 2022-08-01 09:46:22 +03:00
Miko Keskimäki eb1a92e96a Update nonbreaking package versions 2022-08-01 09:18:03 +03:00
Miko Keskimäki 8c7a94b476 Npm audit fix 2022-08-01 09:10:56 +03:00
Miko Keskimäki f683d33769 Update all package-locks to lockfile v2 2022-08-01 09:10:53 +03:00
Miko Keskimäki 0c84cbf08f Show search by class to iam users 2022-07-29 13:11:39 +03:00
Miko Keskimäki 358f629285 Hide tabs without data from iam users 2022-07-29 13:10:53 +03:00
Miko Keskimäki af77bc500e Return obfuscated population statistics for iam rights 2022-07-29 13:03:52 +03:00
Miko Keskimäki 224e199931 Hide population statistics from iam users for now
Make visible after a more limited access and obfuscation are working
2022-07-14 12:07:20 +03:00
Miko Keskimäki cfcb28aa58 Show study programme overview to users with correct iam rights
Basic information, Studytracks and Programme courses tabs are visible
2022-07-14 11:09:39 +03:00
Laura Sinikallio 469a2be823 Fix duplicate api calls 2022-07-13 16:20:19 +03:00
Laura Sinikallio 61e0a3b380 Refactor faculty basics services 2022-07-13 15:35:00 +03:00
Laura Sinikallio 5b092f905e Add faculty transfers stats 2022-07-13 15:05:46 +03:00
Laura Sinikallio 01715f4879 Refactor faculty graduates 2022-07-13 14:06:31 +03:00
Laura Sinikallio 6acef8abad Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-13 14:04:59 +03:00
Miko Keskimäki 44743b70ca Show user's iam groups in users tab 2022-07-13 13:04:47 +03:00
Laura Sinikallio fcd637a2fa Fix issue in faculty academic year setting 2022-07-13 11:57:29 +03:00
Laura Sinikallio f5e08acf04 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-13 11:52:03 +03:00
Laura Sinikallio 04c4ee1b80 Add faculty graduations breakdown 2022-07-13 11:51:57 +03:00
Roni Lindholm d87ee4bd84 Lost test 2022-07-13 11:50:02 +03:00
Roni Lindholm 04758edba0 Better numbers for tests? 2022-07-13 11:14:11 +03:00
Laura Sinikallio 796feea25c Fix faculty graduate count 2022-07-13 11:08:20 +03:00
Roni Lindholm 52c47a214e Unskip test 2022-07-13 10:21:08 +03:00
Laura Sinikallio 92450f634c Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-12 14:33:04 +03:00
Laura Sinikallio fec92c752f Faculty started and graduated stats exist 2022-07-12 14:32:57 +03:00
Roni Lindholm 5d6bbfb4b5 Fix study track filter when student has graduated
Fixes #3536
2022-07-12 14:12:10 +03:00
Miko Keskimäki 3c22bbc3d1 Move getting iam rights into formatUser to work with mock users 2022-07-12 13:56:38 +03:00
Roni Lindholm 5e5edede6f Skip for now 2022-07-12 13:41:23 +03:00
Laura Sinikallio a2a4a05f02 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-12 13:17:03 +03:00
Laura Sinikallio 4f0f5acb88 Remove duplicates from faculty started stats, work on graduated 2022-07-12 13:16:56 +03:00
Roni Lindholm f00ef92e86 Uncomment correct course stats attemps table test values 2022-07-12 12:41:30 +03:00
Roni Lindholm 38f1872d68 Fix course stats attemps table crash 2022-07-12 12:26:56 +03:00
Miko Keskimäki c314138d89 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-12 12:25:04 +03:00
Miko Keskimäki b174082623 Include only relevant iams 2022-07-12 12:23:57 +03:00
Miko Keskimäki 38dcf3dcae Save iam groups and get iam based access rights in userService 2022-07-12 12:15:40 +03:00
Miko Keskimäki a872500fb1 Add iam_groups column to user 2022-07-12 12:09:22 +03:00
Roni Lindholm 5a9d8b922c Fix broken obscurification in course stats
Fixes #3785
2022-07-12 10:45:02 +03:00
Miko Keskimäki 9ee09fdbcf Add modified version of IAM logic from Norppa 2022-07-12 10:31:46 +03:00
Laura Sinikallio 8a9e2d2c5c Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-12 10:31:39 +03:00
Laura Sinikallio 1f955889c6 Work on faculty basic stats 2022-07-12 10:31:32 +03:00
Roni Lindholm a565179cb2 Mock current date in filter tests
Fixes #3360, no happy birthday commits anymore :(
2022-07-12 10:23:49 +03:00
Laura Sinikallio 42307f6e56 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-12 09:47:21 +03:00
Roni Lindholm bfd3119a28 Clean also transfers from and to duplicate study programmes
Fixes #3784
2022-07-12 08:51:53 +03:00
Roni Lindholm 596688f701 Fix duplicate study programmes
Update references from duplicate study programme to corresponding
regular study programme in post update. Fixes #3259.
2022-07-12 08:02:19 +03:00
Laura Sinikallio eef3c3c766 Faculty touch-ups 2022-07-11 11:25:23 +03:00
Laura Sinikallio c1f81ef855 Stabilize most faculty non-major credits 2022-07-08 12:51:56 +03:00
Laura Sinikallio 30ac43e131 Fix faculty selector language picker 2022-07-08 09:41:38 +03:00
Laura Sinikallio 67cb87c932 Unify spelling 2022-07-07 15:36:57 +03:00
Laura Sinikallio f044966725 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-07 15:34:21 +03:00
Laura Sinikallio 6606755d99 Faculty credit stats redone 2022-07-07 15:31:41 +03:00
Roni Lindholm 9afe77977d Fix language picker in study programme overview 2022-07-07 14:10:42 +03:00
Laura Sinikallio 12aef60d93 Start rewrite of faculty credits 2022-07-07 13:26:41 +03:00
Roni Lindholm 3479c95a6d Fix study programme overview graduation tests 2022-07-07 13:25:42 +03:00
Roni Lindholm a0b3e3f76c Fix study programme overview graduated stats 2022-07-07 13:10:50 +03:00
Laura Sinikallio 107f64d85a Edit faculty test 2022-07-07 10:52:39 +03:00
Roni Lindholm 03327f59c1 Update study programme overview tests 2022-07-07 10:46:53 +03:00
Laura Sinikallio 98d2e52e64 Add faculty tests to workflow better 2022-07-07 10:44:47 +03:00
dependabot[bot]andGitHub 7608f62869 Bump moment from 2.29.1 to 2.29.4 in /services/frontend
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-07 07:41:06 +00:00
dependabot[bot]andGitHub 03c3b99f3d Bump moment from 2.29.3 to 2.29.4 in /updater/sis-updater-scheduler
Bumps [moment](https://github.com/moment/moment) from 2.29.3 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.3...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-07 07:40:51 +00:00
dependabot[bot]andGitHub af08a0b681 Bump moment from 2.29.1 to 2.29.4 in /updater/sis-updater-worker
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-07 07:40:43 +00:00
Laura Sinikallio 9ea473dc68 Add faculty tests to workflow 2022-07-07 10:39:59 +03:00
dependabot[bot]andGitHub 44ee47c34d Bump moment from 2.29.1 to 2.29.4
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-07 04:29:55 +00:00
Laura Sinikallio 18e4055588 Basis for faculty student stats 2022-07-06 14:38:05 +03:00
Laura Sinikallio ba819d4074 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-06 14:27:58 +03:00
Roni Lindholm b9eaf86711 Fix graduations in study programme overview basic info table 2022-07-06 14:22:26 +03:00
Roni Lindholm 9f92bb94b1 Fix transfer and study right start dates when using snapshot date time
Use first snapshot date time when study right starts or education
changes. This was not working previously.
2022-07-06 13:28:41 +03:00
Laura Sinikallio 51cb20c75d Fix refresh crashing faculty page 2022-07-06 10:15:50 +03:00
Laura Sinikallio fe1243f9b8 Add tests for faculty view 2022-07-05 16:55:51 +03:00
Laura Sinikallio b81c7140a8 Refactor faculty state management 2022-07-05 14:58:13 +03:00
Laura Sinikallio b5fb6274dd Refactor faculty credits 2022-07-05 14:20:14 +03:00
Roni Lindholm 4c363b2658 Fix study programme overview caching crash when no data 2022-07-05 12:16:38 +03:00
Laura Sinikallio 881da1c79d Refactor faculty stats routes 2022-07-05 12:14:44 +03:00
Laura Sinikallio e8301077f6 Filter out some faculties 2022-07-05 10:43:49 +03:00
Laura Sinikallio d59c6092b3 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-05 09:46:20 +03:00
Roni Lindholm 09dfd45cb2 Fix credit distribution graph crash on future population 2022-07-05 09:16:48 +03:00
Roni Lindholm 008e808134 Refactor study programme routes and fix credit stats caching 2022-07-05 09:07:42 +03:00
Laura Sinikallio 370084845d Show credits produced by faculty 2022-07-04 18:49:24 +03:00
Laura Sinikallio de73afef15 Add basis for faculty view elements 2022-07-04 14:47:12 +03:00
Laura Sinikallio d981144bc4 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-07-04 14:40:29 +03:00
Laura Sinikallio 05520f31ec Add faculty selector 2022-07-04 12:47:51 +03:00
dependabot[bot]andGitHub f79aafa6cf Bump minimist from 1.2.5 to 1.2.6 in /services/frontend
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-25 11:24:38 +00:00
dependabot[bot]andGitHub 0b5b4b5ebd Bump async from 2.6.3 to 2.6.4 in /services/frontend
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-25 11:24:29 +00:00
dependabot[bot]andGitHub e61a4cf09c Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-25 11:23:36 +00:00
dependabot[bot]andGitHub 11d58a9fc4 Bump minimist from 1.2.5 to 1.2.6 in /updater/sis-updater-worker
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-25 11:23:22 +00:00
137 changed files with 58751 additions and 10231 deletions
+2 -1
View File
@@ -14,6 +14,7 @@
"no-console": "error",
"react/no-unescaped-entities": "off",
"no-async-promise-executor": "off",
"prettier/prettier": "error"
"prettier/prettier": "error",
"cypress/no-assigning-return-values": "off"
}
}
+52
View File
@@ -0,0 +1,52 @@
name: 'Run tests'
description: 'Runs Cypress tests for individual spec'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
spec:
required: true
description: 'Path to Cypress file'
runs:
using: composite
steps:
- name: 'Download images'
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built images'
shell: bash
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: 'Run services, pull rest of the needed images'
shell: bash
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v4
with:
browser: chrome
spec: ${{ inputs.spec }}
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v3
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
+21
View File
@@ -8,6 +8,10 @@ updates:
labels:
- 'dependencies'
- 'infra'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: '/updater/sis-updater-scheduler'
schedule:
@@ -16,6 +20,10 @@ updates:
labels:
- 'dependencies'
- 'updater'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: '/updater/sis-updater-worker'
schedule:
@@ -24,6 +32,10 @@ updates:
labels:
- 'dependencies'
- 'updater'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: '/services/backend'
schedule:
@@ -32,6 +44,10 @@ updates:
labels:
- 'dependencies'
- 'back'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: '/services/frontend'
schedule:
@@ -40,8 +56,13 @@ updates:
labels:
- 'dependencies'
- 'front'
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: weekly
time: '03:00'
open-pull-requests-limit: 10
+8 -8
View File
@@ -9,10 +9,10 @@ jobs:
name: 'Build and release backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Login to DockerHub'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -23,7 +23,7 @@ jobs:
id: extract_version
run: echo ::set-output name=VERSION::"${GITHUB_REF#refs/tags/}"
- name: 'Build and push'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
@@ -41,10 +41,10 @@ jobs:
name: 'Build and release frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Login to DockerHub'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -58,7 +58,7 @@ jobs:
id: extract_version
run: echo ::set-output name=VERSION::"${GITHUB_REF#refs/tags/}"
- name: 'Build and push'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
+16 -16
View File
@@ -14,10 +14,10 @@ jobs:
name: 'Build sis-updater-worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Build the image'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -30,7 +30,7 @@ jobs:
cache-from: type=registry,ref=toska/sis-updater-worker:latest
cache-to: type=inline
- name: 'Store built image'
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
path: /tmp/worker.tar
@@ -38,10 +38,10 @@ jobs:
name: 'Build sis-updater-scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Build the image'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -54,7 +54,7 @@ jobs:
cache-from: type=registry,ref=toska/sis-updater-scheduler:latest
cache-to: type=inline
- name: 'Store built image'
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
path: /tmp/scheduler.tar
@@ -65,15 +65,15 @@ jobs:
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load worker image'
@@ -114,15 +114,15 @@ jobs:
needs:
- test_updater
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Login to DockerHub'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 'Build and push scheduler'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
cache-from: type=registry,ref=toska/sis-updater-scheduler:latest
cache-to: type=inline
@@ -131,7 +131,7 @@ jobs:
tags: toska/sis-updater-scheduler:latest
target: production
- name: 'Build and push worker'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
cache-from: type=registry,ref=toska/sis-updater-worker:latest
cache-to: type=inline
+78 -238
View File
@@ -6,14 +6,16 @@ on:
- master
paths:
- '.github/workflows/test-oodikone.yaml'
- '.github/actions/**'
- 'cypress/**'
- 'cypress.json'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff.js'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'nginx.conf'
- 'package*.json'
- 'updater/sis-updater-worker/src/db/migrations/**'
jobs:
# === Static tests / linting ===
@@ -22,8 +24,8 @@ jobs:
name: 'Lint backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.4.1
with:
node-version: 14
- name: 'Install dependencies'
@@ -35,8 +37,8 @@ jobs:
name: 'Lint frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.4.1
with:
node-version: 14
- name: 'Install dependencies'
@@ -51,10 +53,10 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Build image and export it'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -69,7 +71,7 @@ jobs:
target: production
outputs: type=docker,dest=/tmp/backend.tar
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
path: /tmp/backend.tar
@@ -77,13 +79,13 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- name: 'Get current time'
id: get_current_time
run: echo ::set-output name=NOW::"$(date --iso-8601=seconds)"
- name: 'Build image and export it'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -99,7 +101,7 @@ jobs:
target: production
outputs: type=docker,dest=/tmp/frontend.tar
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
path: /tmp/frontend.tar
@@ -107,7 +109,7 @@ jobs:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: check_migrations
with:
@@ -116,7 +118,7 @@ jobs:
- 'updater/sis-updater-worker/src/db/migrations/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
@@ -134,7 +136,7 @@ jobs:
- build_backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: check_migrations
with:
@@ -143,13 +145,13 @@ jobs:
- 'services/backend/src/database/migrations_kone/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Download images'
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built backend image'
run: docker load --input /tmp/artifact/backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
@@ -167,7 +169,7 @@ jobs:
- build_backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: check_migrations
with:
@@ -176,13 +178,13 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test-oodikone.yaml'
- name: 'Download images'
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built backend image'
run: docker load --input /tmp/artifact/backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
@@ -205,39 +207,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Course_statistics.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Course_statistics.js
test_custom_population:
name: 'Test custom population'
@@ -249,39 +225,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Custom_population.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Custom_population.js
test_population_statistics:
name: 'Test population statistics'
@@ -293,39 +243,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Population_statistics.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Population_statistics.js
test_filters:
name: 'Test filters'
@@ -337,39 +261,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Filters.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Filters.js
test_student_statistics:
name: 'Test student statistics'
@@ -381,39 +279,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Student_statistics.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Student_statistics.js
test_studyprogramme_overview:
name: 'Test studyprogramme overview'
@@ -425,39 +297,13 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: cypress/integration/Studyprogramme_overview.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
spec: cypress/e2e/Studyprogramme_overview.js
test_teachers_and_users:
name: 'Test teachers and users'
@@ -469,41 +315,33 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Download images'
uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Run cypress'
uses: cypress-io/github-action@v2
with:
browser: chrome
spec: |
cypress/integration/Teachers.js
cypress/integration/Users.js
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v2
if: failure()
cypress/e2e/Teachers.js
cypress/e2e/Users.js
test_faculty_statistics:
name: 'Test faculty statistics'
needs:
- build_backend
- build_frontend
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
name: Cypress videos
path: ./cypress/videos/*
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
spec: cypress/e2e/Faculty_statistics.js
# === Other tests ===
@@ -517,9 +355,9 @@ jobs:
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: 'Download images'
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built images'
@@ -527,7 +365,7 @@ jobs:
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
@@ -560,11 +398,12 @@ jobs:
- test_student_statistics
- test_studyprogramme_overview
- test_teachers_and_users
- test_faculty_statistics
- test_service_exit_statuses
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: 'Download images'
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built images'
@@ -572,7 +411,7 @@ jobs:
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to DockerHub'
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -626,10 +465,11 @@ jobs:
- test_student_statistics
- test_studyprogramme_overview
- test_teachers_and_users
- test_faculty_statistics
- test_service_exit_statuses
if: failure()
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0 hadolint ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/workflows/*': files => `${dockerCmdBase} rhysd/actionlint:1.6.2 ${relativeFilePaths(files)}`,
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
'docker-compose*': files => {
const composeFiles = file => {
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
+1 -1
View File
@@ -5,5 +5,5 @@
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"jsxSingleQuote": false
}
+5 -1
View File
@@ -13,6 +13,10 @@
"ignorePseudoClasses": ["global"]
}
],
"prettier/prettier": true
"prettier/prettier": true,
"selector-class-pattern": null,
"custom-property-pattern": null,
"value-no-vendor-prefix": null,
"property-no-vendor-prefix": null
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ There are three types of tests in this project: static tests with eslint/prettie
- Cypress
- can be launched in interactive mode with `npm run cypress open`. `package.json` defines entrypoint `npm run cypress` so you can basically run cypress with any arguments you want
- are defined in cypress -folder and cypress.json
- are defined in cypress -folder and cypress.config.json
- whole cypress test stack takes about 15 to 20 mins to run. Since tests are ran in our Github actions CI pipe, you're encouraged to take advantage of this instead of running all tests locally.
- There are some different user types and cypress commands defined for testing. Take a look at these when debugging tests.
- Jest
+19
View File
@@ -0,0 +1,19 @@
const { defineConfig } = require('cypress')
module.exports = defineConfig({
projectId: 'c3jsph',
defaultCommandTimeout: 30000,
requestTimeout: 30000,
videoCompression: false,
viewportWidth: 1800,
viewportHeight: 1200,
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
})
-7
View File
@@ -1,7 +0,0 @@
{
"projectId": "c3jsph",
"baseUrl": "http://localhost:3000",
"defaultCommandTimeout": 30000,
"requestTimeout": 30000,
"videoCompression": false
}
@@ -360,7 +360,7 @@ describe('Course Statistics tests', () => {
})
})
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
it.skip('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
cy.init('/coursestatistics', 'onlycoursestatistics')
cy.get('[data-cy=navbar-courseStatistics]').click()
cy.get('[data-cy=course-code-input]').type('TKT20003')
@@ -374,10 +374,10 @@ describe('Course Statistics tests', () => {
const attemptsTableContents = [
// [time, passed, failed, passrate]
// ['Total', 288, 213, 75, '73.96 %'],
// ['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
['Total', null, 295, 216, 79, '73.22 %'],
['2020-2021', null, 5, 2, 3, '40.00 %'],
['Total', 288, 213, 75, '73.96 %'], //Somehow cypress in CI can't find the number even if it is present in the artifact video
['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
// ['Total', null, 295, 216, 79, '73.22 %'],
// ['2020-2021', null, 5, 2, 3, '40.00 %'],
['2019-2020', null, 164, 121, 43, '73.78 %'],
['2018-2019', null, 85, 60, 25, '70.59 %'],
['2017-2018', null, 39, 32, 7, '82.05 %'],
+139
View File
@@ -0,0 +1,139 @@
/// <reference types="Cypress" />
// Change admin to regular user when feature is ready for general use
describe('Faculty overview', () => {
describe('Faculty can be selected', () => {
it('Faculties are listed and one can be chosen', () => {
cy.init('/faculties', 'admin')
cy.get('[data-cy=select-faculty]').contains('td', 'H10')
cy.contains('td', 'H99').should('not.exist')
cy.contains('td', 'H90').click()
cy.contains('.header', 'Eläinlääketieteellinen tiedekunta')
})
})
describe('Faculty basic information: admin user', () => {
beforeEach(() => {
cy.init('/faculties', 'admin')
cy.contains('td', 'H90').click()
})
it('Credits produced by faculty are shown', () => {
cy.get('[data-cy="Section-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
})
it('Update statistics tab is shown', () => {
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Update statistics')
})
})
describe('Faculty basic information: basic user', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('td', 'H80').click()
})
it('Basic information tab show all graphs and tables', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-ThesisWritersOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-StudentsOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-GraduatedOfTheFaculty"]').should('be.visible')
})
it('Correct tabs are shown', () => {
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Basic information')
// Fix these when access is gained for facultyStatistics users
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Programmes and student populations')
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Graduation times')
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Update statistics')
})
it('Toggle years works', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="YearToggle"]').click()
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022 - 2023')
})
it('Toggle programmes works', () => {
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
cy.get('[data-cy="ProgrammeToggle"]').click()
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
})
it('Students of the faculty infobox works', () => {
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('not.exist')
cy.get('[data-cy="StudentsOfTheFaculty-open-info"]').click()
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('be.visible')
cy.get('[data-cy="StudentsOfTheFaculty-close-info"]').click()
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('not.exist')
})
})
describe('Study programme information: users with access', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('td', 'H80').click()
})
it('Study programme credit information is not visible in the beginning', () => {
cy.get('table[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
})
it('Study programme credit information can be toggled', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
// I tried to fix this without cy.wait() method without effort. Sorting the programmes
// or something else causes rerendering of the view and yield to failing tests.
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-3"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
cy.get('[data-cy="Button-Hide-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
})
it('Graph stays open when sorted', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Menu-CreditsProducedByTheFaculty-Total"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
})
})
})
@@ -2,6 +2,8 @@
// const baseUrl = Cypress.config().baseUrl
const MOCKED_DATE = new Date(2022, 6, 12).getTime()
const checkFilteringResult = studentCount => {
cy.contains(`Students (${studentCount})`)
}
@@ -152,6 +154,7 @@ describe('Population Statistics', () => {
})
it('Age filter works', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 20, 40, 47)
})
@@ -213,6 +216,7 @@ describe('Population Statistics', () => {
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
runTestStepWithPreAndPostParts('Age', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
@@ -273,6 +277,7 @@ describe('Course Statistics', () => {
})
it('Age filter works', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 20, 40, 33)
})
@@ -304,6 +309,7 @@ describe('Course Statistics', () => {
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Grade', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('gradeFilter-3').click()
@@ -330,6 +336,7 @@ describe('Custom Population Statistics', () => {
})
it('Age filter works', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 20, 30, 1)
})
@@ -217,4 +217,30 @@ describe('Population Statistics tests', () => {
})
})
})
describe('when using IAM user', () => {
beforeEach(() => {
cy.init(pathToCSBach2017, 'onlyiamrights')
})
it('Population statistics is visible', () => {
cy.reload()
cy.get('.card').within(() => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Sample size: 170 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
cy.contains('Credit accumulation (for 170 students)')
cy.contains('Credit statistics')
})
it('Students tab is not available', () => {
cy.contains('Students (170)').should('not.exist')
// TODO: somehow check that obfuscation works
})
})
})
@@ -45,7 +45,7 @@ describe('Studyprogramme overview', () => {
// [Year, Started, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0]),
[2021, 0, 0, 1, 0],
[2020, 12, 36, 3, 2],
[2020, 12, 35, 3, 2],
[2019, 87, 17, 0, 1],
[2018, 161, 1, 0, 0],
[2017, 171, 0, 0, 0],
@@ -77,7 +77,7 @@ describe('Studyprogramme overview', () => {
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2021, 0, 0],
[2020, 36, 35],
[2020, 35, 35],
[2019, 17, 25],
[2018, 1, 5],
[2017, 0, 0],
@@ -181,7 +181,6 @@ describe('Studyprogramme overview', () => {
.should('contain', 5)
.should('contain', 17)
.should('contain', 25)
.should('contain', 36)
.should('contain', 35)
cy.get('[data-cy=Graph-2019-AverageGraduationTimes]')
@@ -250,8 +249,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
0,
'0 %',
1,
'1.2 %',
1,
'1.2 %',
65,
@@ -292,8 +291,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
3,
'1.8 %',
4,
'2.3 %',
42,
'24.6 %',
135,
@@ -313,8 +312,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
3,
'0.7 %',
5,
'1.2 %',
54,
'12.5 %',
328,
@@ -333,11 +332,11 @@ describe('Studyprogramme overview', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
['2020 - 2021', 12, 5, 2, 2, 0, 2, 0, 1],
['2019 - 2020', 86, 13, 15, 32, 11, 2, 0, 13],
['2018 - 2019', 162, 7, 16, 35, 33, 27, 18, 26],
['2017 - 2018', 171, 10, 22, 18, 22, 24, 27, 48],
['Total', 431, 35, 55, 87, 66, 55, 45, 88],
['2020 - 2021', 12, 12, 0, 0, 0, 0, 0, 0],
['2019 - 2020', 86, 26, 34, 21, 5, 0, 0, 0],
['2018 - 2019', 162, 10, 27, 47, 36, 26, 12, 4],
['2017 - 2018', 171, 20, 21, 26, 22, 22, 33, 27],
['Total', 431, 51, 64, 97, 79, 55, 51, 34],
]
cy.checkTableStats(tableContents, 'StudytrackProgress')
@@ -351,9 +350,9 @@ describe('Studyprogramme overview', () => {
.should('contain', 'Less than 30 credits')
.should('contain', '30-59 credits')
.should('contain', 'More than 180 credits')
.should('contain', '5.8%') // The percentage for less than 30 credits in 2017-2018, to check that the graph renders
.should('contain', '4.3%') // The percentage for less than 30 credits in 2018-2019
.should('contain', '15.1%') // The percentage for less than 30 credits in 2019-2020
.should('contain', '11.7%') // The percentage for less than 30 credits in 2017-2018, to check that the graph renders
.should('contain', '6.2%') // The percentage for less than 30 credits in 2018-2019
.should('contain', '30.2%') // The percentage for less than 30 credits in 2019-2020
cy.get('[data-cy=Graph-2018-AverageGraduationTimes]')
.should('contain', '2018 - 2019')
@@ -412,7 +411,7 @@ describe('Studyprogramme overview', () => {
cy.get('[data-cy=CoursesSortableTable]').within(() => {
// Test sorting by different columns
cy.get('th').eq(0).click()
cy.get('tr').eq(1).contains('Tietojenkäsittelytieteen kisälliopetus: Java-ohjelmointi')
cy.get('tr').eq(2).contains('Tietojenkäsittelytieteen kisälliopetus: Java-ohjelmointi')
cy.get('th').eq(1).click()
cy.get('tr').eq(1).contains('Äidinkielinen viestintä')
cy.get('th').eq(2).click()
@@ -500,4 +499,42 @@ describe('Studyprogramme overview', () => {
cy.contains(name).should('not.exist')
})
})
describe('IAM user', () => {
beforeEach(() => {
cy.init('/study-programme', 'onlyiamrights')
})
it('can access programme and correct tabs are visible', () => {
cy.reload()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('Basic information')
cy.contains('Studytracks and student populations')
cy.contains('Update statistics').should('not.exist')
cy.contains('Degree Courses').should('not.exist')
})
it('can access basic information', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('Basic information').click()
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
it('can access studytracks', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Section-StudytrackOverview]')
cy.get('[data-cy=Section-StudytrackProgress]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
})
})
+17 -1
View File
@@ -43,7 +43,23 @@ const norightsUserHeaders = {
hypersonsisuid: 'hy-hlo-0000000',
}
const userHeaders = [adminUserHeaders, basicUserHeaders, onlycoursestatisticsUserHeaders, norightsUserHeaders]
const onlyIamRightsUserHeaders = {
uid: 'onlyiamrights',
displayname: 'Only IAM rights user',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'hy-employees;hy-mltdk-tkt-jory',
edupersonaffiliation: 'member',
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-1111111',
}
const userHeaders = [
adminUserHeaders,
basicUserHeaders,
onlycoursestatisticsUserHeaders,
norightsUserHeaders,
onlyIamRightsUserHeaders,
]
/**
Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
+9128 -2710
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -6,6 +6,7 @@
"concurrently": "concurrently",
"eslint": "eslint",
"stylelint": "stylelint",
"actionlint": "node-actionlint",
"prettier": "prettier",
"cli": "./cli.sh",
"oodikone": "./run.sh oodikone anon up --detach",
@@ -28,31 +29,33 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@babel/core": "^7.18.13",
"@babel/eslint-parser": "^7.17.0",
"@babel/plugin-syntax-jsx": "^7.16.7",
"concurrently": "^6.5.1",
"cypress": "^8.7.0",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^18.2.1",
"concurrently": "^7.3.0",
"cypress": "^10.6.0",
"eslint": "^8.23.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prefer-object-spread": "^1.2.1",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.1",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7.0.4",
"lint-staged": "^11.2.6",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"node-actionlint": "^1.2.2",
"prettier": "^2.5.1",
"stylelint": "^13.13.1",
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
"stylelint-prettier": "^1.2.0"
"stylelint": "^14.11.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^26.0.0",
"stylelint-prettier": "^2.0.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.29.1"
"moment": "^2.29.4"
}
}
+435
View File
@@ -0,0 +1,435 @@
const { facultyMap } = require('./data')
const joryMap = {
'hy-ttdk-tuk-jory': '100-K001',
'hy-ttdk-tum-jory': '100-M001',
'hy-oiktdk-on-jory': '200-K001',
'hy-oiktdk-otm-jory': '200-M001',
'hy-oiktdk-ibl-jory': '200-M002',
'hy-oiktdk-ggl-jory': '200-M003',
'hy-ltdk-psyk-jory': ['300-K001', '300-M004'],
'hy-ltdk-logo-jory': ['300-K002', '300-M005'],
'hy-ltdk-ll-jory': '300-M001',
'hy-ltdk-tmed-jory': '300-M002',
'hy-ltdk-hll-jory': '300-M003',
'hy-humtdk-filk-jory': '400-K001',
'hy-humtdk-ttk-jory': '400-K002',
'hy-humtdk-kik-jory': '400-K003',
'hy-humtdk-kok-jory': '400-K004',
'hy-humtdk-kuka-jory': '400-K005',
'hy-humtdk-hisk-jory': '400-K006',
'hy-humtdk-ttm-jory': '400-M001',
'hy-humtdk-kim-jory': '400-M002',
'hy-humtdk-eng-jory': '400-M003',
'hy-humtdk-rus-jory': '400-M004',
'hy-humtdk-lingdig-jory': '400-M005',
'hy-humtdk-tra-jory': '400-M006',
'hy-humtdk-suku-jory': '400-M007',
'hy-humtdk-nor-jory': '400-M008',
'hy-humtdk-kir-jory': '400-M009',
'hy-humtdk-kuma-jory': '400-M010',
'hy-humtdk-ice-jory': '400-M011',
'hy-humtdk-alku-jory': '400-M012',
'hy-humtdk-spt-jory': '400-M014',
'hy-humtdk-hism-jory': '400-M015',
'hy-mltdk-mat-jory': '500-K001',
'hy-mltdk-fys-jory': '500-K002',
'hy-mltdk-kemk-jory': '500-K003',
'hy-mltdk-mfkk-jory': '500-K004',
'hy-mltdk-tkt-jory': '500-K005',
'hy-mltdk-geok-jory': '500-K006',
'hy-mltdk-maa-jory': '500-K007',
'hy-mltdk-bsc-jory': '500-K008',
'hy-mltdk-mast-jory': '500-M001',
'hy-mltdk-lsi-jory': '500-M002',
'hy-mltdk-tcm-jory': '500-M003',
'hy-mltdk-paras-jory': '500-M004',
'hy-mltdk-matres-jory': '500-M005',
'hy-mltdk-atm-jory': '500-M006',
'hy-mltdk-kem-jory': '500-M007',
'hy-mltdk-mfkm-jory': '500-M008',
'hy-mltdk-csm-jory': '500-M009',
'hy-mltdk-data-jory': '500-M010',
'hy-mltdk-geom-jory': '500-M011',
'hy-mltdk-geog-jory': '500-M012',
'hy-mltdk-usp-jory': '500-M013',
'hy-ftdk-farm-jory': '550-K001',
'hy-ftdk-prov-jory': '550-M001',
'hy-ftdk-mpharm-jory': '550-M002',
'hy-bytdk-bio-jory': '570-K001',
'hy-bytdk-mole-jory': '570-K002',
'hy-bytdk-env-jory': '570-K003',
'hy-bytdk-eeb-jory': '570-M001',
'hy-bytdk-ips-jory': '570-M002',
'hy-bytdk-gmb-jory': '570-M003',
'hy-bytdk-neuro-jory': '570-M004',
'hy-bytdk-ecgs-jory': '570-M005',
'hy-ktdk-eduk-jory': '600-K001',
'hy-ktdk-edum-jory': '600-M001',
'hy-ktdk-ce-jory': '600-M002',
'hy-valttdk-pvk-jory': '700-K001',
'hy-valttdk-yk-jory': '700-K002',
'hy-valttdk-sosk-jory': '700-K003',
'hy-valttdk-ecok-jory': '700-K004',
'hy-valttdk-film-jory': '700-M001',
'hy-valttdk-pvm-jory': '700-M002',
'hy-valttdk-gpc-jory': '700-M003',
'hy-valttdk-ym-jory': '700-M004',
'hy-valttdk-cos-jory': '700-M005',
'hy-valttdk-ens-jory': '700-M006',
'hy-valttdk-msv-jory': '700-M007',
'hy-valttdk-sosm-jory': '700-M008',
'hy-valttdk-econ-jory': '700-M009',
'hy-valttdk-sote-jory': '700-M011',
'hy-sskh-ksv-jory': '740-K001',
'hy-mmtdk-maatk-jory': '800-K001',
'hy-mmtdk-metsak-jory': '800-K002',
'hy-mmtdk-etk-jory': '800-K003',
'hy-mmtdk-yet-jory': '800-K004',
'hy-mmtdk-agri-jory': '800-M001',
'hy-mmtdk-agere-jory': '800-M002',
'hy-mmtdk-for-jory': '800-M003',
'hy-mmtdk-food-jory': '800-M004',
'hy-mmtdk-hnfb-jory': '800-M005',
'hy-mmtdk-ekm-jory': '800-M006',
'hy-mmtdk-mmb-jory': '800-M007',
'hy-eltdk-elk-jory': '900-K001',
'hy-eltdk-ell-jory': '900-M001',
'hy-dp-dptheol-jory': 'T920101',
'hy-dp-dplaw-jory': 'T920102',
'hy-dp-philartsoc-jory': 'T920103',
'hy-dp-dphistcult-jory': 'T920104',
'hy-dp-dplang-jory': 'T920105',
'hy-dp-sky-jory': 'T920106',
'hy-dp-dpsocs-jory': 'T920107',
'hy-dp-pyam-jory': 'T920108',
'hy-dp-dpe-jory': 'T920109',
'hy-dp-seduce-jory': 'T920110',
'hy-dp-clic-jory': 'T920111',
'hy-dp-dpbm-jory': 'T921101',
'hy-dp-klto-jory': 'T921102',
'hy-dp-docpop-jory': 'T921103',
'hy-dp-findos-jory': 'T921104',
'hy-dp-dpdr-jory': 'T921105',
'hy-dp-ils-jory': 'T921106',
'hy-dp-bandm-jory': 'T921107',
'hy-dp-cvm-jory': 'T921108',
'hy-dp-dphub-jory': 'T921109',
'hy-dp-luova-jory': 'T922101',
'hy-dp-dpps-jory': 'T922102',
'hy-dp-denvi-jory': 'T922103',
'hy-dp-agforee-jory': 'T922104',
'hy-dp-mbdp-jory': 'T922105',
'hy-dp-foodhealth-jory': 'T922106',
'hy-dp-papu-jory': 'T923101',
'hy-dp-geodoc-jory': 'T923102',
'hy-dp-atm-dp-jory': 'T923103',
'hy-dp-chems-jory': 'T923104',
'hy-dp-domast-jory': 'T923105',
'hy-dp-matrena-jory': 'T923106',
'hy-dp-docs-jory': 'T923107',
}
const kojoMap = {
'hy-ttdk-tuk-jory': 'hy-ttdk-kandi-kojot',
'hy-ttdk-tum-jory': 'hy-ttdk-maisteri-kojot',
'hy-oiktdk-on-jory': 'hy-oiktdk-kandi-kojot',
'hy-oiktdk-otm-jory': 'hy-oiktdk-maisteri-kojot',
'hy-oiktdk-ibl-jory': 'hy-oiktdk-maisteri-kojot',
'hy-oiktdk-ggl-jory': 'hy-oiktdk-maisteri-kojot',
'hy-ltdk-psyk-jory': 'hy-ltdk-maisteri-kojot',
'hy-ltdk-logo-jory': 'hy-ltdk-maisteri-kojot',
'hy-ltdk-ll-jory': 'hy-ltdk-maisteri-kojot',
'hy-ltdk-tmed-jory': 'hy-ltdk-maisteri-kojot',
'hy-ltdk-hll-jory': 'hy-ltdk-maisteri-kojot',
'hy-humtdk-filk-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-ttk-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-kik-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-kok-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-kuka-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-hisk-jory': 'hy-humtdk-kandi-kojot',
'hy-humtdk-ttm-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-kim-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-eng-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-rus-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-lingdig-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-tra-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-suku-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-nor-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-kir-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-kuma-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-ice-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-alku-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-spt-jory': 'hy-humtdk-maisteri-kojot',
'hy-humtdk-hism-jory': 'hy-humtdk-maisteri-kojot',
'hy-mltdk-mat-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-fys-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-kemk-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-mfkk-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-tkt-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-geok-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-maa-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-bsc-jory': 'hy-mltdk-kandi-kojot',
'hy-mltdk-mast-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-lsi-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-tcm-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-paras-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-matres-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-atm-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-kem-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-mfkm-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-csm-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-data-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-geom-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-geog-jory': 'hy-mltdk-maisteri-kojot',
'hy-mltdk-usp-jory': 'hy-mltdk-maisteri-kojot',
'hy-ftdk-farm-jory': 'hy-ftdk-kandi-kojot',
'hy-ftdk-prov-jory': 'hy-ftdk-maisteri-kojot',
'hy-ftdk-mpharm-jory': 'hy-ftdk-maisteri-kojot',
'hy-bytdk-bio-jory': 'hy-bytdk-kandi-kojot',
'hy-bytdk-mole-jory': 'hy-bytdk-kandi-kojot',
'hy-bytdk-env-jory': 'hy-bytdk-kandi-kojot',
'hy-bytdk-eeb-jory': 'hy-bytdk-maisteri-kojot',
'hy-bytdk-ips-jory': 'hy-bytdk-maisteri-kojot',
'hy-bytdk-gmb-jory': 'hy-bytdk-maisteri-kojot',
'hy-bytdk-neuro-jory': 'hy-bytdk-maisteri-kojot',
'hy-bytdk-ecgs-jory': 'hy-bytdk-maisteri-kojot',
'hy-ktdk-eduk-jory': 'hy-ktdk-kandi-kojot',
'hy-ktdk-edum-jory': 'hy-ktdk-maisteri-kojot',
'hy-ktdk-ce-jory': 'hy-ktdk-maisteri-kojot',
'hy-valttdk-pvk-jory': 'hy-valttdk-kandi-kojot',
'hy-valttdk-yk-jory': 'hy-valttdk-kandi-kojot',
'hy-valttdk-sosk-jory': 'hy-valttdk-kandi-kojot',
'hy-valttdk-ecok-jory': 'hy-valttdk-kandi-kojot',
'hy-valttdk-film-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-pvm-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-gpc-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-ym-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-cos-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-ens-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-msv-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-sosm-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-econ-jory': 'hy-valttdk-maisteri-kojot',
'hy-valttdk-sote-jory': 'hy-valttdk-maisteri-kojot',
'hy-sskh-ksv-jory': 'hy-sskh-kandi-kojot',
'hy-mmtdk-maatk-jory': 'hy-mmtdk-kandi-kojot',
'hy-mmtdk-metsak-jory': 'hy-mmtdk-kandi-kojot',
'hy-mmtdk-etk-jory': 'hy-mmtdk-kandi-kojot',
'hy-mmtdk-yet-jory': 'hy-mmtdk-kandi-kojot',
'hy-mmtdk-agri-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-agere-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-for-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-food-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-hnfb-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-ekm-jory': 'hy-mmtdk-maisteri-kojot',
'hy-mmtdk-mmb-jory': 'hy-mmtdk-maisteri-kojot',
'hy-eltdk-elk-jory': 'hy-eltdk-kandi-kojot',
'hy-eltdk-ell-jory': 'hy-eltdk-maisteri-kojot',
'hy-dp-dptheol-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dplaw-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-philartsoc-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dphistcult-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dplang-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-sky-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dpsocs-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-pyam-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dpe-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-seduce-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-clic-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dpbm-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-klto-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-docpop-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-findos-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dpdr-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-ils-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-bandm-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-cvm-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dphub-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-luova-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-dpps-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-denvi-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-agforee-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-mbdp-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-foodhealth-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-papu-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-geodoc-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-atm-dp-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-chems-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-domast-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-matrena-jory': 'hy-tohtoriohjelma-johtajat',
'hy-dp-docs-jory': 'hy-tohtoriohjelma-johtajat',
}
/**
* Maps kosu IAM to the name of TDKs, defined in data.js facultyMap
*/
const kosuFacultyMap = {
'hy-ypa-opa-kosu-kumpula': 'matemaattis-luonnontieteellinen',
'hy-ypa-opa-kosu-meilahti': ['lääketieteellinen'],
'hy-ypa-opa-kosu-kruununhaka': [
'oikeustieteellinen',
'valtiotieteellinen',
'kasvatustieteellinen',
'humanistinen',
'teologinen',
'svenska',
],
'hy-ypa-opa-kosu-metsatalo': [
'oikeustieteellinen',
'valtiotieteellinen',
'kasvatustieteellinen',
'humanistinen',
'teologinen',
'svenska',
],
'hy-ypa-opa-kosu-porthania': [
'oikeustieteellinen',
'valtiotieteellinen',
'kasvatustieteellinen',
'humanistinen',
'teologinen',
'svenska',
],
'hy-ypa-opa-kosu-siltavuori': [
'oikeustieteellinen',
'valtiotieteellinen',
'kasvatustieteellinen',
'humanistinen',
'teologinen',
'svenska',
],
'hy-ypa-opa-kosu-viikki': [
'bio- ja ympäristötieteellinen',
'farmasia',
'maatalous -metsätieteellinen',
'eläinlääketieteellinen',
],
}
const doctoralIams = ['hy-tohtorikoulutus-johtoryhma', 'hy-tine']
const doctoralSchoolMap = {
'hy-tutkijakoulut-hymy-jory': [
'T920101',
'T920102',
'T920103',
'T920104',
'T920105',
'T920106',
'T920107',
'T920108',
'T920109',
'T920110',
'T920111',
],
'hy-tutkijakoulut-dshealth-jory': [
'T921101',
'T921102',
'T921103',
'T921104',
'T921105',
'T921106',
'T921107',
'T921108',
'T921109',
],
'hy-tutkijakoulut-yeb-jory': ['T922101', 'T922102', 'T922103', 'T922104', 'T922105', 'T922106'],
'hy-tutkijakoulut-donasci-jory': ['T923101', 'T923102', 'T923103', 'T923104', 'T923105', 'T923106', 'T923107'],
}
const opetusVaradekaani = 'hy-varadekaanit-opetus'
const dekaaniFacultyMap = {
'hy-ttdk-dekanaatti': 'teologinen',
'hy-oiktdk-dekanaatti': 'oikeustieteellinen',
'hy-ltdk-dekanaatti': 'lääketieteellinen',
'hy-humtdk-dekanaatti': 'humanistinen',
'hy-mltdk-dekanaatti': 'matemaattis-luonnontieteellinen',
'hy-ftdk-dekanaatti': 'farmasia',
'hy-bytdk-dekanaatti': 'bio- ja ympäristötieteellinen',
'hy-ktdk-dekanaatti': 'kasvatustieteellinen',
'hy-valttdk-dekanaatti': 'valtiotieteellinen',
'hy-sskh-rehtoraatti': 'svenska',
'hy-mmtdk-dekanaatti': 'maatalous -metsätieteellinen',
'hy-eltdk-dekanaatti': 'eläinlääketieteellinen',
}
const oodikoneIams = [
'grp-oodikone-staging-users',
'grp-oodikone-basic-staging-users',
'grp-oodikone-users',
'grp-oodikone-basic-users',
]
const universityWideGroups = ['hy-ypa-opa-opintoasiainpaallikot', 'hy-rehtoraatti']
const superAdminGroups = ['grp-toska']
const adminGroups = ['grp-ospa']
const employeeGroups = ['hy-employees']
const isSuperAdminIam = iam => superAdminGroups.includes(iam)
const isAdminIam = iam => adminGroups.includes(iam)
const isUniversityWideIam = iam => universityWideGroups.includes(iam)
const isDoctoralIam = iam => doctoralIams.includes(iam)
const isEmployeeIam = iam => employeeGroups.includes(iam)
const iamToDoctoralSchool = iam => doctoralSchoolMap[iam]
const getStudyLeaderGroup = iam => kojoMap[iam]
const iamToOrganisationCode = iam => {
const organisationCodes = joryMap[iam]
if (Array.isArray(organisationCodes)) {
return organisationCodes
}
return [organisationCodes]
}
const kosuIamToFaculties = iam => {
const faculties = kosuFacultyMap[iam]
if (!Array.isArray(faculties) || !faculties.length) return []
return faculties.map(f => facultyMap[f])
}
const dekaaniIamToFaculty = iam => {
const faculty = dekaaniFacultyMap[iam]
if (!faculty) return null
return facultyMap[faculty]
}
const relevantIAMs = []
.concat(Object.keys(joryMap))
.concat(Object.keys(kojoMap))
.concat(Object.values(kojoMap))
.concat(Object.keys(kosuFacultyMap))
.concat(Object.keys(dekaaniFacultyMap))
.concat(opetusVaradekaani)
.concat(doctoralIams)
.concat(Object.keys(doctoralSchoolMap))
.concat(universityWideGroups)
.concat(superAdminGroups)
.concat(adminGroups)
.concat(employeeGroups)
.concat(oodikoneIams)
module.exports = {
isSuperAdminIam,
isAdminIam,
isUniversityWideIam,
isDoctoralIam,
isEmployeeIam,
iamToDoctoralSchool,
iamToOrganisationCode,
kosuIamToFaculties,
dekaaniIamToFaculty,
opetusVaradekaani,
getStudyLeaderGroup,
relevantIAMs,
}
+19
View File
@@ -0,0 +1,19 @@
const mapToDegreeCode = organisationCode => {
if (!organisationCode) return ''
if (organisationCode.length < 7) return ''
const doctoral = organisationCode[0] === 'T'
if (doctoral) {
return organisationCode
}
const [start, end] = organisationCode.split('-')
if (end && end.length < 3) return ''
if (start.length < 2) return ''
const masters = end[0] === 'M'
const code = `${masters ? 'M' : 'K'}H${start.substr(0, 2)}_${end.substr(-3)}`
return code
}
module.exports = {
mapToDegreeCode,
}
File diff suppressed because it is too large Load Diff
+5303 -1349
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -20,11 +20,11 @@
"lodash": "^4.17.21",
"lru-cache": "^5.1.1",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"moment-timezone": "^0.5.37",
"morgan": "^1.10.0",
"pg": "^8.7.3",
"pg": "^8.8.0",
"redis": "^3.1.2",
"sequelize": "^6.17.0",
"sequelize": "^6.21.4",
"umzug": "^2.3.0",
"unfuck-utf8-headers-middleware": "^1.0.1",
"uuid": "^3.4.0",
@@ -0,0 +1,14 @@
const { ARRAY, STRING } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.addColumn('users', 'iam_groups', {
type: ARRAY(STRING),
allowNull: false,
defaultValue: [],
})
},
down: async queryInterface => {
await queryInterface.removeColumn('users', 'iam_groups')
},
}
@@ -0,0 +1,17 @@
module.exports = {
up: queryInterface => {
return queryInterface.bulkInsert('access_groups', [
{
group_code: 'facultyStatistics',
group_info: 'grants access to faculty statistics',
},
])
},
down: queryInterface => {
return queryInterface.bulkDelete('access_groups', [
{
group_code: 'facultyStatistics',
},
])
},
}
+21 -1
View File
@@ -5,6 +5,8 @@ const { refreshAssociationsInRedis } = require('./services/studyrights')
const { getAllProgrammes } = require('./services/studyrights')
const { updateBasicView, updateStudytrackView } = require('./services/studyprogrammeUpdates')
const { findAndSaveTeachers } = require('./services/topteachers')
const { faculties } = require('./services/organisations')
const { updateFacultyOverview } = require('./services/faculty/facultyUpdates')
const { isProduction } = require('./conf-backend')
const { getCurrentSemester } = require('./services/semesters')
const logger = require('./util/logger')
@@ -16,6 +18,23 @@ const refreshStudyrightAssociations = async () => {
logger.info('Studyright associations refreshed!')
}
const refreshFaculties = async () => {
logger.info('Refreshing faculty overview statistics for all faculties')
const facultyList = (await faculties()).filter(f => !['Y', 'H99', 'Y01', 'H92', 'H930'].includes(f.code))
let ready = 0
for (const faculty of facultyList) {
try {
await updateFacultyOverview(faculty.code)
ready += 1
} catch (e) {
logger.error({ message: `Failed to update stats for faculty ${faculty?.code}`, meta: e })
}
logger.info(`${ready}/${facultyList.length} faculties done`)
}
logger.info('Faculty stats refreshed!')
}
const refreshNewOverviews = async () => {
logger.info('Refreshing studyprogramme and studytrack overview statistics for all programmes')
const codes = (await getAllProgrammes())
@@ -100,7 +119,7 @@ const startCron = () => {
logger.info('Cronjob for refreshing stats started: runs at 3am every day.')
// refresh 3am every day
schedule('0 3 * * *', async () => {
for (const func of [refreshStatistics, refreshTrends, refreshNewOverviews]) {
for (const func of [refreshStatistics, refreshTrends, refreshNewOverviews, refreshFaculties]) {
await func()
}
})
@@ -111,4 +130,5 @@ module.exports = {
startCron,
refreshStatistics,
refreshTrends,
refreshFaculties,
}
@@ -32,6 +32,7 @@ const accessLogger = morgan((tokens, req, res) => {
..._.omit(meta, ['studentsUserCanAccess']),
// pass this as a custom field so we can filter by it in graylog
isUsageStats: true,
onlyIamRights: !user.isAdmin && user.roles.length === 0 && user.rights.length === 0,
})
})
@@ -3,10 +3,13 @@ const { ApplicationError } = require('../util/customErrors')
const { getUser, getMockedUser } = require('../services/userService')
const { requiredGroup } = require('../conf-backend')
const _ = require('lodash')
const { relevantIAMs } = require('../../config/IAMConfig')
const { getOrganizationAccess } = require('../util/organizationAccess')
const parseIamGroups = iamGroups => iamGroups?.split(';').filter(Boolean) ?? []
const hasRequiredIamGroup = iamGroups => _.intersection(iamGroups, requiredGroup).length > 0
const hasRequiredIamGroup = (iamGroups, iamRights) =>
_.intersection(iamGroups, requiredGroup).length > 0 || iamRights.length > 0
const currentUserMiddleware = async (req, _res, next) => {
const {
@@ -24,9 +27,11 @@ const currentUserMiddleware = async (req, _res, next) => {
throw new ApplicationError('Not enough data in request headers', 403, { logoutUrl })
}
const iamGroups = parseIamGroups(hygroupcn)
const iamGroups = parseIamGroups(hygroupcn).filter(iam => relevantIAMs.includes(iam))
if (!hasRequiredIamGroup(iamGroups)) {
const iamRights = Object.keys(await getOrganizationAccess({ iamGroups }))
if (!hasRequiredIamGroup(iamGroups, iamRights)) {
throw new ApplicationError('User does not have required iam group', 403, { logoutUrl })
}
@@ -76,6 +76,11 @@ const User = sequelizeUser.define(
email: { type: Sequelize.STRING },
language: { type: Sequelize.STRING },
sisu_person_id: { type: Sequelize.STRING },
iam_groups: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: false,
defaultValue: [],
},
last_login: { type: Sequelize.DATE },
},
{
+1 -1
View File
@@ -57,7 +57,7 @@ module.exports = (app, url) => {
app.use(url, tags)
app.use(url, programmeModules)
app.use(url, studyProgramme)
app.use(`${url}/faculties`, faculties)
app.use(`${url}/faculties`, auth.roles(['facultyStatistics']), faculties)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
app.use(`${url}/users`, auth.roles(['admin']), users)
+2 -3
View File
@@ -28,10 +28,9 @@ router.get('/v3/courseyearlystats', async (req, res) => {
return res.status(403).json({ error: 'No programmes so no access to course stats' })
}
const { codes, separate: sep, unifyOpenUniCourses: unify } = req.query
const { codes, separate: sep } = req.query
const separate = !sep ? false : JSON.parse(sep)
const unifyOpenUniCourses = !unify ? false : JSON.parse(unify)
if (!codes) {
res.status(422).send('Missing required query parameters')
} else {
@@ -39,7 +38,7 @@ router.get('/v3/courseyearlystats', async (req, res) => {
// and users with rights to any specific study programmes
const anonymize = allowedRoles !== 'admin' && rights.length < 1
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await Course.courseYearlyStats(codes, separate, unifyOpenUniCourses, anonymizationSalt)
const results = await Course.courseYearlyStats(codes, separate, anonymizationSalt)
res.json(results)
}
})
+135 -1
View File
@@ -1,9 +1,143 @@
const router = require('express').Router()
const { faculties } = require('../services/organisations')
const { combineFacultyBasics } = require('../services/faculty/facultyBasics')
const { combineFacultyCredits } = require('../services/faculty/facultyCredits')
const { findFacultyProgrammeCodes } = require('../services/faculty/faculty')
const { combineFacultyThesisWriters } = require('../services/faculty/facultyThesisWriters')
const { countGraduationTimes } = require('../services/faculty/facultyGraduationTimes')
const { updateFacultyOverview } = require('../services/faculty/facultyUpdates')
const {
getFacultyProgrammes,
setFacultyProgrammes,
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getThesisWritersStats,
setThesisWritersStats,
} = require('../services/faculty/facultyService')
const logger = require('../util/logger')
// Faculty uses a lot of tools designed for Study programme.
// Some of them have been copied here and slightly edited for faculty purpose.
const getProgrammes = async (code, programmeFilter) => {
const programmes = await getFacultyProgrammes(code, programmeFilter)
if (programmes) return programmes
let updatedProgrammes = await findFacultyProgrammeCodes(code, programmeFilter)
if (updatedProgrammes) updatedProgrammes = await setFacultyProgrammes(code, updatedProgrammes, programmeFilter)
return updatedProgrammes
}
router.get('/', async (req, res) => {
const facultyList = await faculties()
const ignore = ['Y', 'H99', 'Y01', 'H92', 'H930']
const facultyList = (await faculties()).filter(f => !ignore.includes(f.code))
res.json(facultyList)
})
router.get('/:id/basicstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
const programmeFilter = req.query?.programme_filter
const specialGroups = req.query?.special_groups
if (!code) return res.status(422).end()
const data = await getBasicStats(code, yearType, programmeFilter, specialGroups)
if (data) return res.json(data)
const wantedProgrammes = await getProgrammes(code, programmeFilter)
if (!wantedProgrammes) return res.status(422).end()
//all programmes are required for correct sorting of transfers
let allProgrammeCodes = []
if (programmeFilter === 'NEW_STUDY_PROGRAMMES') {
const allProgs = await getProgrammes(code, 'ALL_PROGRAMMES')
allProgs?.data.forEach(prog => allProgrammeCodes.push(prog.code))
} else {
wantedProgrammes?.data.forEach(prog => allProgrammeCodes.push(prog.code))
}
let updatedStats = await combineFacultyBasics(
code,
wantedProgrammes.data,
yearType,
allProgrammeCodes,
programmeFilter,
specialGroups
)
if (updatedStats) {
updatedStats = await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
router.get('/:id/creditstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
const programmeFilter = req.query?.programme_filter
if (!code) return res.status(422).end()
const data = await getCreditStats(code, yearType, programmeFilter, specialGroups)
if (data) return res.json(data)
const programmes = await getProgrammes(code, programmeFilter)
if (!programmes) return res.status(422).end()
let updatedStats = await combineFacultyCredits(code, programmes.data, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setCreditStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
router.get('/:id/thesisstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
const programmeFilter = req.query?.programme_filter
if (!code) return res.status(422).end()
const data = await getThesisWritersStats(code, yearType, programmeFilter, specialGroups)
if (data) return res.json(data)
const programmes = await getProgrammes(code, programmeFilter)
if (!programmes) return res.status(422).end()
let updateStats = await combineFacultyThesisWriters(code, programmes.data, yearType, specialGroups)
if (updateStats) {
updateStats = await setThesisWritersStats(updateStats, yearType, programmeFilter, specialGroups)
}
return res.json(updateStats)
})
router.get('/:id/graduationtimes', async (req, res) => {
const code = req.params.id
const programmeFilter = req.query?.programme_filter
if (!code) return res.status(422).end()
// const programmes = await getProgrammes(code, programmeFilter)
const result = await countGraduationTimes(code, programmeFilter)
// currently counts all programmes
return res.json(result)
})
router.get('/:id/update_basicview', async (req, res) => {
const code = req.params.id
if (code) {
let result = null
try {
result = await updateFacultyOverview(code)
} catch (e) {
logger.error(`Failed to update faculty ${code} basic tab stats: ${e}`)
}
return res.json(result)
}
return res.status(422).end()
})
module.exports = router
+59 -11
View File
@@ -9,6 +9,7 @@ const TagService = require('../services/tags')
const CourseService = require('../services/courses')
const StatMergeService = require('../services/statMerger')
const { mapToProviders } = require('../util/utils')
const { encrypt, decrypt } = require('../services/encrypt')
const { ApplicationError } = require('../util/customErrors')
@@ -38,16 +39,26 @@ router.post('/v2/populationstatistics/courses', async (req, res) => {
req.body.months = 12
}
const encrypted = req.body.selectedStudents[0]?.encryptedData
if (req.body.years) {
const upperYearBound = new Date().getFullYear() + 1
const multicoursestatPromises = Promise.all(
req.body.years.map(year => {
if (req.body.selectedStudentsByYear) {
req.body.selectedStudents = req.body.selectedStudentsByYear[year]
req.body.selectedStudents = encrypted
? req.body.selectedStudents
.filter(
({ encryptedData }) =>
req.body.selectedStudentsByYear[year] &&
req.body.selectedStudentsByYear[year].includes(encryptedData)
)
.map(decrypt)
: req.body.selectedStudentsByYear[year]
}
const newMonths = (upperYearBound - Number(year)) * 12
const query = { ...req.body, year, months: newMonths }
const coursestatistics = Population.bottlenecksOf(query)
const coursestatistics = Population.bottlenecksOf(query, null, encrypted)
return coursestatistics
})
)
@@ -60,7 +71,9 @@ router.post('/v2/populationstatistics/courses', async (req, res) => {
res.json(result)
} else {
const result = await Population.bottlenecksOf(req.body)
if (encrypted) req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
const result = await Population.bottlenecksOf(req.body, null, encrypted)
if (result.error) {
Sentry.captureException(new Error(result.error))
@@ -68,6 +81,20 @@ router.post('/v2/populationstatistics/courses', async (req, res) => {
return
}
//These cause problems in front (where they were originally calculated)
// Do them here for the moment
for (const course of result.coursestatistics) {
// course.students.notParticipated = {}
course.students.didNotPass = {}
for (const student of Object.keys(course.students.all)) {
if (
!Object.keys(course.students.passed).includes(student) &&
!Object.keys(course.students.retryPassed).includes(student)
)
course.students.didNotPass[student] = true
}
}
res.json(result)
}
})
@@ -180,13 +207,13 @@ router.get('/v3/populationstatistics', async (req, res) => {
return
}
let studyRights = null
try {
studyRights = JSON.parse(studyRightsJSON)
const {
user: { rights, isAdmin },
} = req
studyRights = JSON.parse(studyRightsJSON)
const {
user: { rights, iamRights, isAdmin },
} = req
if (!isAdmin && !rights.includes(studyRights.programme)) {
try {
if (!isAdmin && !rights.includes(studyRights.programme) && !iamRights.includes(studyRights.programme)) {
res.status(403).json([])
return
}
@@ -232,6 +259,27 @@ router.get('/v3/populationstatistics', async (req, res) => {
return
}
// Obfuscate if user has only iam rights
if (!isAdmin && !rights.includes(studyRights.programme)) {
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
return {
...student,
firstnames: '',
lastname: '',
started: null,
iv,
studentNumber: encryptedData,
name: '',
email: '',
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
}
})
}
res.json(filterPersonalTags(result, user.id))
}
})
@@ -377,13 +425,13 @@ router.post('/v3/populationstatisticsbystudentnumbers', async (req, res) => {
router.get('/v3/populationstatistics/studyprogrammes', async (req, res) => {
const {
user: { rights, roles },
user: { rights, iamRights, roles },
} = req
if (roles?.includes('admin')) {
const studyrights = await StudyrightService.getAssociations()
res.json(studyrights)
} else {
const studyrights = await StudyrightService.getFilteredAssociations(rights)
const studyrights = await StudyrightService.getFilteredAssociations(rights.concat(iamRights))
res.json(studyrights)
}
})
+54 -101
View File
@@ -22,31 +22,20 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
if (code) {
let data = null
try {
data = await getBasicStats(code, yearType, specialGroups)
} catch (e) {
logger.error(`Failed to get code ${code} basic stats: ${e}`)
}
if (!data) {
try {
const result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setBasicStats(result, yearType, specialGroups)
} catch (e) {
logger.error(`Failed to update code ${code} basic stats: ${e}`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
if (!code) return res.status(422).end()
const data = await getBasicStats(code, yearType, specialGroups)
if (data) return res.json(data)
const updated = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
if (updated) await setBasicStats(updated, yearType, specialGroups)
return res.json(updated)
})
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
@@ -54,32 +43,19 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
if (code) {
let data = null
try {
data = await getCreditStats(code, yearType, specialGroups)
data = null
} catch (e) {
logger.error(`Failed to get code ${code} credit stats`)
}
if (!data) {
try {
const result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setCreditStats(result, yearType, specialGroups)
} catch (e) {
logger.error(`Failed to update code ${code} credit stats`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
if (!code) return res.status(422).end()
const data = await getCreditStats(code, yearType, specialGroups)
if (data) return res.json(data)
const updatedStats = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
if (updatedStats) await setCreditStats(updatedStats, yearType, specialGroups)
return res.json(updatedStats)
})
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
@@ -87,31 +63,19 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
if (code) {
let data = null
try {
data = await getGraduationStats(code, yearType, specialGroups)
} catch (e) {
logger.error(`Failed to get code ${code} graduation stats`)
}
if (!data) {
try {
const result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setGraduationStats(result, yearType, specialGroups)
} catch (e) {
logger.error(`Failed to update code ${code} graduation stats`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
if (!code) return res.status(422).end()
const data = await getGraduationStats(code, yearType, specialGroups)
if (data) return res.json(data)
const updatedStats = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
if (updatedStats) await setGraduationStats(updatedStats, yearType, specialGroups)
return res.json(updatedStats)
})
router.get('/v2/studyprogrammes/:id/coursestats', async (req, res) => {
@@ -133,31 +97,20 @@ router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
const graduated = req.query?.graduated
const specialGroups = req.query?.special_groups
if (code) {
let data = null
try {
data = await getStudytrackStats(code, graduated, specialGroups)
} catch (e) {
logger.error(`Failed to get code ${code} studytrack stats: ${e}`)
}
if (!data) {
try {
const result = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setStudytrackStats(result, graduated, specialGroups)
} catch (e) {
logger.error(`Failed to update code ${code} studytrack stats: ${e}`)
}
}
return res.json(data)
} else {
res.status(422).end()
}
if (!code) return res.status(422).end()
const data = await getStudytrackStats(code, graduated, specialGroups)
if (data) return res.json(data)
const updated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
})
if (updated) await setStudytrackStats(updated, graduated, specialGroups)
return res.json(updated)
})
router.get('/v2/studyprogrammes/:id/update_basicview', async (req, res) => {
+2 -1
View File
@@ -449,7 +449,8 @@ const codeLikeTerm = code =>
? undefined
: {
code: {
[Op.iLike]: `%${code.trim()}%`,
// Starts with code or has AY/A in front of the code
[Op.iRegexp]: `^(AY|ay|A|a)?${code}`,
},
}
+28
View File
@@ -0,0 +1,28 @@
const { randomBytes, createCipheriv, createDecipheriv } = require('crypto')
const algorithm = 'aes-256-cbc'
const key = randomBytes(32)
const iv = randomBytes(16)
const encrypt = text => {
const cipher = createCipheriv(algorithm, Buffer.from(key), iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return {
iv: iv.toString('hex'),
encryptedData: encrypted.toString('hex'),
}
}
const decrypt = text => {
const iv = Buffer.from(text.iv, 'hex')
const encryptedText = Buffer.from(text.encryptedData, 'hex')
const decipher = createDecipheriv(algorithm, Buffer.from(key), iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
return decrypted.toString()
}
module.exports = { encrypt, decrypt }
@@ -0,0 +1,308 @@
const Sequelize = require('sequelize')
const { Op } = Sequelize
const {
ElementDetail,
Organization,
ProgrammeModule,
Semester,
SemesterEnrollment,
Studyright,
StudyrightElement,
Student,
Transfer,
Credit,
Course,
} = require('../../models')
const {
facultyFormatStudyright,
facultyFormatProgramme,
formatFacultyTransfer,
formatFacultyThesisWriter,
formatOrganization,
formatAbsence,
isNewProgramme,
} = require('./facultyHelpers')
const getTransferredToAndAway = async (programmeCodes, allProgrammeCodes, since) => {
const awayTransfers = (await transferredAway(programmeCodes, allProgrammeCodes, since)).map(t => t.studyrightid)
const toTransfers = (await transferredTo(programmeCodes, allProgrammeCodes, since)).map(t => t.studyrightid)
const transferredToOrAway = [...toTransfers, ...awayTransfers]
return transferredToOrAway
}
const getTransferredInside = async (programmeCodes, allProgrammeCodes, since) => {
const transfersInside = (await transferredInsideFaculty(programmeCodes, allProgrammeCodes, since)).map(
t => t.studyrightid
)
return transfersInside
}
const startedStudyrights = async (faculty, since, studyRightWhere) =>
(
await Studyright.findAll({
include: [
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
},
},
],
where: {
faculty_code: faculty,
studystartdate: {
[Op.gte]: since,
},
student_studentnumber: { [Op.not]: null },
...studyRightWhere,
},
})
).map(facultyFormatStudyright)
const graduatedStudyrights = async (faculty, since, studyrightWhere) =>
(
await Studyright.findAll({
include: [
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
},
},
],
where: {
faculty_code: faculty,
enddate: {
[Op.gte]: since,
},
graduated: 1,
student_studentnumber: { [Op.not]: null },
...studyrightWhere,
},
})
).map(facultyFormatStudyright)
const bachelorStudyright = async id => {
return await Studyright.findOne({
attributes: ['studystartdate'],
where: {
studyrightid: id,
extentcode: 1,
},
})
}
const transferredInsideFaculty = async (programmes, allProgrammeCodes, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: allProgrammeCodes,
targetcode: programmes,
},
})
).map(formatFacultyTransfer)
const transferredAway = async (programmes, allProgrammeCodes, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: programmes,
targetcode: {
[Op.notIn]: allProgrammeCodes,
},
},
})
).map(formatFacultyTransfer)
const transferredTo = async (programmes, allProgrammeCodes, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: {
[Op.notIn]: allProgrammeCodes,
},
targetcode: programmes,
},
})
).map(formatFacultyTransfer)
const degreeProgrammesOfFaculty = async facultyCode =>
(
await ProgrammeModule.findAll({
attributes: ['code', 'name'],
include: {
model: Organization,
where: {
code: {
[Op.startsWith]: facultyCode,
},
},
},
})
).map(facultyFormatProgramme)
const facultyOgranizationId = async faculty => {
return await Organization.findOne({
attributes: ['id'],
where: {
code: faculty,
},
})
}
const getChildOrganizations = async facultyId =>
(
await Organization.findAll({
attributes: ['id', 'name', 'code', 'parent_id'],
where: {
parent_id: facultyId,
},
})
).map(formatOrganization)
const thesisWriters = async (provider, since, thesisTypes, students) =>
(
await Credit.findAll({
attributes: ['id', 'course_code', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['course_unit_type'],
required: true,
where: {
course_unit_type: {
[Op.in]: thesisTypes,
},
},
include: {
model: Organization,
attributes: [],
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: 4,
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
student_studentnumber: students.length > 0 ? students : { [Op.not]: null },
},
})
).map(formatFacultyThesisWriter)
const statutoryAbsences = async (studentnumber, startdate, enddate) =>
(
await SemesterEnrollment.findAll({
attributes: ['semestercode'],
include: {
model: Semester,
attributes: ['startdate', 'enddate'],
where: {
startdate: {
[Op.gte]: startdate,
},
enddate: {
[Op.lte]: enddate,
},
},
},
where: {
studentnumber: studentnumber,
statutory_absence: true,
},
})
).map(formatAbsence)
// Some programme modules are not directly associated to a faculty (organization).
// Some have intermediate organizations, such as department, so the connection must be digged up
const findFacultyProgrammeCodes = async (faculty, programmeFilter) => {
let allProgrammes = []
let allProgrammeCodes = []
const directAssociationProgrammes = await degreeProgrammesOfFaculty(faculty)
allProgrammes = allProgrammes.concat(directAssociationProgrammes)
allProgrammeCodes = allProgrammeCodes.concat(directAssociationProgrammes.map(prog => prog.code))
// find faculty's organization id and its direct child organizations
const { id } = await facultyOgranizationId(faculty)
const facultyChildOrganizations = await getChildOrganizations(id)
// get programme modules that have a faculty child as organization(_id)
for (const org of facultyChildOrganizations) {
const childAssociationProgrammes = await degreeProgrammesOfFaculty(org.code)
if (childAssociationProgrammes.length > 0) {
childAssociationProgrammes.forEach(prog => {
if (!(prog.code in allProgrammeCodes)) {
allProgrammes = allProgrammes.concat([prog])
allProgrammeCodes = allProgrammeCodes.concat([prog.code])
}
})
} else {
// dig deeper
const grandChildren = await getChildOrganizations(org.id)
if (grandChildren.length > 0) {
for (const gcOrg of grandChildren) {
const associatedProgrammes = await degreeProgrammesOfFaculty(gcOrg.code)
if (associatedProgrammes.length > 0) {
associatedProgrammes.forEach(prog => {
if (!(prog.code in allProgrammeCodes)) {
allProgrammes = allProgrammes.concat([prog])
allProgrammeCodes = allProgrammeCodes.concat([prog.code])
}
})
}
}
}
}
}
if (programmeFilter === 'NEW_STUDY_PROGRAMMES') {
allProgrammes = allProgrammes.filter(prog => isNewProgramme(prog.code))
}
return allProgrammes
}
module.exports = {
startedStudyrights,
graduatedStudyrights,
bachelorStudyright,
transferredInsideFaculty,
transferredAway,
transferredTo,
degreeProgrammesOfFaculty,
thesisWriters,
findFacultyProgrammeCodes,
facultyOgranizationId,
statutoryAbsences,
getTransferredToAndAway,
getTransferredInside,
}
@@ -0,0 +1,432 @@
const { indexOf } = require('lodash')
const {
startedStudyrights,
graduatedStudyrights,
transferredInsideFaculty,
transferredAway,
transferredTo,
getTransferredToAndAway,
getTransferredInside,
} = require('./faculty')
const { getStatsBasis, getYearsArray, defineYear } = require('../studyprogrammeHelpers')
const { findRightProgramme, isNewProgramme, checkTransfers, getExtentFilter } = require('./facultyHelpers')
const filterDuplicateStudyrights = studyrights => {
// bachelor+master students have two studyrights (separated by two last digits in studyrightid)
// choose only the earlier started one, so we don't count start of masters as starting in faculty
let rightsToCount = {}
studyrights.forEach(right => {
const id = right.studyrightid.slice(0, -2)
const start = new Date(right.studystartdate)
if (id in rightsToCount) {
if (new Date(rightsToCount[id].studystartdate) > start) {
rightsToCount[id] = right
}
} else {
rightsToCount[id] = right
}
})
return Object.values(rightsToCount)
}
const addProgramme = async (
stats,
programme,
tableStats,
transferYear,
index,
allBasics,
facultyProgrammes,
includeAllSpecials
) => {
if (!(programme in stats)) {
stats[programme] = {}
if (includeAllSpecials) {
Object.keys(tableStats).forEach(year => (stats[programme][year] = [0, 0, 0]))
} else {
Object.keys(tableStats).forEach(year => (stats[programme][year] = [0]))
}
}
stats[programme][transferYear][index] += 1
if (!(programme in allBasics.programmeNames)) {
const name = facultyProgrammes.filter(prog => prog.code === programme)[0]?.name
allBasics.programmeNames[programme] = name
}
}
const getFacultyStarters = async (
faculty,
transfersToOrAwayStudyrights,
insideTransfersStudyrights,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
counts,
allBasics,
programmeData,
programmeFilter,
includeAllSpecials
) => {
const startedGraphStats = [...graphStats]
const startedTableStats = { ...tableStats }
const studyrightWhere = getExtentFilter(includeAllSpecials)
const studyrights = await startedStudyrights(faculty, since, studyrightWhere)
let filteredStudyrights = filterDuplicateStudyrights(studyrights)
const programmeTableStats = {}
if (!includeAllSpecials) {
filteredStudyrights = filteredStudyrights.filter(
s => !checkTransfers(s, insideTransfersStudyrights, transfersToOrAwayStudyrights)
)
}
filteredStudyrights.forEach(({ studystartdate, studyrightElements }) => {
const { programme, programmeName } = findRightProgramme(studyrightElements, 'started')
if (programmeFilter === 'ALL_PROGRAMMES' || isNewProgramme(programme)) {
const startYear = defineYear(studystartdate, isAcademicYear)
startedGraphStats[indexOf(yearsArray, startYear)] += 1
startedTableStats[startYear] += 1
if (!(programme in programmeTableStats)) {
programmeTableStats[programme] = { ...tableStats }
}
programmeTableStats[programme][startYear] += 1
if (!(programme in allBasics.programmeNames)) {
allBasics.programmeNames[programme] = programmeName
}
}
})
allBasics.studentInfo.graphStats.push({ name: 'Started studying', data: startedGraphStats })
programmeData['started'] = programmeTableStats
Object.keys(startedTableStats).forEach(year => {
counts[year] = [startedTableStats[year]]
})
}
const getFacultyGraduates = async (
faculty,
transfersToOrAwayStudyrights,
insideTransfersStudyrights,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
allBasics,
counts,
countsGraduations,
programmeData,
programmeFilter,
includeAllSpecials
) => {
const graduatedGraphStats = [[...graphStats], [...graphStats], [...graphStats], [...graphStats], [...graphStats]]
const graduatedTableStats = {}
let studyrightWhere = getExtentFilter(includeAllSpecials)
let graduatedRights = await graduatedStudyrights(faculty, since, studyrightWhere)
const programmeTableStats = {}
Object.keys(tableStats).forEach(year => (graduatedTableStats[year] = [0, 0, 0, 0, 0]))
if (!includeAllSpecials) {
graduatedRights = graduatedRights.filter(
s => !checkTransfers(s, insideTransfersStudyrights, transfersToOrAwayStudyrights)
)
}
graduatedRights.forEach(({ enddate, extentcode, studyrightElements }) => {
const { programme, programmeName } = findRightProgramme(studyrightElements, 'graduated')
if (programmeFilter === 'ALL_PROGRAMMES' || isNewProgramme(programme)) {
const endYear = defineYear(enddate, isAcademicYear)
graduatedGraphStats[0][indexOf(yearsArray, endYear)] += 1
graduatedTableStats[endYear][0] += 1
if (!(programme in programmeTableStats)) {
programmeTableStats[programme] = {}
Object.keys(tableStats).forEach(year => (programmeTableStats[programme][year] = [0, 0, 0, 0, 0]))
}
programmeTableStats[programme][endYear][0] += 1
if (extentcode === 1) {
graduatedGraphStats[1][indexOf(yearsArray, endYear)] += 1
graduatedTableStats[endYear][1] += 1
programmeTableStats[programme][endYear][1] += 1
} else if (extentcode === 2) {
graduatedGraphStats[2][indexOf(yearsArray, endYear)] += 1
graduatedTableStats[endYear][2] += 1
programmeTableStats[programme][endYear][2] += 1
} else if (extentcode === 4) {
graduatedGraphStats[3][indexOf(yearsArray, endYear)] += 1
graduatedTableStats[endYear][3] += 1
programmeTableStats[programme][endYear][3] += 1
} else {
graduatedGraphStats[4][indexOf(yearsArray, endYear)] += 1
graduatedTableStats[endYear][4] += 1
programmeTableStats[programme][endYear][4] += 1
}
if (!(programme in allBasics.programmeNames)) {
allBasics.programmeNames[programme] = programmeName
}
}
})
allBasics.studentInfo.graphStats.push({ name: 'Graduated', data: graduatedGraphStats[0] })
allBasics.graduationInfo.graphStats.push({ name: 'Bachelors', data: graduatedGraphStats[1] })
allBasics.graduationInfo.graphStats.push({ name: 'Masters', data: graduatedGraphStats[2] })
allBasics.graduationInfo.graphStats.push({ name: 'Doctors', data: graduatedGraphStats[3] })
allBasics.graduationInfo.graphStats.push({ name: 'Others', data: graduatedGraphStats[4] })
programmeData['graduated'] = programmeTableStats
Object.keys(graduatedTableStats).forEach(year => {
counts[year] = counts[year].concat(graduatedTableStats[year][0])
countsGraduations[year] = graduatedTableStats[year]
})
}
const getFacultyTransfers = async (
programmes,
programmeCodes,
allProgrammeCodes,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
allBasics,
counts,
programmeData,
includeAllSpecials
) => {
const insideTransfers = await transferredInsideFaculty(programmeCodes, allProgrammeCodes, since)
// Filter still away those that have started studying before 1.8.2017
let transferGraphStats = []
let transferTableStats = {}
if (includeAllSpecials) {
transferGraphStats = [[...graphStats], [...graphStats], [...graphStats]]
Object.keys(tableStats).forEach(year => (transferTableStats[year] = [0, 0, 0]))
} else {
transferGraphStats = [[...graphStats]]
Object.keys(tableStats).forEach(year => (transferTableStats[year] = [0]))
}
const programmeTableStats = {}
// inside transfer is counted towards target
for (const { transferdate, targetcode } of insideTransfers) {
const transferYear = defineYear(transferdate, isAcademicYear)
transferGraphStats[0][indexOf(yearsArray, transferYear)] += 1
transferTableStats[transferYear][0] += 1
await addProgramme(
programmeTableStats,
targetcode,
tableStats,
transferYear,
0,
allBasics,
programmes,
includeAllSpecials
)
}
if (includeAllSpecials) {
const awayTransfers = await transferredAway(programmeCodes, allProgrammeCodes, since)
const toTransfers = await transferredTo(programmeCodes, allProgrammeCodes, since)
for (const { transferdate, sourcecode } of awayTransfers) {
const transferYear = defineYear(transferdate, isAcademicYear)
transferGraphStats[1][indexOf(yearsArray, transferYear)] += 1
transferTableStats[transferYear][1] += 1
await addProgramme(
programmeTableStats,
sourcecode,
tableStats,
transferYear,
1,
allBasics,
programmes,
includeAllSpecials
)
}
for (const { transferdate, targetcode } of toTransfers) {
const transferYear = defineYear(transferdate, isAcademicYear)
transferGraphStats[2][indexOf(yearsArray, transferYear)] += 1
transferTableStats[transferYear][2] += 1
await addProgramme(
programmeTableStats,
targetcode,
tableStats,
transferYear,
2,
allBasics,
programmes,
includeAllSpecials
)
}
}
allBasics.studentInfo.graphStats.push({ name: 'Transferred inside', data: transferGraphStats[0] })
if (includeAllSpecials) {
allBasics.studentInfo.graphStats.push({ name: 'Transferred away', data: transferGraphStats[1] })
allBasics.studentInfo.graphStats.push({ name: 'Transferred to', data: transferGraphStats[2] })
}
programmeData['transferred'] = programmeTableStats
Object.keys(transferTableStats).forEach(year => {
counts[year] = counts[year].concat(transferTableStats[year])
})
}
const combineFacultyBasics = async (faculty, programmes, yearType, allProgrammeCodes, programmeFilter, special) => {
let counts = {}
let countsGraduations = {}
let years = []
let programmeData = {}
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = special === 'SPECIAL_INCLUDED'
const since = isAcademicYear ? new Date('2017-08-01') : new Date('2017-01-01')
const yearsArray = getYearsArray(since.getFullYear(), isAcademicYear)
const { graphStats, tableStats } = getStatsBasis(yearsArray)
Object.keys(tableStats).forEach(year => years.push(year))
const wantedProgrammeCodes = programmes.map(prog => prog.code)
let allBasics = {
id: faculty,
years: [],
programmeNames: {},
studentInfo: {
tableStats: [],
graphStats: [],
titles: includeAllSpecials
? ['', 'Started studying', 'Graduated', 'Transferred inside', 'Transferred away', 'Transferred to']
: ['', 'Started studying', 'Graduated', 'Transferred inside'],
programmeTableStats: {},
},
graduationInfo: {
tableStats: [],
graphStats: [],
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
programmeTableStats: {},
},
}
const transfersToAwayStudyrights = await getTransferredToAndAway(wantedProgrammeCodes, allProgrammeCodes, since)
const transferInsideStudyrights = await getTransferredInside(wantedProgrammeCodes, allProgrammeCodes, since)
// Started studying in faculty
await getFacultyStarters(
faculty,
transfersToAwayStudyrights,
transferInsideStudyrights,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
counts,
allBasics,
programmeData,
programmeFilter,
includeAllSpecials
)
// Graduated in faculty
await getFacultyGraduates(
faculty,
transfersToAwayStudyrights,
transferInsideStudyrights,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
allBasics,
counts,
countsGraduations,
programmeData,
programmeFilter,
includeAllSpecials
)
// Transfers
await getFacultyTransfers(
programmes,
wantedProgrammeCodes,
allProgrammeCodes,
since,
isAcademicYear,
yearsArray,
graphStats,
tableStats,
allBasics,
counts,
programmeData,
includeAllSpecials
)
// combine tableStats from all categories
allBasics.years = years
years.forEach(year => {
allBasics.studentInfo.tableStats.push([year, ...counts[year]])
allBasics.graduationInfo.tableStats.push([year, ...countsGraduations[year]])
})
allBasics.studentInfo.tableStats.reverse()
allBasics.graduationInfo.tableStats.reverse()
//combine programme level tablestats
// all programmes are not present in all data types, check all found programmes and patch missing details with 0
let allCodes = Object.keys(programmeData.started).concat(
Object.keys(programmeData.graduated),
Object.keys(programmeData.transferred)
)
allCodes = [...new Set(allCodes)]
let studentInfo = {}
let graduationInfo = {}
allCodes.forEach(code => {
if (!(code in studentInfo)) studentInfo[code] = {}
for (const year of years) {
// started
if (code in programmeData.started) {
studentInfo[code][year] = [year, programmeData.started[code][year]]
} else {
studentInfo[code][year] = [year, 0]
}
// graduated total
if (code in programmeData.graduated) {
studentInfo[code][year] = studentInfo[code][year].concat([programmeData.graduated[code][year][0]])
if (!(code in graduationInfo)) graduationInfo[code] = {}
graduationInfo[code][year] = [year, ...programmeData.graduated[code][year]]
} else {
studentInfo[code][year] = studentInfo[code][year].concat([0])
}
// trasferred
if (code in programmeData.transferred) {
studentInfo[code][year] = studentInfo[code][year].concat(programmeData.transferred[code][year])
} else {
studentInfo[code][year] = studentInfo[code][year].concat([0, 0, 0])
}
}
})
Object.keys(studentInfo).forEach(prog => {
allBasics.studentInfo.programmeTableStats[prog] = [...Object.values(studentInfo[prog])].reverse()
})
Object.keys(graduationInfo).forEach(prog => {
allBasics.graduationInfo.programmeTableStats[prog] = Object.values(graduationInfo[prog]).reverse()
})
return allBasics
}
module.exports = { combineFacultyBasics }
@@ -0,0 +1,275 @@
const { indexOf } = require('lodash')
const { mapToProviders } = require('../../util/utils')
const {
getStatsBasis,
defineYear,
isMajorStudentCredit,
isNonMajorCredit,
getYearsArray,
getStartDate,
isSpecialGroupCredit,
} = require('../studyprogrammeHelpers')
const {
getStudyRights,
getCreditsForStudyProgramme,
allTransfers,
getCourseCodesForStudyProgramme,
} = require('../studyprogramme')
const isFacultyNonMajorCredit = (studyrights, attainment_date, facultyProgrammes) => {
let right = ''
studyrights.forEach(studyright => {
if (studyright) {
if (!studyright.graduated && new Date(attainment_date) >= new Date(studyright.studystartdate)) {
if (!right) right = studyright.code
else if (right && !facultyProgrammes.includes(right) && facultyProgrammes.includes(studyright.code))
right = studyright.code
} else {
if (
new Date(attainment_date) >= new Date(studyright.studystartdate) &&
new Date(attainment_date) <= 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)
}
// Fetches all the credits for the studyprogramme and divides them into major-students, non-major faculty students,
// non-major other faculty students adn 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,
includeAllSpecials,
facultyProgrammes,
}) => {
const providercode = mapToProviders([studyprogramme])[0]
const courses = await getCourseCodesForStudyProgramme(providercode)
const credits = await getCreditsForStudyProgramme(courses, since)
const students = [...new Set(credits.map(({ student_studentnumber }) => student_studentnumber))]
let studyrights = await getStudyRights(students)
const transfers = (await allTransfers(studyprogramme, since)).map(t => t.studyrightid).filter(s => s !== 'undefined')
if (!includeAllSpecials) {
studyrights = studyrights.filter(s => !transfers.includes(s.studyrightid))
}
let majors = getStatsBasis(years)
let allNonMajors = getStatsBasis(years)
let facultyNonMajors = getStatsBasis(years)
let otherNonMajors = getStatsBasis(years)
let nonDegree = getStatsBasis(years)
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
const studentStudyrights = studyrights.filter(studyright => studyright.studentnumber === student_studentnumber)
const attainmentYear = defineYear(attainment_date, isAcademicYear)
if (!includeAllSpecials && isSpecialGroupCredit(studentStudyrights, attainment_date, transfers)) {
return
}
if (isMajorStudentCredit(studentStudyrights, attainment_date, studyprogramme)) {
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
majors.tableStats[attainmentYear] += credits || 0
} else if (isNonMajorCredit(studentStudyrights, attainment_date)) {
// for comparison
allNonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
allNonMajors.tableStats[attainmentYear] += credits || 0
if (isFacultyNonMajorCredit(studentStudyrights, attainment_date, facultyProgrammes)) {
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 {
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: {} },
}
}
return { majors, facultyNonMajors, otherNonMajors, nonDegree }
}
// 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, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials, facultyProgrammes }
const { majors, facultyNonMajors, otherNonMajors, 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 =>
includeAllSpecials
? [
year,
majors.tableStats[year] +
facultyNonMajors.tableStats[year] +
otherNonMajors.tableStats[year] +
nonDegree.tableStats[year],
majors.tableStats[year],
facultyNonMajors.tableStats[year],
otherNonMajors.tableStats[year],
nonDegree.tableStats[year],
]
: [year, majors.tableStats[year], majors.tableStats[year]]
)
const graphStats = includeAllSpecials
? [
{
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,
},
]
: [
{
name: 'Major students credits',
data: majors.graphStats,
},
]
return {
id: studyprogramme,
years,
tableStats,
graphStats,
}
}
const getProgrammeCredits = async (code, yearType, specialGroups, facultyProgrammes) => {
const updatedStats = await getFacultyCreditStatsForStudytrack({
studyprogramme: code,
facultyProgrammes,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
return updatedStats
}
const combineFacultyCredits = async (faculty, programmes, yearType, specialGroups) => {
let counts = {}
let years = []
const titles =
specialGroups === 'SPECIAL_INCLUDED'
? [
'',
'Total',
'Major students credits',
'Non-major faculty students credits',
'Non-major other faculty students credits',
'Non-degree student credits',
]
: ['', 'Total', 'Major students credits']
let allCredits = {
id: faculty,
years: [],
tableStats: [],
graphStats: [],
programmeTableStats: {},
programmeNames: {},
titles,
}
const progCodes = programmes.map(p => p.code)
for (const prog of programmes) {
const data = await getProgrammeCredits(prog.code, yearType, specialGroups, progCodes)
if (data) {
allCredits.programmeNames[prog.code] = prog.name
allCredits.programmeTableStats[prog.code] = 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 nonDegree = []
years.forEach(year => {
majors.push(counts[year][1])
facultyNonMajor.push(counts[year][2])
otherNonMajor.push(counts[year][3])
nonDegree.push(counts[year][4])
})
allCredits.graphStats =
specialGroups === 'SPECIAL_INCLUDED'
? [
{ 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: 'Major students credits', data: majors }]
return allCredits
}
module.exports = { combineFacultyCredits, getFacultyCreditStatsForStudytrack }
@@ -0,0 +1,108 @@
const moment = require('moment')
const { graduatedStudyrights, bachelorStudyright, statutoryAbsences } = require('./faculty')
const { findRightProgramme, isNewProgramme } = require('./facultyHelpers')
const { getYearsArray, getYearsObject, getMean, getMedian, defineYear } = require('../studyprogrammeHelpers')
const findBachelorStartdate = async id => {
const studyright = await bachelorStudyright(id)
if (studyright) return studyright.studystartdate
return null
}
const getStatutoryAbsences = async (studentnumber, startdate, enddate) => {
const absences = await statutoryAbsences(studentnumber, startdate, enddate)
if (absences.length) {
const absentMonths = absences.reduce((sum, ab) => sum + moment(ab.end).diff(moment(ab.start), 'months'), 0)
return absentMonths
}
return 0
}
const countGraduationTimes = async (faculty, programmeFilter) => {
const isAcademicYear = false // = yearType === 'ACADEMIC_YEAR'
const since = isAcademicYear ? new Date('2017-08-01') : new Date('2017-01-01')
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const levels = ['bachelor', 'bcMsCombo', 'master', 'doctor', 'licentiate']
let graduationAmounts = {}
let graduationTimes = {}
let medians = {}
let means = {}
levels.forEach(level => {
graduationAmounts[level] = getYearsObject({ years })
graduationTimes[level] = getYearsObject({ years, emptyArrays: true })
medians[level] = getYearsObject({ years, emptyArrays: true })
means[level] = getYearsObject({ years, emptyArrays: true })
})
// the max amount of months in the graph, depends on programme type
// (times 2 to set goal time at the halfway point on graphs)
const comparisonValues = {
bachelor: 36 * 2,
bcMsCombo: 60 * 2,
master: 24 * 2,
doctor: 48 * 2,
// Fix differrent cases?
licentiate: 78 * 2,
}
// We count studyrights (vs. studyright_elements)
// This way we get the whole time for a degree, even if the student was transferred to a new programme
// E.g. started 8/2016 in old Bc, transferred to new 10/2020, graduated from new 1/2021 --> total 53 months (not 3)
const graduatedRights = await graduatedStudyrights(faculty, since)
for (const right of graduatedRights) {
const { enddate, startdate, studyrightid, extentcode, studyrightElements, studentnumber } = right
const { programme } = findRightProgramme(studyrightElements, 'graduated')
if (programmeFilter === 'NEW_STUDY_PROGRAMMES' && !isNewProgramme(programme)) continue
const graduationYear = defineYear(enddate, isAcademicYear)
let actualStartdate = startdate
let level = null
if (extentcode == 1) {
level = 'bachelor'
} else if (extentcode === 2) {
if (studyrightid.slice(-2) === '-2') {
level = 'bcMsCombo'
actualStartdate = await findBachelorStartdate(studyrightid.replace(/-2$/, '-1'))
} else {
level = 'master'
}
} else if (extentcode === 4) {
level = 'doctor'
} else if (extentcode === 3) {
level = 'licentiate'
} else {
continue
}
const absoluteTimeToGraduation = moment(enddate).diff(moment(actualStartdate), 'months')
const statutoryAbsences = await getStatutoryAbsences(studentnumber, actualStartdate, enddate)
const timeToGraduation = absoluteTimeToGraduation - statutoryAbsences
graduationAmounts[level][graduationYear] += 1
graduationTimes[level][graduationYear] = [...graduationTimes[level][graduationYear], timeToGraduation]
}
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
years.forEach(year => {
levels.forEach(level => {
const median = getMedian(graduationTimes[level][year])
const mean = getMean(graduationTimes[level][year])
medians[level][year] = [
['', median],
['', comparisonValues[level] - median],
]
means[level][year] = [
['', mean],
['', comparisonValues[level] - mean],
]
})
})
return { medians, means, graduationAmounts, years, levels }
}
module.exports = { countGraduationTimes }
@@ -0,0 +1,137 @@
const Sequelize = require('sequelize')
const { Op } = Sequelize
const findRightProgramme = (studyrightElements, mode) => {
let programme = ''
let programmeName = ''
let studyRightElement = null
if (studyrightElements) {
if (mode === 'started') {
studyRightElement = studyrightElements
.filter(sre => sre.element_detail.type === 20)
.sort((a, b) => new Date(a.startdate) - new Date(b.startdate))[0]
// this way round counts to old programmes, other way around to new programmes
} else {
// graduated
studyRightElement = studyrightElements
.filter(sre => sre.element_detail.type === 20)
.sort((a, b) => new Date(b.startdate) - new Date(a.startdate))[0]
}
if (studyRightElement) {
programme = studyRightElement.code
programmeName = studyRightElement.element_detail.name
}
}
return { programme, programmeName }
}
const facultyFormatStudyright = studyright => {
const {
studyrightid,
studystartdate,
enddate,
givendate,
graduated,
active,
prioritycode,
extentcode,
student,
studyright_elements,
startdate,
} = studyright
return {
studyrightid,
studystartdate,
startdate,
enddate,
givendate,
graduated,
active,
prioritycode,
extentcode,
studentnumber: student.studentnumber,
studyrightElements: studyright_elements,
}
}
const formatFacultyTransfer = transfer => {
const { sourcecode, targetcode, transferdate, studyrightid } = transfer
return {
sourcecode,
targetcode,
transferdate,
studyrightid,
}
}
const facultyFormatProgramme = programme => {
const { code, name } = programme
return { code, name }
}
const formatFacultyThesisWriter = credit => {
const { course_code, credits, attainment_date, student_studentnumber, course } = credit
return {
course_code,
credits,
attainment_date,
student_studentnumber,
courseUnitType: course.course_unit_type,
}
}
const formatOrganization = org => {
const { id, name, code, parent_id } = org
return { id, name, code, parentId: parent_id }
}
const formatAbsence = absence => {
const { semestercode, semester } = absence
return { semestercode, start: semester.startdate, end: semester.enddate }
}
const newProgrammes = [/^KH/, /^MH/, /^T/, /^LI/, /^K-/, /^FI/, /^00901$/, /^00910$/]
const isNewProgramme = code => {
for (let i = 0; i < newProgrammes.length; i++) {
if (newProgrammes[i].test(code)) {
return true
}
}
return false
}
const checkTransfers = (s, insideTransfersStudyrights, transfersToOrAwayStudyrights) => {
if (transfersToOrAwayStudyrights.includes(s.studyrightid)) return true
if (insideTransfersStudyrights.includes(s.studyrightid) && new Date(s.studystartdate) < new Date('2017-08-01'))
return true
return false
}
const getExtentFilter = includeAllSpecials => {
const filteredExtents = [16] // always filter out secondary subject students
if (!includeAllSpecials) {
filteredExtents.push(7, 34, 22, 99, 14, 13, 9)
}
let studyrightWhere = {
extentcode: {
[Op.notIn]: filteredExtents,
},
}
return studyrightWhere
}
module.exports = {
findRightProgramme,
facultyFormatStudyright,
facultyFormatProgramme,
formatFacultyTransfer,
formatFacultyThesisWriter,
formatOrganization,
formatAbsence,
isNewProgramme,
checkTransfers,
getExtentFilter,
}
@@ -0,0 +1,101 @@
const { redisClient } = require('../redis')
const moment = require('moment')
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, specialGroups) =>
`FACULTY_CREDIT_STATS_${id}_${yearType}_${programmeFilter}_${specialGroups}`
// const createRedisKeyForGraduationTimeStats = (id, mode, programmeFilter) =>
// `FACULTY_GRADUATION_TIME_STATS_${id}_${mode}__${programmeFilter}`
const createRedisKeyForThesiswriters = (id, yearType, programmeFilter, specialGroups) =>
`FACULTY_THESIS_WRITERS_STATS_${id}_${yearType}_${programmeFilter}_${specialGroups}`
const setFacultyProgrammes = async (id, data, programmeFilter) => {
const redisKey = createRedisKeyForFacultyProgrammes(id, programmeFilter)
const dataToRedis = {
data: data,
status: 'DONE',
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getFacultyProgrammes = async (id, programmeFilter) => {
const redisKey = createRedisKeyForFacultyProgrammes(id, programmeFilter)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setBasicStats = async (data, yearType, programmeFilter, specialGroups) => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id, yearType, programmeFilter, specialGroups)
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 getBasicStats = async (id, yearType, programmeFilter, specialGroups) => {
const redisKey = createRedisKeyForBasicStats(id, yearType, programmeFilter, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setCreditStats = async (data, yearType, programmeFilter, specialGroups) => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(id, yearType, programmeFilter, specialGroups)
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, specialGroups) => {
const redisKey = createRedisKeyForCreditStats(id, yearType, programmeFilter, specialGroups)
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)
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 getThesisWritersStats = async (id, yearType, programmeFilter, specialGroups) => {
const redisKey = createRedisKeyForThesiswriters(id, yearType, programmeFilter, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
module.exports = {
setFacultyProgrammes,
getFacultyProgrammes,
setBasicStats,
getBasicStats,
setCreditStats,
getCreditStats,
setThesisWritersStats,
getThesisWritersStats,
}
@@ -0,0 +1,134 @@
const { indexOf } = require('lodash')
const { mapToProviders } = require('../../util/utils')
const { thesisWriters } = require('./faculty')
const {
getStatsBasis,
getYearsArray,
defineYear,
getCorrectStudentnumbers,
alltimeEndDate,
alltimeStartDate,
} = require('../studyprogrammeHelpers')
const getFacultyThesisWriters = async ({ since, years, isAcademicYear, facultyProgrammes, includeAllSpecials }) => {
const thesisTypes = [
'urn:code:course-unit-type:bachelors-thesis',
'urn:code:course-unit-type:masters-thesis',
'urn:code:course-unit-type:doctors-thesis',
'urn:code:course-unit-type:licentiate-thesis',
]
let bachelors = getStatsBasis(years)
let masters = getStatsBasis(years)
let doctors = getStatsBasis(years)
let licentiates = getStatsBasis(years)
let programmeCounts = {}
let programmeNames = {}
for (const { code, name } of facultyProgrammes) {
const provider = mapToProviders([code])[0]
const students = await getCorrectStudentnumbers({
codes: [code],
startDate: alltimeStartDate,
endDate: alltimeEndDate,
includeAllSpecials,
})
const thesisCourseCodes = await thesisWriters(provider, since, thesisTypes, students)
thesisCourseCodes?.forEach(({ attainment_date, courseUnitType }) => {
const thesisYear = defineYear(attainment_date, isAcademicYear)
if (!(code in programmeCounts)) {
programmeCounts[code] = {}
Object.keys(bachelors.tableStats).forEach(year => (programmeCounts[code][year] = [0, 0, 0, 0, 0]))
programmeNames[code] = name
}
programmeCounts[code][thesisYear][0] += 1
if (courseUnitType === thesisTypes[0]) {
bachelors.graphStats[indexOf(years, thesisYear)] += 1
bachelors.tableStats[thesisYear] += 1
programmeCounts[code][thesisYear][1] += 1
} else if (courseUnitType === thesisTypes[1]) {
masters.graphStats[indexOf(years, thesisYear)] += 1
masters.tableStats[thesisYear] += 1
programmeCounts[code][thesisYear][2] += 1
} else if (courseUnitType === thesisTypes[2]) {
doctors.graphStats[indexOf(years, thesisYear)] += 1
doctors.tableStats[thesisYear] += 1
programmeCounts[code][thesisYear][3] += 1
} else if (courseUnitType === thesisTypes[3]) {
licentiates.graphStats[indexOf(years, thesisYear)] += 1
licentiates.tableStats[thesisYear] += 1
programmeCounts[code][thesisYear][4] += 1
}
})
}
return { bachelors, masters, doctors, licentiates, programmeCounts, programmeNames }
}
const getFacultyThesisWritersForStudyTrack = async (
allThesisWriters,
facultyProgrammes,
isAcademicYear,
includeAllSpecials
) => {
const since = isAcademicYear ? new Date('2017-08-01') : new Date('2017-01-01')
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { since, years, isAcademicYear, facultyProgrammes, includeAllSpecials }
const { bachelors, masters, doctors, licentiates, programmeCounts, programmeNames } = await getFacultyThesisWriters(
queryParameters
)
allThesisWriters.years = years.map(year => year.toString())
const reversedYears = years.reverse()
allThesisWriters.tableStats = reversedYears.map(year => [
year,
bachelors.tableStats[year] + masters.tableStats[year] + doctors.tableStats[year] + licentiates.tableStats[year],
bachelors.tableStats[year],
masters.tableStats[year],
doctors.tableStats[year],
licentiates.tableStats[year],
])
allThesisWriters.graphStats.push({ name: 'Bachelors', data: bachelors.graphStats })
allThesisWriters.graphStats.push({ name: 'Masters', data: masters.graphStats })
allThesisWriters.graphStats.push({ name: 'Doctors', data: doctors.graphStats })
allThesisWriters.graphStats.push({ name: 'Licentiates', data: licentiates.graphStats })
const programmes = programmeNames ? Object.keys(programmeNames) : facultyProgrammes.map(programme => programme.code)
programmes.forEach(programmeCode => {
reversedYears.forEach(year => {
if (programmeCounts && programmeCode in programmeCounts) {
if (!(programmeCode in allThesisWriters.programmeTableStats)) {
allThesisWriters.programmeTableStats[programmeCode] = []
}
allThesisWriters.programmeTableStats[programmeCode].push([year, ...programmeCounts[programmeCode][year]])
}
})
})
allThesisWriters.programmeNames = programmeNames
}
const combineFacultyThesisWriters = async (faculty, facultyProgrammes, yearType, specialGroups) => {
let allThesisWriters = {
id: faculty,
years: [],
tableStats: [],
graphStats: [],
programmeTableStats: {},
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Licentiates'],
programmeNames: {},
status: 'Done',
lastUpdated: '',
}
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
await getFacultyThesisWritersForStudyTrack(allThesisWriters, facultyProgrammes, isAcademicYear, includeAllSpecials)
return allThesisWriters
}
module.exports = { combineFacultyThesisWriters }
@@ -0,0 +1,113 @@
const logger = require('../../util/logger')
const { findFacultyProgrammeCodes } = require('./faculty')
const { combineFacultyBasics } = require('./facultyBasics')
const { combineFacultyCredits } = require('./facultyCredits')
const { combineFacultyThesisWriters } = require('./facultyThesisWriters')
const { setFacultyProgrammes, setBasicStats, setCreditStats, setThesisWritersStats } = require('./facultyService')
const updateFacultyOverview = async faculty => {
const calendarNewSpecial = {
yearType: 'CALENDAR_YEAR',
programmeFilter: 'NEW_STUDY_PROGRAMMES',
specialGroups: 'SPECIAL_INCLUDED',
}
const calendarNewSpecialOut = {
yearType: 'CALENDAR_YEAR',
programmeFilter: 'NEW_STUDY_PROGRAMMES',
specialGroups: 'SPECIAL_EXCLUDED',
}
const calendarAllSpecial = {
yearType: 'CALENDAR_YEAR',
programmeFilter: 'ALL_PROGRAMMES',
specialGroups: 'SPECIAL_INCLUDED',
}
const calendarAllSpecialOut = {
yearType: 'CALENDAR_YEAR',
programmeFilter: 'ALL_PROGRAMMES',
specialGroups: 'SPECIAL_EXCLUDED',
}
const academicNewSpecial = {
yearType: 'ACADEMIC_YEAR',
programmeFilter: 'NEW_STUDY_PROGRAMMES',
specialGroups: 'SPECIAL_INCLUDED',
}
const academicAllSpecial = {
yearType: 'ACADEMIC_YEAR',
programmeFilter: 'ALL_PROGRAMMES',
specialGroups: 'SPECIAL_INCLUDED',
}
const academicNewSpecialOut = {
yearType: 'ACADEMIC_YEAR',
programmeFilter: 'NEW_STUDY_PROGRAMMES',
specialGroups: 'SPECIAL_EXCLUDED',
}
const academicAllSpecialOut = {
yearType: 'ACADEMIC_YEAR',
programmeFilter: 'ALL_PROGRAMMES',
specialGroups: 'SPECIAL_EXCLUDED',
}
const options = [
calendarNewSpecial,
calendarAllSpecial,
academicNewSpecial,
academicAllSpecial,
calendarNewSpecialOut,
calendarAllSpecialOut,
academicNewSpecialOut,
academicAllSpecialOut,
]
let allProgrammes = []
let newProgrammes = []
let allProgrammeCodes = []
try {
const all = await findFacultyProgrammeCodes(faculty, 'ALL_PROGRAMMES')
const onlyNew = await findFacultyProgrammeCodes(faculty, 'NEW_STUDY_PROGRAMMES')
allProgrammes = await setFacultyProgrammes(faculty, all, 'ALL_PROGRAMMES')
newProgrammes = await setFacultyProgrammes(faculty, onlyNew, 'NEW_STUDY_PROGRAMMES')
allProgrammes?.data.forEach(prog => allProgrammeCodes.push(prog.code))
} catch (e) {
logger.error(e)
}
for (const option of options) {
const { yearType, programmeFilter, specialGroups } = option
try {
const updatedStudentInfo = await combineFacultyBasics(
faculty,
programmeFilter === 'ALL_PROGRAMMES' ? allProgrammes.data : newProgrammes.data,
yearType,
allProgrammeCodes,
programmeFilter,
specialGroups
)
await setBasicStats(updatedStudentInfo, option.yearType, option.programmeFilter, option.specialGroups)
const updatedCredits = await combineFacultyCredits(
faculty,
programmeFilter === 'ALL_PROGRAMMES' ? allProgrammes.data : newProgrammes.data,
yearType,
specialGroups
)
await setCreditStats(updatedCredits, yearType, programmeFilter, specialGroups)
const updateThesisWriters = await combineFacultyThesisWriters(
faculty,
programmeFilter === 'ALL_PROGRAMMES' ? allProgrammes.data : newProgrammes.data,
yearType,
specialGroups
)
await setThesisWritersStats(updateThesisWriters, yearType, programmeFilter, specialGroups)
} catch (e) {
logger.error(e)
}
}
return 'OK'
}
module.exports = { updateFacultyOverview }
+23 -4
View File
@@ -24,6 +24,7 @@ const { Tag, TagStudent } = require('../models/models_kone')
const { CourseStatsCounter } = require('./course_stats_counter')
const { getPassingSemester, semesterEnd, semesterStart } = require('../util/semester')
const { getAllProgrammes } = require('./studyrights')
const { encrypt } = require('../services/encrypt')
const enrolmentDates = () => {
const query = 'SELECT DISTINCT s.dateOfUniversityEnrollment as date FROM Student s'
@@ -209,7 +210,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
raw: true,
}),
Enrollment.findAll({
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber'],
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber', 'semestercode'],
where: {
enrollment_date_time: {
[Op.between]: [attainmentDateFrom, endDate],
@@ -413,7 +414,7 @@ const studentnumbersWithAllStudyrightElements = async (
// db startdate is formatted to utc so need to change it when querying
const formattedStartDate = new Date(moment.tz(startDate, 'Europe/Helsinki').format()).toUTCString()
const filteredExtents = [10] // always filter out secondary subject students
const filteredExtents = [16] // always filter out secondary subject students
let studyrightWhere = {
extentcode: {
[Op.notIn]: filteredExtents,
@@ -423,7 +424,7 @@ const studentnumbersWithAllStudyrightElements = async (
filteredExtents.push(7, 34)
}
if (!nondegreeStudents) {
filteredExtents.push(33, 99, 14, 13)
filteredExtents.push(22, 99, 14, 13)
}
let studentWhere = {}
@@ -981,7 +982,7 @@ const parseCreditInfo = credit => ({
date: credit.attainment_date,
})
const bottlenecksOf = async (query, studentnumberlist) => {
const bottlenecksOf = async (query, studentnumberlist, encryptdata = false) => {
const isValidRequest = async (query, params) => {
const { studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
@@ -1055,6 +1056,22 @@ const bottlenecksOf = async (query, studentnumberlist) => {
return [allstudents, courses]
}
}
const encryptStudentnumbers = bottlenecks => {
for (const course in bottlenecks.coursestatistics) {
const encryptedStudentStats = {}
for (const data in bottlenecks.coursestatistics[course].students) {
encryptedStudentStats[data] = {}
const studentnumbers = Object.keys(bottlenecks.coursestatistics[course].students[data])
studentnumbers.forEach(studentnumber => {
encryptedStudentStats[data][encrypt(studentnumber).encryptedData] =
bottlenecks.coursestatistics[course].students[data][studentnumber]
})
}
bottlenecks.coursestatistics[course].students = encryptedStudentStats
}
}
const params = parseQueryParams(query)
const [[allstudents, courses], error] = await Promise.all([
getStudentsAndCourses(query.selectedStudents, studentnumberlist, query.courses),
@@ -1114,6 +1131,8 @@ const bottlenecksOf = async (query, studentnumberlist) => {
bottlenecks.coursestatistics = Object.values(stats).map(coursestatistics => coursestatistics.getFinalStats())
bottlenecks.allStudents = allstudentslength
if (encryptdata) encryptStudentnumbers(bottlenecks)
return bottlenecks
}
+3 -3
View File
@@ -121,7 +121,7 @@ const getUnifyStatus = unifyCourses => {
}
const findByCourseAndSemesters = async (coursecodes, from, to, separate, unifyCourses = 'unifyStats') => {
const { startdate } = await Semester.findOne({
const { startdate, semestercode: fromSemester } = await Semester.findOne({
where: {
[separate ? 'semestercode' : 'yearcode']: from,
},
@@ -129,7 +129,7 @@ const findByCourseAndSemesters = async (coursecodes, from, to, separate, unifyCo
limit: 1,
raw: true,
})
const { enddate } = await Semester.findOne({
const { enddate, semestercode: toSemester } = await Semester.findOne({
where: {
[separate ? 'semestercode' : 'yearcode']: to,
},
@@ -164,7 +164,7 @@ const findByCourseAndSemesters = async (coursecodes, from, to, separate, unifyCo
WHERE
e.studentnumber = s.studentnumber
AND e.course_code IN (:coursecodes)
AND e.enrollment_date_time BETWEEN '${startdate.toISOString()}' AND '${enddate.toISOString()}'
AND e.semestercode BETWEEN ${fromSemester} AND ${toSemester}
AND e.enrollment_date_time >= '2021-05-31'
AND e.state IN ('ENROLLED', 'CONFIRMED')
);
@@ -31,6 +31,19 @@ const sinceDate = since => {
const studytrackStudents = async studentnumbers =>
(
await Student.findAll({
include: {
model: Credit,
separate: true,
attributes: ['credits', 'attainment_date'],
where: {
isStudyModule: {
[Op.eq]: null,
},
credittypecode: {
[Op.in]: [4, 9],
},
},
},
where: {
studentnumber: {
[Op.in]: studentnumbers,
@@ -321,8 +334,6 @@ const transfersAway = async (studytrack, since) =>
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
@@ -335,8 +346,6 @@ const transfersTo = async (studytrack, since) =>
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
@@ -581,6 +590,36 @@ const getOtherStudentsForProgrammeCourses = async (from, to, programmeCourses, s
}))
}
const getTransferStudentsForProgrammeCourses = async (from, to, programmeCourses) => {
const res = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
co.code AS code, co.name AS course_name FROM credit cr
INNER JOIN course co ON cr.course_code = co.code
WHERE cr.attainment_date BETWEEN :from AND :to
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 9
)
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
FROM Dist
GROUP BY dist.code, course_name;
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { from, to, programmeCourses },
}
)
return res.map(course => ({
code: course.code,
name: course.course_name,
totalTransferStudents: parseInt(course.total_students),
totalTransferCredits: parseInt(course.total_credits),
type: 'transfer',
}))
}
const getStudentsWithoutStudyrightForProgrammeCourses = async (from, to, programmeCourses) => {
const res = await sequelize.query(
`
@@ -720,4 +759,5 @@ module.exports = {
getAllProgrammeCourses,
getStudyRights,
getCourseCodesForStudyProgramme,
getTransferStudentsForProgrammeCourses,
}
@@ -47,10 +47,16 @@ const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
studyrights.forEach(({ enddate, studyrightElements }) => {
// Check that the study right element ending to graduation belong to study programme
const elements = studyrightElements.filter(
sre => new Date(sre.enddate).toDateString() === new Date(enddate).toDateString()
)
if (elements.length) {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
}
})
return { graphStats, tableStats }
@@ -145,5 +151,6 @@ const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
}
module.exports = {
getGraduatedStats,
getBasicStatsForStudytrack,
}
@@ -5,6 +5,7 @@ const {
getCurrentStudyYearStartDate,
getOwnStudentsForProgrammeCourses,
getStudentsWithoutStudyrightForProgrammeCourses,
getTransferStudentsForProgrammeCourses,
getOtherStudentsForProgrammeCourses,
getAllProgrammeCourses,
} = require('./studyprogramme')
@@ -50,6 +51,9 @@ const makeYearlyPromises = (years, academicYear, type, programmeCourses, studypr
case 'otherStudents':
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyprogramme)
break
case 'transfer':
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
break
default:
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
}
@@ -92,16 +96,26 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
studyprogramme
)
const yearlyTransferStudentsPromises = makeYearlyPromises(
yearRange,
academicYear,
'transfer',
programmeCourses,
studyprogramme
)
const [
yearlyStudentByCourse,
yearlyProgrammeStudents,
yearlyStudentsWithoutStudyright,
yearlyOtherProgrammeStudents,
yearlyTransferStudents,
] = await Promise.all([
Promise.all(yearlyStudentByCoursePromises),
Promise.all(yearlyProgrammeStudentsPromises),
Promise.all(yearlyStudentsWithoutStudyrightPromises),
Promise.all(yearlyOtherProgrammeStudentsPromises),
Promise.all(yearlyTransferStudentsPromises),
])
let maxYear = 0
@@ -110,6 +124,7 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
...yearlyProgrammeStudents.flat(),
...yearlyStudentsWithoutStudyright.flat(),
...yearlyOtherProgrammeStudents.flat(),
...yearlyTransferStudents.flat(),
].reduce((acc, curr) => {
if (curr.year > maxYear) maxYear = curr.year
if (!acc[curr.code]) {
@@ -129,6 +144,8 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
totalOtherProgrammeCredits: 0,
totalWithoutStudyrightStudents: 0,
totalWithoutStudyrightCredits: 0,
totalTransferStudents: 0,
totalTransferCredits: 0,
}
}
switch (curr.type) {
@@ -140,16 +157,17 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
acc[curr.code]['years'][curr.year]['totalProgrammeStudents'] += curr.totalProgrammeStudents
acc[curr.code]['years'][curr.year]['totalProgrammeCredits'] += curr.totalProgrammeCredits
break
case 'otherProgramme':
acc[curr.code]['years'][curr.year]['totalOtherProgrammeStudents'] += curr.totalOtherProgrammeStudents
acc[curr.code]['years'][curr.year]['totalOtherProgrammeCredits'] += curr.totalOtherProgrammeCredits
break
case 'noStudyright':
acc[curr.code]['years'][curr.year]['totalWithoutStudyrightStudents'] += curr.totalWithoutStudyrightStudents
acc[curr.code]['years'][curr.year]['totalWithoutStudyrightCredits'] += curr.totalWithoutStudyrightCredits
break
case 'transfer':
acc[curr.code]['years'][curr.year]['totalTransferStudents'] += curr.totalTransferStudents
acc[curr.code]['years'][curr.year]['totalTransferCredits'] += curr.totalTransferCredits
}
return acc
@@ -164,6 +182,8 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
'totalOtherProgrammeCredits',
'totalWithoutStudyrightStudents',
'totalWithoutStudyrightCredits',
'totalTransferCredits',
'totalTransferStudents',
]
ayCourses.forEach(ayCourse => {
const normCode = isOpenUniCourseCode(ayCourse)[1]
@@ -187,6 +207,8 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
totalOtherProgrammeCredits: 0,
totalWithoutStudyrightStudents: 0,
totalWithoutStudyrightCredits: 0,
totalTransferCredits: 0,
totalTransferStudents: 0,
}
} else {
mergedCourse['years'][year] = { ...allCourses[normCode]['years'][year] }
@@ -36,10 +36,16 @@ const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbers)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
studyrights.forEach(({ enddate, studyrightElements }) => {
// Check that the study right element ending to graduation belong to study programme
const elements = studyrightElements.filter(
sre => new Date(sre.enddate).toDateString() === new Date(enddate).toDateString()
)
if (elements.length) {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
}
})
return { graphStats, tableStats }
@@ -67,6 +67,7 @@ const formatStudyright = studyright => {
extentcode,
studentnumber: student.studentnumber,
code: resolveStudyRightCode(studyright_elements),
studyrightElements: studyright_elements,
name:
studyright_elements?.length && studyright_elements[0].element_detail && studyright_elements[0].element_detail.name
? studyright_elements[0].element_detail.name
@@ -75,12 +76,13 @@ const formatStudyright = studyright => {
}
const formatStudent = student => {
const { studentnumber, gender_code, home_country_en, creditcount } = student
const { studentnumber, gender_code, home_country_en, creditcount, credits } = student
return {
studentnumber,
gender_code,
home_country_en,
creditcount,
credits,
}
}
@@ -154,7 +156,7 @@ const isSpecialGroupCredit = (studyrights, attainment_date, transfers) =>
if (!studyright) return true // If there is no studyright matching the credit, is not a major student credit
if (studyright.studystartdate > attainment_date) return true // Credits before the studyright started are not major student credits
if (studyright.enddate && attainment_date > studyright.enddate) return true // Credits after studyright are not major student credits
if ([7, 9, 16, 34, 33, 99, 14, 13].includes(studyright.extentcode)) return true // Excludes non-degree studyrights and exchange students
if ([7, 9, 16, 34, 22, 99, 14, 13].includes(studyright.extentcode)) return true // Excludes non-degree studyrights and exchange students
if (transfers.includes(studyright.studyrightid)) return true // Excludes both transfers in and out of the programme
return false
})
@@ -348,8 +350,8 @@ const tableTitles = {
'',
'Total',
'Major students credits',
'Non major students credits',
'Non degree students credits',
'Non-major students credits',
'Non-degree students credits',
'Transferred credits',
],
},
+10 -2
View File
@@ -1,7 +1,7 @@
const {
dbConnections: { sequelize },
} = require('../database/connection')
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
const { Studyright, StudyrightElement, ElementDetail, Transfer } = require('../models')
const moment = require('moment')
const { redisClient } = require('../services/redis')
const _ = require('lodash')
@@ -187,6 +187,10 @@ const StudyRightType = {
const calculateAssociationsFromDb = async (chunksize = 100000) => {
// bottlenecked by Studyright.findAll in associatedStudyrightElements()
const transfers = await Transfer.findAll({
attributes: ['studyrightid'],
})
const transferedStudyrightIds = transfers.map(transfer => transfer.studyrightid)
const getSemester = momentstartdate => {
if (momentstartdate < moment(`${momentstartdate.utc().year()}-07-31 21:00:00+00`)) return 'SPRING'
return 'FALL'
@@ -247,8 +251,12 @@ const calculateAssociationsFromDb = async (chunksize = 100000) => {
name: e.name,
code: e.code,
}
if (!associations.programmes[code].studytracks.includes(e.code))
if (
!associations.programmes[code].studytracks.includes(e.code) &&
!transferedStudyrightIds.includes(e.studyrightid)
) {
associations.programmes[code].studytracks.push(e.code)
}
}
associations.studyTracks[e.code] = associations.studyTracks[e.code] || {
type: e.type,
@@ -26,11 +26,15 @@ const {
} = require('./studyprogramme')
const { getAcademicYearDates } = require('../util/semester')
const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
const getStudentData = (startDate, students, thresholdKeys, thresholdAmounts) => {
let data = { female: 0, male: 0, finnish: 0 }
thresholdKeys.forEach(t => (data[t] = 0))
students.forEach(({ gender_code, home_country_en, creditcount }) => {
students.forEach(({ gender_code, home_country_en, credits }) => {
const creditcount = credits
.filter(credit => moment(credit.attainment_date).isAfter(startDate))
.reduce((prev, curr) => (prev += curr.credits), 0)
data.male += gender_code === '1' ? 1 : 0
data.female += gender_code === '2' ? 1 : 0
data.finnish += home_country_en === 'Finland' ? 1 : 0
@@ -121,7 +125,7 @@ const getStudytrackDataForTheYear = async ({
// Get all the studyrights and students for the calculations
const all = await allStudyrights(track, studentnumbers)
const students = await studytrackStudents(studentnumbers)
const studentData = getStudentData(students, creditThresholdKeys, creditThresholdAmounts)
const studentData = getStudentData(startDate, students, creditThresholdKeys, creditThresholdAmounts)
const started = await startedStudyrights(track, startDate, studentnumbers)
const enrolled = await enrolledStudents(track, startDate, studentnumbers)
const absent = await absentStudents(track, studentnumbers)
+5 -6
View File
@@ -43,7 +43,7 @@ const getCategoriesAndYears = async () => {
const creditsWithTeachersForYear = yearcode =>
Credit.findAll({
attributes: ['id', 'credits', 'credittypecode', 'isStudyModule'],
attributes: ['id', 'credits', 'credittypecode', 'isStudyModule', 'is_open'],
include: [
{
model: Semester,
@@ -106,21 +106,20 @@ const findTopTeachers = async yearcode => {
credits
.filter(isRegularCourse)
.map(credit => {
const { credits, course, credittypecode } = credit
const { credits, credittypecode, is_open } = credit
const teachers = credit.teachers
.filter(({ id }) => !id.includes('hy-hlo-org')) // Remove faculties from the leaderboards
.map(({ id, name }) => ({ id, name }))
const passed = Credit.passed(credit) || Credit.improved(credit)
const failed = Credit.failed(credit)
const transferred = credittypecode === 9
const isOpenUni = course.code[0] === 'A'
return { passed, failed, credits, teachers, isOpenUni, transferred }
return { passed, failed, credits, teachers, is_open, transferred }
})
.forEach(credit => {
const { passed, failed, credits, teachers, isOpenUni, transferred } = credit
const { passed, failed, credits, teachers, is_open, transferred } = credit
teachers.forEach(teacher => {
all[teacher.id] = updatedStats(all, teacher, passed, failed, credits, transferred)
if (isOpenUni) {
if (is_open) {
openuni[teacher.id] = updatedStats(openuni, teacher, passed, failed, credits, transferred)
}
})
+18 -1
View File
@@ -7,8 +7,10 @@ const { sendNotificationAboutNewUser } = require('../services/mailservice')
const { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
const _ = require('lodash')
const { User, UserElementDetails, AccessGroup, UserFaculties, sequelizeUser } = require('../models/models_user')
const { getOrganizationAccess } = require('../util/organizationAccess')
const courseStatisticsGroup = 'grp-oodikone-basic-users'
const facultyStatisticsGroup = 'grp-oodikone-users'
const hyOneGroup = 'hy-one'
const toskaGroup = 'grp-toska'
@@ -190,8 +192,18 @@ const formatUser = async userFromDb => {
const rights = _.uniqBy([...programmes, ...enrichProgrammesFromFaculties(faculties)])
const iamRights = Object.keys(await getOrganizationAccess(userFromDb))
const {
dataValues: { id, username: userId, full_name: name, email, language, sisu_person_id: sisPersonId },
dataValues: {
id,
username: userId,
full_name: name,
email,
language,
sisu_person_id: sisPersonId,
iam_groups: iamGroups,
},
} = userFromDb
const studentsUserCanAccess = _.uniqBy(
@@ -205,9 +217,11 @@ const formatUser = async userFromDb => {
name,
language,
sisPersonId, //
iamGroups,
email,
is_enabled: true, // this is probably not needed: is here mainly because old userservice created users for every logged in user, even if they hadn't correct iamgroups
rights,
iamRights,
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'),
@@ -240,6 +254,7 @@ const getUser = async ({ username, name, email, iamGroups, sisId }) => {
email,
language,
sisu_person_id: sisId,
iam_groups: iamGroups,
last_login: new Date(),
})
@@ -251,6 +266,8 @@ const getUser = async ({ username, name, email, iamGroups, sisId }) => {
let newAccessGroups = []
if (await checkStudyGuidanceGroupsAccess(sisId)) newAccessGroups.push('studyGuidanceGroups')
if (iamGroups.includes(courseStatisticsGroup)) newAccessGroups.push('courseStatistics')
if (iamGroups.includes(facultyStatisticsGroup) || iamGroups.some(element => /-jory$/.test(element)))
newAccessGroups.push('facultyStatistics')
if (iamGroups.includes(hyOneGroup) || currentAccessGroups.includes('teachers')) newAccessGroups.push('teachers')
if (iamGroups.includes(toskaGroup) || currentAccessGroups.includes('admin')) newAccessGroups.push('admin')
if (_.difference(newAccessGroups, currentAccessGroups).length > 0) {
+239
View File
@@ -0,0 +1,239 @@
const {
isSuperAdminIam,
isAdminIam,
isUniversityWideIam,
isDoctoralIam,
getStudyLeaderGroup,
iamToOrganisationCode,
isEmployeeIam,
iamToDoctoralSchool,
kosuIamToFaculties,
dekaaniIamToFaculty,
opetusVaradekaani,
} = require('../../config/IAMConfig')
const { data } = require('../../config/data')
const { mapToDegreeCode } = require('../../config/common')
/**
* Return given access to all programmes where predicate is true
* (all if no predicate defined)
* @param {object} accessLevel
* @param {(program: object) => boolean} where
* @returns {object} access to programmes
*/
const getAllProgrammeAccess = (accessLevel, where) => {
const access = {}
data.forEach(faculty => {
faculty.programmes.forEach(program => {
if (where?.(program) === false) return
access[program.key] = { ...accessLevel }
})
})
return access
}
/**
* NOT USED
* Grant super-admin rights if the user has correct iams (eg. grp-toska)
* @param {string[]} hyGroups
* @returns superAdmin special group
*/
const getSuperAdmin = hyGroups => {
const isToska = hyGroups.some(isSuperAdminIam)
if (isToska) {
return { specialGroup: { superAdmin: true } }
}
return {}
}
/**
* NOT USED
* Grant admin rights if the user has correct iams (eg. grp-ospa)
* @param {string[]} hyGroups
* @returns admin special group
*/
const getAdmin = hyGroups => {
const isOspa = hyGroups.some(isAdminIam)
if (isOspa) {
return { specialGroup: { admin: true } }
}
return {}
}
/**
* Grant reading rights to all programmes if user has uni wide IAM (eg. hy-rehtoraatti)
* @param {string[]} hyGroups
* @returns read access to ALL programmes
*/
const getUniversityReadingRights = hyGroups => {
const hasUniversityReadingRights = hyGroups.some(isUniversityWideIam)
if (!hasUniversityReadingRights) {
return {}
}
const access = getAllProgrammeAccess({ read: true })
const specialGroup = { allProgrammes: true }
return { access, specialGroup }
}
/**
* Grant reading rights to programmes of faculties if user is kosu or dekaanaatti of some faculties
* @param {string[]} hyGroups
*/
const getFacultyReadingRights = hyGroups => {
// faculty codes from kosu iam
const facultyCodes = hyGroups
.flatMap(kosuIamToFaculties)
// faculty codes from dekanaatti iam
.concat(hyGroups.map(dekaaniIamToFaculty))
.filter(Boolean)
const access = {}
facultyCodes.forEach(fc => {
const faculty = data.find(faculty => faculty.code === fc)
const programmeCodes = faculty.programmes.map(p => p.key)
programmeCodes.forEach(code => {
access[code] = { read: true }
})
})
return { access, specialGroup: {} }
}
/**
* Grant admin rights to faculty programmes if user is opetusvaradekaani of that faculty
* @param {string[]} hyGroups
*/
const getFacultyAdminRights = hyGroups => {
if (!hyGroups.includes(opetusVaradekaani)) return {}
const facultyCodes = hyGroups.map(dekaaniIamToFaculty).filter(Boolean)
const access = {}
facultyCodes.forEach(fc => {
const faculty = data.find(faculty => faculty.code === fc)
const programmeCodes = faculty.programmes.map(p => p.key)
programmeCodes.forEach(code => {
access[code] = { read: true, write: true, admin: true }
})
})
return { access, specialGroup: {} }
}
/**
* Grant reading rights to all doctoral programmes if the user belongs to doctoral IAM
* @param {string[]} hyGroups
* @returns read access to ALL doctoral programs
*/
const getDoctoralAccess = hyGroups => {
const hasDoctoralReadingRights = hyGroups.some(isDoctoralIam)
if (!hasDoctoralReadingRights) return {}
const access = getAllProgrammeAccess({ read: true }, program => program.level === 'doctoral')
const specialGroup = { doctoral: true }
return { access, specialGroup }
}
/**
* Grants reading rights to all doctoral programmes that belong to user's
* doctoral school IAMs
* @param {string[]} hyGroups
* @returns read access to doctoral programs
*/
const getDoctoralSchoolAccess = hyGroups => {
const doctoralProgrammeCodes = hyGroups.flatMap(iamToDoctoralSchool)
const access = {}
doctoralProgrammeCodes.forEach(code => {
if (!code) return
access[code] = { read: true }
})
return { access }
}
/**
* Grant admin access if the user belongs to studyprogramme's manager group and is a study program leader
* @param {string[]} hyGroups
*/
const getProgrammeAdminAccess = hyGroups => {
const orgCodes = hyGroups
.filter(iam => hyGroups.includes(getStudyLeaderGroup(iam)))
.map(iam => iamToOrganisationCode(iam))
.filter(Boolean)
const degreeCodes = orgCodes.flatMap(codes => codes.map(mapToDegreeCode))
if (!degreeCodes?.length > 0) {
return {}
}
const access = {}
degreeCodes.forEach(code => {
access[code] = { read: true, write: true, admin: true }
})
return { access }
}
/**
* Grant write and read access if the user belongs to employees group and studyprogramme's manager group
* @param {string[]} hyGroups
*/
const getProgrammeWriteAccess = hyGroups => {
if (!hyGroups.some(isEmployeeIam)) return {}
const orgCodes = hyGroups.map(iam => iamToOrganisationCode(iam)).filter(Boolean)
const degreeCodes = orgCodes.flatMap(codes => codes.map(mapToDegreeCode))
const access = {}
degreeCodes.forEach(code => {
if (!code) return
access[code] = { read: true, write: true }
})
return { access }
}
/**
* Grant read access if the user belongs to studyprogramme's manager group
* @param {string[]} hyGroups
*/
const getProgrammeReadAccess = hyGroups => {
const orgCodes = hyGroups.map(iam => iamToOrganisationCode(iam)).filter(Boolean)
const degreeCodes = orgCodes.flatMap(codes => codes.map(mapToDegreeCode))
const access = {}
degreeCodes.forEach(code => {
if (!code) return
access[code] = { read: true }
})
return { access }
}
/**
* Gets access rights and special groups,
* based on IAM-groups in IAM header string
* @param {string} hyGroupsHeader
*/
const getIAMRights = hyGroups => {
let access = {}
let specialGroup = {}
;[
getUniversityReadingRights,
getFacultyReadingRights,
getDoctoralAccess,
getDoctoralSchoolAccess,
getProgrammeReadAccess,
getProgrammeWriteAccess,
getProgrammeAdminAccess,
getFacultyAdminRights,
getAdmin,
getSuperAdmin,
]
.map(f => f(hyGroups))
.forEach(({ access: newAccess, specialGroup: newSpecialGroup }) => {
access = { ...access, ...newAccess }
specialGroup = { ...specialGroup, ...newSpecialGroup }
})
return { access, specialGroup }
}
module.exports = {
getIAMRights,
}
@@ -0,0 +1,96 @@
/* eslint-disable no-unused-vars */
const _ = require('lodash')
const Organization = require('../models/organization')
const { getIAMRights } = require('./IAMRights')
const isNumber = value => !Number.isNaN(parseInt(value, 10))
const normalizeOrganizationCode = r => {
if (!r.includes('_')) {
return r
}
const [left, right] = r.split('_')
const prefix = [...left].filter(isNumber).join('')
const suffix = `${left[0]}${right}`
const providercode = `${prefix}0-${suffix}`
return providercode
}
const RELEVANT_ORGANIZATION_CODES = [
'H906', // Kielikeskus
'H930', // Avoin yliopisto
]
const ORGANIZATION_ACCESS_BY_IAM_GROUP = {
'grp-kielikeskus-esihenkilot': {
// Kielikeskus
H906: {
read: true,
write: true,
admin: true,
},
},
'grp-avoin-johto': {
// Avoin yliopisto
H930: {
read: true,
},
},
}
const getOrganizationAccessFromIamGroups = user => {
const access = {}
const iamAccess = (user.iamGroups ?? []).reduce(
(access, group) => _.merge(access, ORGANIZATION_ACCESS_BY_IAM_GROUP[group] ?? {}),
{}
)
Object.keys(iamAccess).forEach(code => {
access[code] = iamAccess[code]
})
return access
}
const organizationIsRelevant = organization => {
const { code } = organization
return code.includes('-') || RELEVANT_ORGANIZATION_CODES.includes(code)
}
const getAccessToAll = async accessLevel => {
const access = {}
const allOrganizations = await Organization.findAll({ attributes: ['code'] })
allOrganizations.filter(organizationIsRelevant).forEach(({ code }) => {
access[code] = accessLevel
})
return access
}
const getLomakeAccess = async user => {
if (!user.iamGroups) user.iamGroups = user.iam_groups
const access = {}
const { access: iamAccess } = getIAMRights(user.iamGroups)
if (!_.isObject(iamAccess)) return access
Object.keys(iamAccess).forEach(code => {
access[code] = iamAccess[code]
})
return access
}
const getOrganizationAccess = async user => {
const access = {
...(await getLomakeAccess(user)),
...getOrganizationAccessFromIamGroups(user),
}
return access
}
module.exports = {
getOrganizationAccess,
}
+11 -1
View File
@@ -23,7 +23,14 @@
"no-shadow": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-filename-extension": "off",
"no-async-promise-executor": "off",
"react/function-component-definition": [
2,
{
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}
],
"default-param-last": "off",
"no-implied-eval": "off",
"react/state-in-constructor": "off",
"react/static-property-placement": "off",
@@ -31,6 +38,9 @@
"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-no-useless-fragment": "off",
"import/no-unresolved": "off",
"react/jsx-no-bind": "error",
"prefer-object-spread/prefer-object-spread": "error",
+25235 -3179
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -6,7 +6,7 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.0",
"@reduxjs/toolkit": "^1.8.5",
"@sentry/browser": "^6.18.2",
"@sentry/tracing": "^6.18.2",
"axios": "^0.21.4",
@@ -18,7 +18,7 @@
"history": "^4.10.1",
"immer": "^9.0.12",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"moment": "^2.29.4",
"path-to-regexp": "^0.1.7",
"piwik-react-router": "^0.12.1",
"prop-types": "^15.8.1",
@@ -35,6 +35,7 @@
"react-router-dom": "^5.3.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"scheduler": "^0.23.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.1.2",
"unfuck-spa-shibboleth-session": "^3.1.4",
@@ -0,0 +1,42 @@
export default {
ProgrammeToggle: `
New study programmes: Näyttää kaikissa luvuissa ja kuvaajissa pelkästään uuden opetussuunnitelman ohjelmat.
All programmes: Näyttää kaikissa luvuissa ja kuvaajissa kuvaajissa myös vanhan opetussuunnitelman ohjelmat.`,
YearToggle: `
Academic year: Näyttää tilastot akateemisille vuosille jaoteltuna, esimerkiksi 1.8.2019-31.7.2020
Calendar year: Näyttää tilastot kalenterivuosille jaoteltuna, esimerkiksi 1.1.2019-31.12.2019
`,
StudentsOfTheFaculty: `
**Started studying**: Sisältää kyseisenä vuonna tiedekunnassa aloittaneet opiskelijat (aloitetut opinto-oikeudet) riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut myöhemmin keskeyttää opintonsa tai siirtyä pois ohjelmasta.
Kandi-maisteri-opinto-oikeudellisista opiskelijoista lasketaan vain kandissa ts. tiedekunnassa aloitus, ei toistamiseen enää maisteriopintojen aloitusta.\n
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
**Transferred inside**: Sisältää kyseisenä vuonna tiedekunnan sisällä siirtyneet opiskelijat (opinto-oikeudet). Ohjelmakohtaisissa tilastoissa siirto on laskettu kohdeohjelman tilastoihin.
**Transferred away**: Sisältää kyseisenä vuonna pois tiedekunnasta siirtyneet opiskelijat (opinto-oikeudet). Sama opiskelija voi esiintyä tilastoissa useana vuonna, mikäli hän on siirtynyt tiedekuntaan ja siitä pois useasti.
**Transferred to**: Sisältää kyseisenä vuonna tiedekuntaan siirtyneet opiskelijat (opinto-oikeudet).
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.
**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.
**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. Sisältää esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat.
Sisältää myös opintopisteet, jotka opiskelija on suorittanut koulutusohjelmaan ennen opintojen aloittamista koulutusohjelmassa tai opintojen päätyttyä.
**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.
Sisältää esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat. Sisältää myös opintopisteet, jotka opiskelija on suorittanut koulutusohjelmaan ennen opintojen aloittamista koulutusohjelmassa tai opintojen päätyttyä.
**Non-degree student credits**: Sisältää tiedekuntaan suoritetut opintopisteet, joiden suorittajilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
`,
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: `Sisältää kyseisenä vuonna kandidaatintutkielman, pro gradu -tutkielman, väitöskirjan tai lisensiaatintyön hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.`,
StudentToggle: `
All studyrights: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat ja ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.\n
Special studyrights excluded: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
`,
}
@@ -3,6 +3,7 @@ import CoolDataScience from './coolDataScience'
import CourseStatistics from './courseStatistics'
import CoursePopulation from './coursePopulation'
import Studyprogramme from './studyprogramme'
import Faculty from './faculty'
export default {
PopulationStatistics,
@@ -10,4 +11,5 @@ export default {
CourseStatistics,
CoursePopulation,
Studyprogramme,
Faculty,
}
@@ -80,4 +80,10 @@ export default {
NonDegree: `
Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
`,
TransferredCourses: `
Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
`,
TransferredCredits: `
Sisältää opintopisteet, jotka ovat suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
`,
}
+2 -2
View File
@@ -41,7 +41,7 @@ export const byName = (a, b) => a.name.localeCompare(b.name)
export const byCodeDesc = (a, b) => b.code.localeCompare(a.code)
export const studyRightRegex = new RegExp(/.*master|bachelor|doctor|licentiate|specialist.*/)
export const studyRightRegex = /.*master|bachelor|doctor|licentiate|specialist.*/
export const studyrightElementTypes = { programme: 20, speciality: 30 }
@@ -150,8 +150,8 @@ export const getUnifyTextIn = unifyCourses => {
export const cancelablePromise = promise => {
let hasCanceled = false
// eslint-disable-next-line no-async-promise-executor
const wrappedPromise = new Promise(async (res, rej) => {
// eslint-disable-line no-async-promise-executor
try {
await promise
if (hasCanceled) res(false)
@@ -30,7 +30,7 @@
}
.cool-data-science-tile.cool-data-science-tile--active {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
box-shadow: 0 3px 6px rgb(0 0 0 / 16%), 0 3px 6px rgb(0 0 0 / 23%);
}
.cool-data-science-tile__title {
@@ -7,7 +7,7 @@
}
.status-card:hover {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
box-shadow: 0 3px 6px rgb(0 0 0 / 16%), 0 3px 6px rgb(0 0 0 / 23%);
}
.years {
@@ -65,7 +65,7 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
categories: { passed, failed },
grades: cgrades,
enrollmentsByState: { ...acc.attempts.enrollmentsByState },
totalEnrollments: acc.students.totalEnrollments + (curr.students.totalEnrollments || 0),
totalEnrollments: acc.attempts.totalEnrollments + (curr.attempts.totalEnrollments || 0),
},
students: {
totalEnrollments: acc.students.totalEnrollments + (curr.students.totalEnrollments || 0),
@@ -24,13 +24,15 @@ const ANALYTICS_CATEGORY = 'Course Statistics'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const countFilteredStudents = (stat, filter) =>
Object.entries(stat).reduce((acc, entry) => {
const [category, students] = entry
return {
...acc,
[category]: students.filter(filter).length,
}
}, {})
stat
? Object.entries(stat).reduce((acc, entry) => {
const [category, students] = entry
return {
...acc,
[category]: students.filter(filter).length,
}
}, {})
: {}
const SingleCourseStats = ({
stats,
@@ -1,3 +1,4 @@
/* eslint-disable camelcase */
import React, { useState, useRef, useMemo } from 'react'
import { renderToString } from 'react-dom/server'
import moment from 'moment'
@@ -261,9 +262,7 @@ const createGoalSeries = (starting, ending, absences) => {
}
}
// eslint-disable-next-line camelcase
const resolveStudyRightElement = ({ studyright_elements }) => {
// eslint-disable-next-line camelcase
if (!studyright_elements || !studyright_elements.length) return {}
return (
studyright_elements
@@ -0,0 +1,353 @@
import React from 'react'
import { Divider, Loader, Popup, Button, Message } from 'semantic-ui-react'
import xlsx from 'xlsx'
import {
useGetFacultyCreditStatsQuery,
useGetFacultyBasicStatsQuery,
useGetFacultyThesisStatsQuery,
} from 'redux/facultyStats'
import LineGraph from 'components/StudyProgramme/BasicOverview/LineGraph'
import StackedBarChart from 'components/StudyProgramme/BasicOverview/StackedBarChart'
import InteractiveDataTable from '../InteractiveDataView'
import Toggle from '../../StudyProgramme/Toggle'
import InfoBox from '../../Info/InfoBox'
import InfotoolTips from '../../../common/InfoToolTips'
import '../faculty.css'
const Overview = ({
faculty,
academicYear,
setAcademicYear,
studyProgrammes,
setStudyProgrammes,
specialGroups,
setSpecialGroups,
language,
}) => {
const toolTips = InfotoolTips.Faculty
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
const credits = useGetFacultyCreditStatsQuery({
id: faculty?.code,
yearType,
studyProgrammeFilter,
specialGroups: special,
})
const basics = useGetFacultyBasicStatsQuery({
id: faculty?.code,
yearType,
studyProgrammeFilter,
specialGroups: special,
})
const thesisWriters = useGetFacultyThesisStatsQuery({
id: faculty?.code,
yearType,
studyProgrammeFilter,
specialGroups: special,
})
/*
Order of the programme keys: KH -> MH -> T -> FI -> K- -> Numbers containing letters at end -> Y- -> Numbers
*/
const regexValuesAll = [
/^KH/,
/^MH/,
/^T/,
/^LI/,
/^K-/,
/^FI/,
/^00901$/,
/^00910$/,
/^\d.*a$/,
/^Y/,
/\d$/,
/^\d.*e$/,
]
const testKey = value => {
for (let i = 0; i < regexValuesAll.length; i++) {
if (regexValuesAll[i].test(value)) {
return i
}
}
return 6
}
const sortProgrammeKeys = programmeKeys => {
return programmeKeys.sort((a, b) => {
if (testKey(a) - testKey(b) === 0) {
return a.localeCompare(b)
}
return testKey(a) - testKey(b)
})
}
const downloadCsv = (titles, tableStats, programmeStats, programmeNames, toolTipText) => {
const headers = titles.map(title => ({ label: title === '' ? 'Year' : title, key: title === '' ? 'Year' : title }))
const csvData = sortProgrammeKeys(Object.keys(programmeStats)).reduce(
(results, programme) => [
...results,
...programmeStats[programme].map(yearRow => {
return {
Programme: programme,
Name: programmeNames[programme][language]
? programmeNames[programme][language]
: programmeNames[programme].fi,
...yearRow.reduce((result, value, valueIndex) => ({ ...result, [headers[valueIndex].key]: value }), {}),
}
}),
],
[]
)
const tableStatsAsCsv = tableStats.map(yearArray =>
yearArray.reduce((result, value, yearIndex) => ({ ...result, [headers[yearIndex].key]: value }), {})
)
const book = xlsx.utils.book_new()
const tableSheet = xlsx.utils.json_to_sheet(tableStatsAsCsv)
xlsx.utils.book_append_sheet(book, tableSheet, 'TableStats')
const sheet = xlsx.utils.json_to_sheet(csvData)
xlsx.utils.book_append_sheet(book, sheet, 'ProgrammeStats')
xlsx.writeFile(book, `${faculty.code}-${toolTipText}.xlsx`)
}
const getDivider = (title, toolTipText, titles, tableStats, programmeStats, programmeNames) => (
<>
<div className="divider">
<Divider data-cy={`Section-${toolTipText}`} horizontal>
{title}
</Divider>
</div>
<InfoBox content={toolTips[toolTipText]} cypress={toolTipText} />
<Popup
content="Download statistics as csv"
trigger={
<Button
icon="download"
floated="right"
onClick={() => downloadCsv(titles, tableStats, programmeStats, programmeNames, toolTipText)}
style={{ backgroundColor: 'white', borderRadius: 0 }}
/>
}
/>
</>
)
const isFetchingOrLoading =
credits.isLoading ||
credits.isFetching ||
basics.isLoading ||
basics.isFetching ||
thesisWriters.isLoading ||
thesisWriters.isFetching
const isError =
(basics.isError && credits.isError && thesisWriters.isError) ||
(basics.isSuccess &&
!basics.data &&
credits.isSuccess &&
!credits.data &&
thesisWriters.isSuccess &&
!thesisWriters.data)
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
let creditShortTitles = []
let transferShortTitles = []
if (special === 'SPECIAL_INCLUDED') {
creditShortTitles = ['Code', 'Total', 'Major', 'Non-major', 'Non-major other', 'Non-degree']
transferShortTitles = ['Code', 'Started', 'Graduated', 'Transferred in', 'Transferred away', 'Transferred to']
} else {
creditShortTitles = ['Code', 'Major']
transferShortTitles = ['Code', 'Started', 'Graduated']
}
return (
<div className="faculty-overview">
<div className="toggle-container">
<Toggle
cypress="YearToggle"
toolTips={toolTips.YearToggle}
firstLabel="Calendar year"
secondLabel="Academic year"
value={academicYear}
setValue={setAcademicYear}
/>
<Toggle
cypress="ProgrammeToggle"
toolTips={toolTips.ProgrammeToggle}
firstLabel="New study programmes"
secondLabel="All study programmes"
value={studyProgrammes}
setValue={setStudyProgrammes}
/>
<Toggle
cypress="StudentToggle"
toolTips={toolTips.StudentToggle}
firstLabel="All studyrights"
secondLabel="Special studyrights excluded"
value={specialGroups}
setValue={setSpecialGroups}
/>
</div>
{isFetchingOrLoading ? (
<Loader active style={{ marginTop: '15em' }} />
) : (
<>
{studyProgrammeFilter === 'ALL_PROGRAMMES' && (
<Message data-cy="FacultyProgrammesShownInfo">
Please note that the data is complete only for current Bachelor, Masters and Doctoral programmes.
Especially, credits and thesis writers contain only data for current programmes.
</Message>
)}
{special === 'SPECIAL_EXCLUDED' && (
<Message data-cy="FacultyExcludeSpecialsInfo">
Please note that the data might be inaccurate in some fields. Checking the values is in progress.
</Message>
)}
{basics.isSuccess && basics.data && (
<>
{getDivider(
'Students of the faculty',
'StudentsOfTheFaculty',
basics?.data?.studentInfo.titles,
basics?.data?.studentInfo.tableStats,
basics?.data?.studentInfo.programmeTableStats,
basics?.data?.programmeNames
)}
<div className="section-container">
<div className="graph-container-narrow">
<LineGraph
cypress="StudentsOfTheFaculty"
data={{ ...basics?.data.studentInfo, years: basics.data.years }}
/>
</div>
<div className="table-container-wide">
<InteractiveDataTable
cypress="StudentsOfTheFaculty"
dataStats={basics?.data?.studentInfo.tableStats}
dataProgrammeStats={basics?.data?.studentInfo.programmeTableStats}
programmeNames={basics?.data?.programmeNames}
sortedKeys={sortProgrammeKeys(Object.keys(basics?.data?.studentInfo.programmeTableStats))}
titles={basics?.data?.studentInfo.titles}
sliceStart={1}
language={language}
yearsVisible={Array(basics?.data?.studentInfo.tableStats.length).fill(false)}
shortNames={transferShortTitles}
/>
</div>
</div>
</>
)}
{credits.isSuccess && credits.data && (
<>
{getDivider(
'Credits produced by the faculty',
'CreditsProducedByTheFaculty',
credits?.data?.titles,
credits?.data?.tableStats,
credits?.data?.programmeTableStats,
credits?.data?.programmeNames
)}
<div className="section-container">
<div className="graph-container-narrow">
<StackedBarChart
cypress="CreditsProducedByTheFaculty"
data={credits?.data?.graphStats}
labels={credits?.data?.years}
wideTable="narrow"
/>
</div>
<div className="table-container-wide">
<InteractiveDataTable
cypress="CreditsProducedByTheFaculty"
dataStats={credits?.data?.tableStats}
dataProgrammeStats={credits?.data?.programmeTableStats}
programmeNames={credits?.data?.programmeNames}
sortedKeys={sortProgrammeKeys(Object.keys(credits?.data?.programmeTableStats))}
titles={credits?.data?.titles}
extraHeight="EXTRA HEIGHT"
sliceStart={2}
language={language}
yearsVisible={Array(credits?.data?.tableStats.length).fill(false)}
shortNames={creditShortTitles}
/>
</div>
</div>
</>
)}
{basics.isSuccess && basics.data && (
<>
{getDivider(
'Graduated of the faculty',
'GraduatedOfTheFaculty',
basics?.data?.graduationInfo.titles,
basics?.data?.graduationInfo.tableStats,
basics?.data?.graduationInfo.programmeTableStats,
basics?.data?.programmeNames
)}
<div className="section-container">
<div className="graph-container-narrow">
<LineGraph
cypress="GraduatedOfTheFaculty"
data={{ ...basics?.data.graduationInfo, years: basics.data.years }}
/>
</div>
<div className="table-container-wide">
<InteractiveDataTable
cypress="GraduatedOfTheFaculty"
dataStats={basics?.data?.graduationInfo.tableStats}
dataProgrammeStats={basics?.data?.graduationInfo.programmeTableStats}
programmeNames={basics?.data?.programmeNames}
sortedKeys={sortProgrammeKeys(Object.keys(basics?.data?.graduationInfo.programmeTableStats))}
titles={basics?.data?.graduationInfo.titles}
sliceStart={2}
language={language}
yearsVisible={Array(basics?.data?.graduationInfo.tableStats.length).fill(false)}
/>
</div>
</div>
</>
)}
{thesisWriters.isSuccess && thesisWriters.data && (
<>
{getDivider(
'Thesis writers of the faculty',
'ThesisWritersOfTheFaculty',
thesisWriters?.data?.titles,
thesisWriters?.data.tableStats,
thesisWriters?.data.programmeTableStats,
thesisWriters?.data?.programmeNames
)}
<div className="section-container">
<div className="graph-container-narrow">
<LineGraph
cypress="ThesisWritersOfTheFaculty"
data={{ ...thesisWriters?.data, years: thesisWriters?.data.years }}
/>
</div>
<div className="table-container-wide">
<InteractiveDataTable
cypress="ThesisWritersOfTheFaculty"
dataStats={thesisWriters?.data.tableStats}
dataProgrammeStats={thesisWriters?.data.programmeTableStats}
programmeNames={thesisWriters?.data.programmeNames}
sortedKeys={sortProgrammeKeys(Object.keys(thesisWriters?.data.programmeTableStats))}
titles={thesisWriters?.data?.titles}
sliceStart={2}
extraHeight="EXTRA HEIGHT"
language={language}
yearsVisible={Array(thesisWriters?.data.tableStats.length).fill(false)}
/>
</div>
</div>
</>
)}
</>
)}
</div>
)
}
export default Overview
@@ -0,0 +1,46 @@
import React from 'react'
// import { Divider, Loader } from 'semantic-ui-react'
import Toggle from '../../StudyProgramme/Toggle'
// import InfoBox from '../../Info/InfoBox'
import InfotoolTips from '../../../common/InfoToolTips'
import '../faculty.css'
// const getDivider = (title, toolTipText) => (
// <>
// <div className="divider">
// <Divider data-cy={`Section-${toolTipText}`} horizontal>
// {title}
// </Divider>
// </div>
// {/* <InfoBox content={toolTips[toolTipText]} /> */}
// </>
// )
const FacultyProgrammeOverview = ({ faculty, studyProgrammes, setStudyProgrammes }) => {
const toolTipsProgramme = InfotoolTips.Faculty
// // const [programme, setProgramme] = useState(faculty)
// const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
// if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
return (
<>
<div className="programmes-overview">
<div className="toggle-container">
<Toggle
cypress="ProgrammeToggle"
toolTips={toolTipsProgramme.ProgrammeToggle}
firstLabel="New study programmes"
secondLabel="All study programmes"
value={studyProgrammes}
setValue={setStudyProgrammes}
/>
</div>
</div>
<strong> Data for {faculty?.code} upcoming</strong>
</>
)
}
export default FacultyProgrammeOverview
@@ -0,0 +1,37 @@
import React from 'react'
import { Button, Icon, Table, Label } from 'semantic-ui-react'
const roundValue = value => {
return Number.isInteger(value) ? value : value.toFixed(1)
}
const BasicRow = ({ icon, yearArray, cypress, yearIndex, toggleVisibility, styles }) => {
return (
<Table.Row style={styles} key={`Basic-row-${cypress}-${yearIndex}`}>
{yearArray?.map((value, idx) => (
<Table.Cell key={`random-button-cell-key-${Math.random()}`}>
{idx === 0 ? (
<Button
as="div"
onClick={toggleVisibility}
labelPosition="right"
style={{ backgroundColor: 'white', borderRadius: 0 }}
data-cy={`Button-${cypress}-${yearIndex}`}
>
<Button icon style={{ backgroundColor: 'white', borderRadius: 0 }}>
<Icon name={icon} />
</Button>
<Label as="a" style={{ backgroundColor: 'white', borderRadius: 0 }}>
{value}
</Label>
</Button>
) : (
roundValue(value)
)}
</Table.Cell>
))}
</Table.Row>
)
}
export default BasicRow
@@ -0,0 +1,153 @@
/* eslint-disable react/no-this-in-sfc */
import React from 'react'
import Highcharts from 'highcharts'
import ReactHighcharts from 'react-highcharts'
const colors = ['#7cb5ec', '#90ed7d', '#434348', '#f7a35c', '#FFF000', '#2b908f', '#f45b5b', '#91e8e1']
const CollapsedStackedBar = ({ data, labels, longLabels, names, language, differenceData, extraHeight }) => {
const transpose = matrix => {
return matrix.reduce((prev, next) => next.map((_item, i) => (prev[i] || []).concat(next[i])), [])
}
const needsExtra = extraHeight === 'EXTRA HEIGHT'
const manyProgrammes =
labels.includes('KH57_001') ||
labels.includes('KH50_001') ||
labels.includes('KH70_001') ||
labels.includes('KH40_001') ||
labels.includes('KH30_001') ||
labels.includes('KH80_001')
const dataTranspose = transpose(data)
.map((obj, idx) => ({ name: names[idx], data: obj, color: colors[idx] }))
.reverse()
const differenceArray = Object.keys(differenceData).reduce(
(programmes, programme) => ({
...programmes,
[programme]: differenceData[programme].reduce(
(results, val, currentIdx) => ({
...results,
[names[currentIdx]]: val,
}),
{}
),
}),
{}
)
const getCorrectSign = change => {
if (change > 0) return `+${change.toString()}`
return change
}
// Point width is 24 px different multipliers adjusts the height.
const getFlexHeight = (len, needsExtra, manyProgrammes, labels) => {
if (labels.includes('KH74_001')) return `${len * 24 * 5}px`
if (needsExtra && manyProgrammes) return `${len * 24 * 1.5}px`
if (needsExtra) return `${len * 24 * 2.5}px`
return `${len * 24 * 1.5}px`
}
const getColor = change => {
if (change > 0) return '#6ab04c'
if (change < 0) return '#ff7979'
return '#7B9FCF'
}
// Tooltip formatter function: this.<some value> needs to be used in order to obtain access to the values.
const defaultConfig = {
chart: {
type: 'bar',
marginTop: 60,
height: getFlexHeight(labels.length, needsExtra, manyProgrammes, labels),
},
credits: {
text: 'oodikone | TOSKA',
},
title: {
text: '',
style: {
display: 'none',
},
},
series: dataTranspose,
xAxis: {
categories: labels,
title: {
text: '',
},
fontSize: '24px',
},
yAxis: {
min: 0,
title: {
text: '',
},
stackLabels: {
enabled: true,
style: {
color: (Highcharts.defaultOptions.title.style && Highcharts.defaultOptions.title.style.color) || 'gray',
},
fontSize: '24px',
},
},
legend: {
layout: 'horizontal',
align: 'left',
fontSize: '24px',
x: 20,
verticalAlign: 'top',
y: -10,
floating: true,
backgroundColor: Highcharts.defaultOptions.legend.backgroundColor || 'white',
borderColor: '#CCC',
borderWidth: 1,
shadow: false,
},
tooltip: {
shared: true,
backgroundColor: 'white',
fontSize: '25px',
formatter() {
let tooltipString = `<b>${
longLabels[this.x] && longLabels[this.x][language] ? longLabels[this.x][language] : longLabels[this.x].fi
}</b><br /><p>${this.x}</p><br />`
const diffArray = differenceArray[this.x]
this.points.forEach(point => {
tooltipString += `<span style="color:${point.color}">●</span> <b>${point.series.name}: ${point.y}</b>
(<span style="color:${getColor(diffArray[point.series.name])};font-weight:bold">${getCorrectSign(
diffArray[point.series.name]
)}</span>)<br />`
})
tooltipString += `<b>Total: ${this.points.reduce((prev, current) => prev + current.y, 0)}</b>`
return tooltipString
},
},
plotOptions: {
series: {
stacking: 'normal',
pointWidth: 24,
dataLabels: {
enabled: true,
formatter() {
if (Number.isInteger(this.y)) return `${this.y}`
return `${this.y.toFixed(1)}`
},
fontSize: '24px',
filter: {
property: 'y',
operator: '>',
value: 2,
},
},
},
},
}
if (!dataTranspose) {
return <>No data provided</>
}
return <ReactHighcharts config={defaultConfig} />
}
export default CollapsedStackedBar
@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react'
import { Table, Menu } from 'semantic-ui-react'
import CollapsedStackedBar from './CollapsedStackedBar'
import BasicRow from './BasicRow'
const InteractiveDataTable = ({
cypress,
dataStats,
dataProgrammeStats,
programmeNames,
sortedKeys,
titles,
language,
sliceStart,
extraHeight,
yearsVisible,
shortNames,
studentsTable,
}) => {
const [keyOrder, setkeyOrder] = useState({})
const [sorter, setSorter] = useState('Code')
const [sortDir, setSortDir] = useState(1)
const [sortbyColumn, setSortByColumn] = useState(0)
const [columnIndex, setSelectedIndex] = useState(0)
const [visible, setVisible] = useState(yearsVisible)
const sortBySelectedColumn = () => {
const keys = {}
if (!(columnIndex === 0)) {
const numbersOfYears = dataStats.length
const data = Object.entries(dataProgrammeStats)
for (let yearIdx = 0; yearIdx < numbersOfYears; yearIdx++) {
const yearlySortedKeys = data
.sort((a, b) => {
if (sortDir === -1) return a[1][yearIdx][columnIndex] - b[1][yearIdx][columnIndex]
return b[1][yearIdx][columnIndex] - a[1][yearIdx][columnIndex]
})
.map(yearlyProgrammes => yearlyProgrammes[0])
keys[yearIdx] = yearlySortedKeys
}
}
setkeyOrder(keys)
setSortByColumn(columnIndex)
}
useEffect(sortBySelectedColumn, [columnIndex, dataStats, sortDir])
if (!dataStats || !titles || !dataProgrammeStats) return null
/* Calculation works as follows
1. Calculate difference between current and previous year: current year - previous year
2. Calculations are done for the values starting from the index 2 (index 0: year, index 1: total)
3. Last year, currently 2017 is initialized to zeros
4. A new object with programme names as keys and the difference array as value is created
*/
const calculatDiffToPrevYear = programmeData => {
const differenceMatrix = Object.keys(programmeData).reduce(
(differenceMatrix, programme) => ({
...differenceMatrix,
[programme]: programmeData[programme].map((_yearlyValues, yearIndex) =>
yearIndex < programmeData[programme].length - 1
? programmeData[programme][yearIndex]
.slice(sliceStart)
.map((val, idx) => val - programmeData[programme][yearIndex + 1][idx + sliceStart])
: new Array(programmeData[programme][yearIndex].length - sliceStart).fill(0)
),
}),
{}
)
return differenceMatrix
}
const handleClick = (sorterName, nameIndex) => {
if (sorterName === sorter) setSortDir(-1 * sortDir)
setSorter(sorterName)
setSelectedIndex(nameIndex)
}
const toggleVisibility = yearIndex => {
const arrayToModify = [...visible]
arrayToModify[yearIndex] = !visible[yearIndex]
setVisible(arrayToModify)
}
const sorterNames = shortNames || titles.map(title => (title === '' ? 'Code' : title))
const differenceToPrevYears = calculatDiffToPrevYear(dataProgrammeStats)
return (
<div>
<Menu compact attached="top">
<Menu.Item style={{ cursor: 'default', borderRadius: '1px', padding: '5px' }} active color="black">
Sort by:
</Menu.Item>
{sorterNames.map((sorterName, nameIndex) => (
<Menu.Item
data-cy={`Menu-${cypress}-${sorterName}`}
color={sorter === sorterName ? 'blue' : 'black'}
key={sorterName}
active={sorter === sorterName}
onClick={() => handleClick(sorterName, nameIndex)}
style={{ borderRadius: '1px', fontSize: '14px', padding: '5px' }}
icon={sortDir === 1 ? 'triangle down' : 'triangle up'}
content={sorterName}
/>
))}
</Menu>
<Table data-cy={`Table-${cypress}`} celled>
<Table.Header>
<Table.Row key={`randow-header-row-${Math.random()}`} textAlign="center">
{titles?.map(title => (
<Table.HeaderCell key={title}>{title}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{dataStats?.map((yearArray, yearIndex) => (
<React.Fragment key={`random-fragment-key-${Math.random()}`}>
<BasicRow
icon={visible[yearIndex] ? 'chevron down' : 'chevron right'}
yearArray={yearArray}
cypress={visible[yearIndex] ? `Hide-${cypress}` : `Show-${cypress}`}
yearIndex={yearIndex}
toggleVisibility={() => toggleVisibility(yearIndex)}
/>
<Table.Row key={`stack-row-key-${Math.random()}`} style={{ display: visible[yearIndex] ? '' : 'none' }}>
<Table.Cell data-cy={`Cell-${cypress}-${yearIndex}`} key={`stack-cell${Math.random()}`} colSpan={100}>
<CollapsedStackedBar
data={
sortbyColumn === 0
? sortedKeys?.map(programme => dataProgrammeStats[programme][yearIndex].slice(sliceStart))
: keyOrder[yearIndex]?.map(programme =>
dataProgrammeStats[programme][yearIndex].slice(sliceStart)
)
}
labels={sortbyColumn === 0 ? sortedKeys : keyOrder[yearIndex]}
differenceData={Object.keys(differenceToPrevYears)?.reduce(
(yearlyObject, programme) => ({
...yearlyObject,
[programme]: differenceToPrevYears[programme][yearIndex],
}),
{}
)}
longLabels={programmeNames}
language={language}
names={titles?.slice(sliceStart)}
extraHeight={extraHeight}
studentsTable={studentsTable}
/>
</Table.Cell>
</Table.Row>
</React.Fragment>
))}
</Table.Body>
</Table>
</div>
)
}
export default InteractiveDataTable
@@ -0,0 +1,96 @@
import React from 'react'
const ReactHighcharts = require('react-highcharts')
const allTresholds = {
bachelor: [36, 41],
bcMsCombo: [60, 70],
master: [24, 27],
doctor: [48, 58],
// Fix differrent cases?
licentiate: [78, 88],
}
const GaugeChart = ({ cypress, data, year, graduationAmount, level }) => {
if (!data || !graduationAmount || !data.length) return null
const thresholdValues = allTresholds[level]
const getColor = data => {
// if (!studyprogramme.includes('KH') && !studyprogramme.includes('MH')) return ['#1d44a1', '#EBECF0']
if (data <= thresholdValues[0]) return ['#90A959', '#EBECF0']
if (data > thresholdValues[0] && data <= thresholdValues[1]) return ['#FEE191', '#EBECF0']
return ['#FB6962', '#EBECF0']
}
if (!data) return null
const defaultConfig = {
chart: {
plotBackgroundColor: null,
plotBorderWidth: 0,
plotShadow: false,
width: 260,
height: 300,
},
title: {
text: `${data[0][1]} kk`,
align: 'center',
verticalAlign: 'middle',
y: 60,
},
subtitle: {
text: `${year}<br/> n = ${graduationAmount}`,
style: {
fontWeight: 'bold',
fontSize: '15px',
},
},
credits: {
text: '',
},
exporting: {
enabled: false,
},
accessibility: {
point: {
valueSuffix: '%',
},
},
plotOptions: {
pie: {
dataLabels: {
title: '{point.percentage:.1f}%',
distance: -50,
style: {
fontWeight: 'bold',
color: 'white',
},
},
startAngle: -90,
endAngle: 90,
center: ['50%', '75%'],
size: '100%',
colors: getColor(data[0][1]),
},
},
tooltip: {
enabled: false,
},
series: [
{
type: 'pie',
name: '',
innerSize: '40%',
data,
},
],
}
return (
<div data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default GaugeChart
@@ -0,0 +1,35 @@
import React from 'react'
import { Divider, Header } from 'semantic-ui-react'
import GaugeChart from './GaugeChart'
const GraduationTimes = ({ title, years, data, amounts, level }) => {
if (!Object.values(amounts).some(a => a > 0)) return null
return (
<>
<div className="divider">
<Header as="h4" floated="left">
{title}
</Header>
<Divider clearing />
{/* <Divider data-cy={`Title-${level}-degree`} clearing>
{title}
</Divider> */}
</div>
<div className="section-container-centered">
{years.map(year => (
<GaugeChart
cypress={`${year}-AverageGraduationTimes`}
key={year}
year={year}
data={data[year]}
graduationAmount={amounts[year]}
level={level}
/>
))}
</div>
</>
)
}
export default GraduationTimes
@@ -0,0 +1,34 @@
import React from 'react'
import { Dropdown } from 'semantic-ui-react'
import '../../StudyProgramme/studyprogramme.css'
const ProgrammeSelector = ({ programme, setProgramme, programmes }) => {
if (!programmes) return null
const handleStudytrackChange = (event, { value }) => {
event.preventDefault()
setProgramme(value)
}
return (
<div className="programme-selector">
<h4>Choose programme</h4>
<Dropdown
fluid
selection
name="programme"
placeholder="All programmes of the faculty"
value={programme}
onChange={handleStudytrackChange}
options={programmes.map(({ code, name }) => ({
key: code,
value: code,
text: code === 'faculty' ? name?.fi : `${name?.fi}, ${code}`,
}))}
/>
</div>
)
}
export default ProgrammeSelector
@@ -0,0 +1,114 @@
import React, { useState } from 'react'
import { Divider, Loader } from 'semantic-ui-react'
import { useGetFacultyGraduationTimesQuery } from 'redux/facultyStats'
import Toggle from '../../StudyProgramme/Toggle'
import InfoBox from '../../Info/InfoBox'
// import ProgrammeSelector from './ProgrammeSelector'
import GraduationTimes from './GraduationTimes'
import InfotoolTips from '../../../common/InfoToolTips'
import '../faculty.css'
const getDivider = (title, toolTipText) => (
<>
<div className="divider">
<Divider data-cy={`Section-${toolTipText}`} horizontal>
{title}
</Divider>
</div>
<InfoBox content="Keskimääräiset valmistumisajat" />
{/* <InfoBox content={toolTips[toolTipText]} /> */}
</>
)
const TimesAndPathsView = ({ faculty, studyProgrammes, setStudyProgrammes }) => {
const toolTipsProgramme = InfotoolTips.Faculty
// const [programme, setProgramme] = useState(faculty)
const [showMeanTime, setShowMeanTime] = useState(false)
const studyProgrammeFilter = studyProgrammes ? 'ALL_PROGRAMMES' : 'NEW_STUDY_PROGRAMMES'
const graduationStats = useGetFacultyGraduationTimesQuery({ id: faculty?.code, studyProgrammeFilter })
const isFetchingOrLoading = graduationStats.isLoading || graduationStats.isFetching
const isError = graduationStats.isError || (graduationStats.isSuccess && !graduationStats.data)
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
return (
<div className="programmes-overview">
<div className="toggle-container">
<Toggle
cypress="ProgrammeToggle"
toolTips={toolTipsProgramme.ProgrammeToggle}
firstLabel="New study programmes"
secondLabel="All study programmes"
value={studyProgrammes}
setValue={setStudyProgrammes}
/>
</div>
{isFetchingOrLoading ? (
<Loader active style={{ marginTop: '15em' }} />
) : (
<>
{graduationStats.isSuccess && graduationStats.data && (
<>
{getDivider('Average graduation times', 'AverageGraduationTimes')}
{/* <ProgrammeSelector programme={programme} setProgramme={setProgramme} programmes={graduationStats?.data} /> */}
<Toggle
cypress="GraduationTimeToggle"
firstLabel="Median time"
secondLabel="Mean time"
value={showMeanTime}
setValue={setShowMeanTime}
/>
<div>
<GraduationTimes
level="bachelor"
title="Bachelor (36 kk)"
years={graduationStats?.data?.years}
amounts={graduationStats?.data.graduationAmounts.bachelor}
data={showMeanTime ? graduationStats?.data?.means.bachelor : graduationStats?.data?.medians.bachelor}
/>
<GraduationTimes
level="bcMsCombo"
title="Bachelor + Master (60 kk)"
years={graduationStats?.data?.years}
amounts={graduationStats?.data.graduationAmounts.bcMsCombo}
data={
showMeanTime ? graduationStats?.data?.means?.bcMsCombo : graduationStats?.data?.medians?.bcMsCombo
}
/>
<GraduationTimes
level="master"
title="Master (24 kk)"
years={graduationStats?.data?.years}
amounts={graduationStats?.data.graduationAmounts.master}
data={showMeanTime ? graduationStats?.data?.means?.master : graduationStats?.data?.medians?.master}
/>
<GraduationTimes
level="doctor"
title="Doctor (48 kk)"
years={graduationStats?.data?.years}
amounts={graduationStats?.data.graduationAmounts.doctor}
data={showMeanTime ? graduationStats?.data?.means?.doctor : graduationStats?.data?.medians?.doctor}
/>
<GraduationTimes
level="licentiate"
title="Licentiate (78 kk)"
years={graduationStats?.data?.years}
amounts={graduationStats?.data.graduationAmounts.licentiate}
data={
showMeanTime ? graduationStats?.data?.means?.licentiate : graduationStats?.data?.medians?.licentiate
}
/>
</div>
</>
)}
</>
)}
</div>
)
}
export default TimesAndPathsView
@@ -0,0 +1,32 @@
import React, { useState } from 'react'
import { Button, Icon, Loader } from 'semantic-ui-react'
import { useUpdateFacultyBasicViewQuery } from '../../../redux/facultyStats'
const getStatusIcon = stats => {
if (stats.isLoading) return <Loader active />
if (stats.isSuccess) return <Icon name="check" color="green" />
if (stats.isError) return <Icon name="close" color="red" />
return ''
}
const UpdateView = ({ faculty }) => {
const [skipBasic, setSkipBasic] = useState(true)
const basicTabStats = useUpdateFacultyBasicViewQuery({ id: faculty }, { skip: skipBasic })
return (
<div className="update-view">
<div className="button-container">
<h4>Update data on Basic information -view</h4>
<p>
<i>(For large faculties this can take a couple of minutes)</i>
</p>
<Button disabled={basicTabStats.isLoading} color="blue" onClick={() => setSkipBasic(false)}>
Update
</Button>
{getStatusIcon(basicTabStats)}
</div>
</div>
)
}
export default UpdateView
@@ -0,0 +1,139 @@
@media only screen and (min-width: 3240px) {
.programmes-overview,
.faculty-overview,
.faculty-courses {
max-width: 80%;
}
}
@media only screen and (min-width: 3240px) {
.programmes-overview .section-container,
.faculty-overview .section-container,
.faculty-courses .section-container {
justify-content: center !important;
}
}
.programmes-overview .toggle-container,
.faculty-overview .toggle-container,
.faculty-courses .toggle-container {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 1em;
}
.faculty-overview .radio-toggle,
.faculty-courses .radio-toggle {
display: flex;
justify-content: center;
margin: 20px 35px;
}
.programmes-overview .radio-toggle {
display: flex;
justify-content: center;
margin: 0 35px 70px;
}
.programmes-overview .toggle-label,
.faculty-overview .toggle-label,
.faculty-courses .toggle-label {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
font-weight: bold;
}
.programmes-overview .toggle-label-checked,
.faculty-overview .toggle-label-checked,
.faculty-courses .toggle-label-checked {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
font-weight: bold;
opacity: 0.5;
}
.programmes-overview .ui.toggle.checkbox input:checked ~ label::before,
.faculty-overview .ui.toggle.checkbox input:checked ~ label::before,
.faculty-courses .ui.toggle.checkbox input:checked ~ label::before,
.programmes-overview .ui.toggle.checkbox input:focus:checked ~ label::before,
.faculty-overview .ui.toggle.checkbox input:focus:checked ~ label::before,
.faculty-courses .ui.toggle.checkbox input:focus:checked ~ label::before {
background-color: rgb(0 0 0 / 5%) !important;
}
.faculty-overview .divider {
margin-top: 4em;
}
.programmes-overview .section-container,
.faculty-overview .section-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.programmes-overview .section-container-centered,
.faculty-overview .section-container-centered {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.programmes-overview .graph-container,
.faculty-overview .graph-container {
min-width: 50%;
height: 500px;
}
.programmes-overview .graph-container-narrow,
.faculty-overview .graph-container-narrow {
min-width: 40%;
height: 500px;
}
.programmes-overview .table-container,
.faculty-overview .table-container {
max-width: 45%;
margin-left: 1em;
flex-grow: 2;
}
.programmes-overview .table-container-wide,
.faculty-overview .table-container-wide {
max-width: 55%;
margin-left: 1em;
flex-grow: 2;
}
.programmes-overview .datatable .total-row-cell,
.programmes-overview .table-container .total-row-cell {
border-top: 3px solid rgb(189 180 180);
font-weight: bold;
}
.programmes-overview .programmes-selector {
margin: 60px 60px 30px;
}
.programmes-overview .header-row {
font-weight: bold;
}
.programmes-overview .datatable {
margin: 2em 0 5em;
}
.programmes-overview .no-data-message {
margin: 20px 0;
}
.update-view {
margin: 2em 0;
}
.update-view .button-container {
margin: 1em 0;
}
@@ -0,0 +1,115 @@
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { debounce } from 'lodash'
import { Message, Header, Form } from 'semantic-ui-react'
import useLanguage from 'components/LanguagePicker/useLanguage'
import { getTextIn } from '../../../common'
import SortableTable from '../../SortableTable'
const FacultySelector = ({ faculties, selected }) => {
const [filter, setFilter] = useState('')
const [filteredFaculties, setFilteredFaculties] = useState([])
const { language } = useLanguage()
const handleFilterChange = debounce(value => {
setFilter(value)
}, 500)
useEffect(() => {
if (faculties) {
const filteredFaculties = faculties.filter(faculty => {
if (faculty.name[language]) {
return (
faculty.code.toLowerCase().includes(filter.toLocaleLowerCase()) ||
faculty.name[language].toLowerCase().includes(filter.toLocaleLowerCase())
)
}
return (
faculty.code.toLowerCase().includes(filter.toLocaleLowerCase()) ||
faculty.name.fi.toLowerCase().includes(filter.toLocaleLowerCase())
)
})
setFilteredFaculties(filteredFaculties)
}
}, [filter, faculties])
if (selected) return null
const headers = [
{
key: 'facultycode',
title: 'code',
getRowVal: faculty => faculty.code,
getRowContent: faculty => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/faculties/${faculty.code}`}
>
{faculty.code}
</Link>
),
cellProps: {
style: {
padding: '0',
},
},
},
{
key: 'facultyname',
title: 'name',
getRowVal: faculty => getTextIn(faculty.name, language),
getRowContent: faculty => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/faculties/${faculty.code}`}
>
{getTextIn(faculty.name, language)}
</Link>
),
cellProps: {
style: {
padding: '0',
},
},
},
]
if (faculties == null) {
return <Message>You do not have access to any faculties</Message>
}
return (
<div data-cy="select-faculty">
<Form>
Filter faculties:
<Form.Input onChange={e => handleFilterChange(e.target.value)} width="4" />
</Form>
{filteredFaculties.length > 0 ? (
<>
<Header>Faculties</Header>
<SortableTable
figure={false}
columns={headers}
getRowKey={faculty => faculty.code}
data={filteredFaculties}
/>
</>
) : null}
</div>
)
}
export default FacultySelector
@@ -1,20 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { Segment, Header } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
const FacultyStatistics = () => {
useTitle('Faculties')
return (
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Faculty statistics
</Header>
<Segment className="contentSegment">Tosi hienot faculty statsit tähän</Segment>
</div>
)
}
export default connect()(withRouter(FacultyStatistics))
@@ -0,0 +1,142 @@
import React, { useCallback, useEffect, useState } from 'react'
import { withRouter, useHistory } from 'react-router-dom'
import { Segment, Header, Tab, Loader } from 'semantic-ui-react'
import { useGetFacultiesQuery } from 'redux/facultyStats'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { getTextIn } from 'common'
import { useTabs, useTitle } from '../../common/hooks'
import FacultySelector from './facultySelector'
import BasicOverview from './BasicOverview'
import ProgrammeOverview from './FacultyProgrammeOverview'
import TimesAndPathsView from './TimesAndPaths'
import UpdateView from './UpdateView'
import useLanguage from '../LanguagePicker/useLanguage'
import TSA from '../../common/tsa'
const FacultyStatistics = props => {
useTitle('Faculties')
const history = useHistory()
const { language } = useLanguage()
const allFaculties = useGetFacultiesQuery()
const faculties = allFaculties?.data
const { match } = props
const { facultyCode } = match.params
const faculty = faculties && facultyCode && faculties.find(f => f.code === facultyCode)
const facultyName = faculty && getTextIn(faculty.name, language)
const { isAdmin } = useGetAuthorizedUserQuery()
const [tab, setTab] = useTabs('p_tab', 0, history)
const [academicYear, setAcademicYear] = useState(false)
const [studyProgrammes, setStudyProgrammes] = useState(false)
const [specialGroups, setSpecialGroups] = useState(false)
useEffect(() => {
if (!facultyName) {
return
}
TSA.Matomo.sendEvent('Faculty Usage', 'faculty overview', facultyName)
TSA.Influx.sendEvent({
group: 'Faculty Usage',
name: 'faculty overview',
label: facultyName,
value: 1,
})
}, [facultyName])
const handleSelect = useCallback(
faculty => {
history.push(`/faculties/${faculty}`, { selected: faculty })
},
[history]
)
if (allFaculties.isLoading || allFaculties.isFetching) {
return <Loader active style={{ marginTop: '10em' }} />
}
const getPanes = () => {
const panes = [
{
menuItem: 'Basic information',
render: () => (
<BasicOverview
faculty={faculty}
academicYear={academicYear}
setAcademicYear={setAcademicYear}
studyProgrammes={studyProgrammes}
setStudyProgrammes={setStudyProgrammes}
specialGroups={specialGroups}
setSpecialGroups={setSpecialGroups}
language={language}
/>
),
},
]
// REMEMBER to change cypress test 'Correct tabs are shown', when visibility of
// Graduation times and Programmes and student Populations -tabs is changed for facultyStatistic users
if (isAdmin) {
panes.push({
menuItem: 'Programmes and student populations',
render: () => (
<ProgrammeOverview
faculty={faculty}
studyProgrammes={studyProgrammes}
setStudyProgrammes={setStudyProgrammes}
/>
),
})
}
if (isAdmin) {
panes.push({
menuItem: 'Graduation times',
render: () => (
<TimesAndPathsView
faculty={faculty}
studyProgrammes={studyProgrammes}
setStudyProgrammes={setStudyProgrammes}
/>
),
})
}
if (isAdmin) {
panes.push({
menuItem: 'Update statistics',
render: () => <UpdateView faculty={faculty?.code} />,
})
}
return panes
}
const panes = getPanes()
if (!facultyCode)
return (
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Faculty statistics
</Header>
<Segment className="contentSegment">
<FacultySelector faculties={faculties} selected={facultyCode !== undefined} handleSelect={handleSelect} />
</Segment>
</div>
)
return (
<div className="segmentContainer" data-cy="FacultySegmentContainer">
<Segment className="contentSegment">
<div align="center" style={{ padding: '30px' }}>
<Header textAlign="center">{facultyName}</Header>
<span>{facultyCode}</span>
</div>
<Tab panes={panes} activeIndex={tab} onTabChange={setTab} />
</Segment>
</div>
)
}
export default withRouter(FacultyStatistics)
@@ -35,7 +35,7 @@
}
.filter-tray-toggle .button .button-label > .label {
padding: 0.5em 0.7em 0.3em 0.7em;
padding: 0.5em 0.7em 0.3em;
margin-top: -2px;
margin-bottom: -2px;
}
@@ -68,7 +68,7 @@
margin: 0 0 0 3rem;
overflow-y: scroll;
overflow-x: hidden;
padding: 1.5rem 0 10rem 0;
padding: 1.5rem 0 10rem;
}
#filter-tray-container > .header {
@@ -112,7 +112,7 @@
}
#filter-tray .card {
background-color: rgba(255, 255, 255, 0.08);
background-color: rgb(255 255 255 / 8%);
border: none;
box-shadow: none;
margin-top: 0;
@@ -140,7 +140,7 @@
#filter-tray.cards .description,
#filter-tray.cards .extra {
padding: 0 0.8rem 0.8rem 0.8rem;
padding: 0 0.8rem 0.8rem;
margin: 0;
}
@@ -171,7 +171,7 @@
#filter-tray .description-text {
color: #9e9e9e;
font-style: italic;
margin: 1rem 0.2rem 0 0.2rem;
margin: 1rem 0.2rem 0;
}
#filter-tray .card-content {
@@ -1,6 +1,6 @@
.filter-card-header .filter-clear-icon {
margin-top: -0.2em;
color: rgb(136, 136, 136);
color: rgb(136 136 136);
}
.filter-card-header .filter-clear-icon:hover {
@@ -10,9 +10,19 @@ const filterTexts = {
[FilterType.PASSED_AFTER_FAILURE]: { label: 'Passed After Failure' },
[FilterType.FAILED]: { label: 'Failed' },
[FilterType.FAILED_MANY_TIMES]: { label: 'Failed Multiple Times' },
[FilterType.NOT_PARTICIPATED]: { label: 'Not Participated' },
// [FilterType.NOT_PARTICIPATED]: { label: 'Not Participated' },
[FilterType.DID_NOT_PASS]: { label: "Didn't Pass" },
}
// a bandaid solution to prevent oodikone crashing
const translate = {
ALL: 'all',
PASSED: 'passed',
PASSED_AFTER_FAILURE: 'retryPassed',
FAILED: 'failed',
FAILED_MANY_TIMES: 'failedMany',
NOT_PARTICIPATED: 'notParticipated',
DID_NOT_PASS: 'didNotPass',
}
const CourseCard = ({ course, filterType, onChange }) => {
const { language } = useLanguage()
@@ -27,7 +37,7 @@ const CourseCard = ({ course, filterType, onChange }) => {
return (
<>
<Label style={{ marginTop: '0.5rem' }}>
{getTextIn(course.name, language)}
{getTextIn(course.course.name, language)}
<Dropdown
text={filterTexts[filterType].label}
@@ -35,7 +45,7 @@ const CourseCard = ({ course, filterType, onChange }) => {
fluid
className="mini"
button
data-cy={`${name}-${course.code}-dropdown`}
data-cy={`${name}-${course.course.code}-dropdown`}
style={{ marginTop: '0.5rem' }}
>
<Dropdown.Menu>
@@ -50,6 +60,8 @@ const CourseCard = ({ course, filterType, onChange }) => {
/>
)
}
if (Object.keys(course?.students[translate[type]]).length === 0)
return <Dropdown.Item key={label} text={label} value={type} onClick={onClick} disabled />
return <Dropdown.Item key={label} text={label} value={type} onClick={onClick} />
})}
</Dropdown.Menu>
@@ -60,7 +72,7 @@ const CourseCard = ({ course, filterType, onChange }) => {
size="tiny"
onClick={clear}
icon
data-cy={`${name}-${course.code}-clear`}
data-cy={`${name}-${course.course.code}-clear`}
style={{ marginTop: '0.5rem' }}
>
<Icon name="close" />
@@ -62,7 +62,7 @@ const CourseFilterCard = ({ courseStats, options, onOptionsChange }) => {
/>
{Object.entries(courseFilters).map(([code]) => (
<CourseCard
course={courseStats[code].course}
course={courseStats[code]}
filterType={courseFilters[code] ?? FilterType.ALL}
onChange={type => setCourseFilter(code, type)}
key={`course-filter-selected-course-${code}`}
@@ -83,9 +83,10 @@ const filterFunctions = {
[FilterType.PASSED_AFTER_FAILURE]: createFilterFunc('retryPassed'),
[FilterType.FAILED]: createFilterFunc('failed'),
[FilterType.FAILED_MANY_TIMES]: createFilterFunc('failedMany'),
[FilterType.NOT_PARTICIPATED]: createFilterFunc('all', true),
[FilterType.DID_NOT_PASS]: ({ studentNumber }, { students }) =>
!Object.keys(students.all).includes(studentNumber) || Object.keys(students.failed).includes(studentNumber),
[FilterType.DID_NOT_PASS]: createFilterFunc('didNotPass'),
// [FilterType.NOT_PARTICIPATED]: createFilterFunc('all', true),
// [FilterType.DID_NOT_PASS]: ({ studentNumber }, { students }) =>
// !Object.keys(students.all).includes(studentNumber) || Object.keys(students.failed).includes(studentNumber),
}
const filter = createFilter({
@@ -1,3 +1,4 @@
/* eslint-disable camelcase */
import React from 'react'
import { Form, Dropdown } from 'semantic-ui-react'
import { getTextIn } from 'common'
@@ -11,6 +12,7 @@ const StudyTrackFilterCard = ({ options, onOptionsChange, withoutSelf, activeAt,
const activeAtMoment = activeAt && moment(activeAt)
const dropdownOptions = _.chain(withoutSelf())
.filter(student => !student.transferredStudyright)
.flatMap(student => student.studyrights)
.map(sr => sr.studyright_elements)
.filter(sre => sre.some(e => e.code === code))
@@ -77,12 +79,20 @@ export default createFilter({
const activeAt = _.get(args, 'activeAt', moment())
return student.studyrights
.flatMap(sr => sr.studyright_elements)
.filter(
sre =>
sre.element_detail.type === 30 &&
(!activeAt || moment(activeAt).isBetween(sre.startdate, sre.enddate, 'day', '[]'))
.filter(({ studyright_elements, graduated }) =>
studyright_elements.some(
sre =>
sre.element_detail.type === 30 &&
(!activeAt ||
moment(activeAt).isBetween(
moment(sre.startdate),
graduated ? moment() : moment(sre.enddate),
'day',
'[]'
))
)
)
.flatMap(({ studyright_elements }) => studyright_elements)
.map(sre => sre.element_detail.code)
.some(code => selected.includes(code))
},
@@ -6,6 +6,7 @@ blockquote {
position: relative;
padding: 40px 20px;
}
blockquote::before {
content: open-quote;
top: 0;
@@ -4,24 +4,32 @@ import { Message, Icon, Button } from 'semantic-ui-react'
import ReactMarkdown from 'react-markdown'
import './infoBox.css'
const InfoBox = ({ content }) => {
const InfoBox = ({ content, cypress }) => {
const [open, setOpen] = useState(false)
const toggleOpen = () => setOpen(prev => !prev)
return open ? (
<Message icon className="ok-infobox" color="green">
<div className="content-container">
<div className="content-container" data-cy={`${cypress}-info-content`}>
<Icon name="info circle" size="huge" />
<Message.Content className="ok-infobox content">
<ReactMarkdown escapeHtml={false}>{content}</ReactMarkdown>
</Message.Content>
</div>
<Button onClick={toggleOpen} className="ok-infobox-close">
<Button onClick={toggleOpen} className="ok-infobox-close" data-cy={`${cypress}-close-info`}>
Close Info
</Button>
</Message>
) : (
<Button icon labelPosition="left" className="ok-infobox-collapsed" onClick={toggleOpen} basic color="green">
<Button
icon
labelPosition="left"
className="ok-infobox-collapsed"
onClick={toggleOpen}
basic
color="green"
data-cy={`${cypress}-open-info`}
>
<Icon name="info circle" />
Show Info
</Button>
@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
padding: 0;
background-color: rgba(0, 0, 0, 0.05);
background-color: rgb(0 0 0 / 5%);
}
.ok-infobox.ok-infobox.ok-infobox .content-container {
@@ -9,7 +9,8 @@ import LanguagePicker from '../LanguagePicker'
import { isDev, adminerUrls } from '../../conf'
const allNavigationItems = {
faculty: { path: '/faculties', key: 'faculties', label: 'Faculties', reqRights: ['admin'] },
trends: { path: '/trends', key: 'trends', label: 'Trends' },
faculty: { path: '/faculties', key: 'faculties', label: 'Faculties' },
populations: {
key: 'studyProgramme',
label: 'Study programme',
@@ -18,11 +19,10 @@ const allNavigationItems = {
{ path: '/study-programme', key: 'overview', label: 'Overview' },
],
},
students: { path: '/students', key: 'students', label: 'Student statistics' },
courseStatistics: { path: '/coursestatistics', key: 'courseStatistics', label: 'Course statistics' },
students: { path: '/students', key: 'students', label: 'Student statistics' },
teachers: { path: '/teachers', key: 'teachers', label: 'Teachers', reqRights: ['teachers'] },
users: { path: '/users', key: 'users', label: 'Users', reqRights: ['admin'] },
trends: { path: '/trends', key: 'trends', label: 'Trends' },
studyGuidanceGroups: {
path: '/studyguidancegroups',
key: 'studyGuidanceGroups',
@@ -39,7 +39,7 @@ const allNavigationItems = {
}
const NavigationBar = () => {
const { isLoading, rights, mockedBy, userId, roles, isAdmin } = useGetAuthorizedUserQuery()
const { isLoading, rights, iamRights, mockedBy, userId, roles, isAdmin } = useGetAuthorizedUserQuery()
const showAsUser = useShowAsUser()
const [logout] = useLogoutMutation()
@@ -48,12 +48,14 @@ const NavigationBar = () => {
if (isLoading) return visibleNavigationItems
Object.keys(allNavigationItems).forEach(key => {
if (key === 'populations') {
if (!isAdmin && rights.length === 0) return
if (!isAdmin && rights.length === 0 && iamRights.length === 0) return
}
if (key === 'students' || key === 'customPopulations') {
if (!checkUserAccess(['admin', 'studyGuidanceGroups'], roles) && rights.length === 0) return
} else if (key === 'courseStatistics') {
if (!checkUserAccess(['courseStatistics', 'admin'], roles) && rights.length === 0) return
} else if (key === 'faculty') {
if (!checkUserAccess(['facultyStatistics', 'admin'], roles)) return
}
const { reqRights } = allNavigationItems[key]
if (!reqRights || reqRights.every(r => roles.includes(r))) {
@@ -29,11 +29,11 @@ const mapCourseData = course => ({
},
})
const GradeDistribution = ({ flat }) => {
const GradeDistribution = ({ flat, onlyIamRights }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const columns = useMemo(
() => [
const columns = useMemo(() => {
const columns = [
{
key: 'name-parent',
mergeHeader: true,
@@ -110,9 +110,12 @@ const GradeDistribution = ({ flat }) => {
},
],
},
],
[onGoToCourseStatisticsClick]
)
]
if (onlyIamRights) columns[0].children.pop()
return columns
}, [onGoToCourseStatisticsClick])
const data = useMemo(() => {
if (flat) {

Some files were not shown because too many files have changed in this diff Show More