Compare commits

...
186 Commits
Author SHA1 Message Date
Sasu MäkinenandGitHub 9102a90262 Merge pull request #1047 from UniversityOfHelsinkiCS/trunk
minthify before meeting
2019-06-26 10:08:41 +03:00
Mäkinen Sasu S da32b25d78 relative view to course graphs 2019-06-26 09:49:06 +03:00
esakemp 3bf9125b2c visual change to tag creation 2019-06-26 08:27:34 +03:00
Mäkinen Sasu S fd80179799 course graph redesigning 2019-06-25 17:38:24 +03:00
esakempandGitHub 285e722ef9 Update AccessGroups.jsx 2019-06-25 15:54:04 +03:00
esakemp fbfe0ac0cd minthifying tags maybe 2019-06-25 13:56:11 +03:00
Rochet2 b88036036e Show doctoral programme in overview 2019-06-25 13:32:12 +03:00
Sasu MäkinenandGitHub dc0b02fe4a Merge pull request #1046 from UniversityOfHelsinkiCS/trunk
improved fixing
2019-06-25 12:13:38 +03:00
Mäkinen Sasu S 31df156381 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-25 11:39:56 +03:00
Mäkinen Sasu S 5309bdb621 made status updating durable 2019-06-25 11:39:51 +03:00
esakemp 538947c0cb small tag improvement 2019-06-25 08:46:43 +03:00
esakemp 8a7538b855 improve multiple tag addition 2019-06-25 08:22:49 +03:00
Mäkinen Sasu S 5ffde2d37c fix fsdkit test 2019-06-20 16:21:02 +03:00
Mäkinen Sasu S e627039176 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-20 16:13:06 +03:00
Mäkinen Sasu S c5d4caefcf #1014 2019-06-20 16:13:02 +03:00
esakempandGitHub 6ceca872e6 Merge pull request #1045 from UniversityOfHelsinkiCS/trunk
get rid of old staging
2019-06-20 15:08:58 +03:00
Tuomo Torppa 81259ef675 Merge branch 'trunk' of github.com:UniversityOfHelsinkiCS/oodikone into trunk 2019-06-20 14:48:26 +03:00
Tuomo Torppa c987e6dbd8 saatana deprekoitu staging 2019-06-20 14:48:05 +03:00
Mäkinen Sasu S 2e2a06e627 cumulative course table selectable and cursor: pointer 2019-06-20 14:47:05 +03:00
Mäkinen Sasu S d10af5ce9f fix shit 2019-06-20 14:39:04 +03:00
Mäkinen Sasu S a3b6265568 teacher id deprecated, clickable with pointer 2019-06-20 14:31:54 +03:00
esakempandGitHub 0448d0b75c Merge pull request #1044 from UniversityOfHelsinkiCS/trunk
Course search form reunattu
2019-06-20 13:14:17 +03:00
Mäkinen Sasu S 1d3eb52e68 fix tests to fit new course statistics 2019-06-20 12:17:04 +03:00
Mäkinen Sasu S ad8b6ca0a1 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-20 12:02:01 +03:00
Mäkinen Sasu S 461ce9d431 Redesigned course search form 2019-06-20 12:01:57 +03:00
esakempandGitHub 122e518481 Merge pull request #1043 from UniversityOfHelsinkiCS/trunk
fix own shit
2019-06-20 10:23:28 +03:00
Tuomo Torppa 45b402d157 Merge branch 'trunk' of github.com:UniversityOfHelsinkiCS/oodikone into trunk 2019-06-20 09:48:35 +03:00
Tuomo Torppa f6446a608a wrong port no-no 2019-06-20 09:48:23 +03:00
esakempandGitHub 2bb2f44d31 Merge branch 'master' into trunk 2019-06-20 09:34:37 +03:00
Tuomo Torppa 67940704bd staging server dockerfile 2019-06-20 09:22:54 +03:00
esakemp b537848311 fix broken grademeanfilter 2019-06-20 08:58:39 +03:00
esakemp c8d0b697b5 fix delete 2019-06-20 08:42:27 +03:00
esakempandGitHub a975292fa6 Merge pull request #1042 from UniversityOfHelsinkiCS/trunk
tagz up the wazooooo
2019-06-19 15:55:37 +03:00
esakemp 8b344e8c25 improve tag deletion 2019-06-19 15:37:10 +03:00
esakemp ba1f208225 oopsfixezz 2019-06-19 14:34:27 +03:00
Rochet2andGitHub 9bd39a37cf Merge pull request #1041 from UniversityOfHelsinkiCS/trunk
Faculty level access
2019-06-19 14:16:03 +03:00
esakemp 6700edf65f Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-19 13:57:13 +03:00
esakemp cd5a457abb Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-19 13:21:15 +03:00
Rochet2 0d8fe732e8 Fix tests 2019-06-19 13:11:39 +03:00
Rochet2 dffccec9f3 Merge branch 'trunk' of github.com:UniversityOfHelsinkiCS/oodikone into trunk 2019-06-19 13:09:08 +03:00
Rochet2 84ec4d46de Add faculty level access rights. Define programmes for valtiotieteellinen tiedekunta 2019-06-19 13:09:02 +03:00
esakemp ff49e720dc roskis implementation of tagging entire population 2019-06-19 13:03:02 +03:00
Mäkinen Sasu S 9edcce3bf2 fixed new query button in course statistics 2019-06-19 12:58:30 +03:00
Sasu MäkinenandGitHub 947a0de0a6 Merge pull request #1039 from UniversityOfHelsinkiCS/trunk
use DB_URL insteadof shit
2019-06-18 18:16:29 +03:00
Mäkinen Sasu S d00182c150 use DB_URL insteadof shit 2019-06-18 18:01:16 +03:00
Sasu MäkinenandGitHub d30529ab72 Merge pull request #1037 from UniversityOfHelsinkiCS/trunk
TAG>Z BAND FUMPS
2019-06-18 17:09:38 +03:00
Mäkinen Sasu S 435f102599 dump only 'DB_SCHEMA' 2019-06-18 16:55:10 +03:00
Mäkinen Sasu S 6d3b46dfc3 env 2019-06-18 16:47:24 +03:00
Mäkinen Sasu S 4615f4a8e6 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-18 16:21:03 +03:00
Mäkinen Sasu S bfcd48dbb0 rm bullshit 2019-06-18 16:20:54 +03:00
Mäkinen Sasu S ee1ade9d8f recoding scheduler logic and writer to be able to create database dumps 2019-06-18 16:19:54 +03:00
esakemp 45c09874b1 fixz tagz againz 2019-06-18 15:14:06 +03:00
esakemp f6e09b8316 tagz fixingz 2019-06-18 14:22:04 +03:00
esakemp 45ec804c2e tag filterz 2019-06-18 11:30:40 +03:00
Sasu MäkinenandGitHub 349597cc29 Merge pull request #1034 from UniversityOfHelsinkiCS/trunk
shuffle student tasks to hopefully reduce deadlocks
2019-06-17 17:09:59 +03:00
Mäkinen Sasu S 977188a4e2 oopsfix 2019-06-17 16:38:57 +03:00
Mäkinen Sasu S c763ae8727 shuffle student tasks to hopefully reduce deadlocks 2019-06-17 16:37:10 +03:00
esakempandGitHub b14dfed31e Merge pull request #1033 from UniversityOfHelsinkiCS/trunk
schedule and force sync fixes
2019-06-17 13:52:10 +03:00
Mäkinen Sasu S 7dc93fc41f schedule and force sync fixes 2019-06-17 13:34:10 +03:00
esakempandGitHub 53e5fb4bc5 Merge pull request #1032 from UniversityOfHelsinkiCS/trunk
Production destruction incoming
2019-06-17 12:08:47 +03:00
Mäkinen Sasu S 68dc31ef21 volyyymitbäkkii 2019-06-17 11:41:34 +03:00
Mäkinen Sasu S 6dc63eea68 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-17 11:41:15 +03:00
Sasu MäkinenandGitHub 53e0afdd56 Merge pull request #1031 from UniversityOfHelsinkiCS/trunk
maybe betterify tags
2019-06-17 10:56:39 +03:00
esakempandGitHub a5f7986d97 Merge branch 'master' into trunk 2019-06-17 10:43:28 +03:00
Mäkinen Sasu S af3716f94b added migrations_kone to shared 2019-06-17 10:29:56 +03:00
esakemp 351b7d0567 minthify taggz 2019-06-17 09:56:11 +03:00
esakemp 88394cb2e1 tag backend betterify 2019-06-17 09:29:47 +03:00
esakempandGitHub 2813ed2eaa Merge pull request #1030 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/oodikone2-userservice/js-yaml-3.13.1
Bump js-yaml from 3.12.1 to 3.13.1 in /services/oodikone2-userservice
2019-06-14 15:48:24 +03:00
esakempandGitHub 029c2a4bb0 Merge branch 'master' into dependabot/npm_and_yarn/services/oodikone2-userservice/js-yaml-3.13.1 2019-06-14 15:35:03 +03:00
esakempandGitHub 1c5c5761c5 Merge pull request #1029 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/oodikone2-userservice/handlebars-4.1.2
Bump handlebars from 4.0.12 to 4.1.2 in /services/oodikone2-userservice
2019-06-14 15:30:42 +03:00
dependabot[bot]andGitHub a73e7369ff Bump js-yaml from 3.12.1 to 3.13.1 in /services/oodikone2-userservice
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.12.1 to 3.13.1.
- [Release notes](https://github.com/nodeca/js-yaml/releases)
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.12.1...3.13.1)

Signed-off-by: dependabot[bot] <support@github.com>
2019-06-14 12:16:29 +00:00
dependabot[bot]andGitHub ea4cf8651f Bump handlebars from 4.0.12 to 4.1.2 in /services/oodikone2-userservice
Bumps [handlebars](https://github.com/wycats/handlebars.js) from 4.0.12 to 4.1.2.
- [Release notes](https://github.com/wycats/handlebars.js/releases)
- [Changelog](https://github.com/wycats/handlebars.js/blob/master/release-notes.md)
- [Commits](https://github.com/wycats/handlebars.js/compare/v4.0.12...v4.1.2)

Signed-off-by: dependabot[bot] <support@github.com>
2019-06-14 12:15:52 +00:00
esakemp b35ac28bf3 enable filters for tag list 2019-06-14 15:01:43 +03:00
esakemp fd124bdcad betterify tags 2019-06-14 14:27:37 +03:00
esakemp 68cca6f431 delete betterify 2019-06-14 13:58:07 +03:00
esakemp 24a5bdd777 betterify tags 2019-06-14 13:42:17 +03:00
esakemp 0364a34ee0 fix button name 2019-06-14 12:30:12 +03:00
esakempandGitHub 3a63c5317c Merge pull request #1024 from UniversityOfHelsinkiCS/trunk
duct tape implementation of tags
2019-06-14 12:08:11 +03:00
esakemp d63481b0d9 duct tape implementation of tags 2019-06-14 11:46:42 +03:00
Sasu MäkinenandGitHub c84fb51f7d Merge pull request #1023 from UniversityOfHelsinkiCS/trunk
curlit vittuun deploysta
2019-06-13 15:28:30 +03:00
Mäkinen Sasu S 1c4a81819b curlit vittuun deploysta 2019-06-13 15:10:03 +03:00
Sasu MäkinenandGitHub 2a0edc71e4 Merge pull request #1022 from UniversityOfHelsinkiCS/trunk
collapsing headers now deals with showing names
2019-06-13 14:44:04 +03:00
Mäkinen Sasu S 88c4c3a738 eslintfix 2019-06-13 14:26:42 +03:00
Mäkinen Sasu S a7f9e59703 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-13 14:12:01 +03:00
Mäkinen Sasu S f9042bf3b8 collapsing headers now deals with showing names 2019-06-13 14:11:17 +03:00
Sasu MäkinenandGitHub 5a26bd18c0 Merge pull request #1021 from UniversityOfHelsinkiCS/trunk
Lets fuck shit up
2019-06-13 12:09:39 +03:00
esakemp e12bccdd13 crucial change for models 2019-06-13 11:50:04 +03:00
esakemp dd22e385cc pls pass the tests 2019-06-13 11:35:18 +03:00
esakemp d32a863a05 tag stuff 2019-06-13 11:27:41 +03:00
esakemp 61c8886922 remove console.log 2019-06-13 08:41:38 +03:00
Sasu MäkinenandGitHub f274ee47bc Merge pull request #1020 from UniversityOfHelsinkiCS/trunk
fucking migrations, how do they work?
2019-06-12 16:09:49 +03:00
esakemp 78c8a505f0 pls god make this work now 2019-06-12 15:26:51 +03:00
esakempandGitHub 9261b00e8e Delete 20190611_01_add_column_to_tag_student.js 2019-06-12 13:23:47 +03:00
Sasu MäkinenandGitHub 2c27010ebf Merge pull request #1005 from UniversityOfHelsinkiCS/trunk
Panic
2019-06-12 12:06:46 +03:00
Mäkinen Sasu S 1ca1d85e9a acutally minthified collapsing mandatory courses 2019-06-12 10:20:42 +03:00
esakemp 2a19138c5c Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-12 09:57:12 +03:00
esakemp 1453f4ff76 model change 2019-06-12 09:54:40 +03:00
Mäkinen Sasu S ee432fdf5a Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-12 09:19:27 +03:00
Mäkinen Sasu S 155769e2e2 add visual cue for skipped cells in mandatory courses 2019-06-12 09:19:19 +03:00
esakemp 4ac09ac052 small change 2019-06-12 09:14:39 +03:00
esakemp abd0ebaee5 tag migrations maybe work now 2019-06-12 09:05:48 +03:00
Sasu MäkinenandGitHub 319fecb54e Merge pull request #1004 from UniversityOfHelsinkiCS/trunk
Collapsing header minthified
2019-06-11 18:09:11 +03:00
Mäkinen Sasu S dbd81caf9a sortableTable megapsykoosit vittu TULILLE 2019-06-11 17:30:37 +03:00
Mäkinen Sasu S a8bf3f7eb1 collapsing header doesnt ruin sorting anymore 2019-06-11 16:40:02 +03:00
Mäkinen Sasu S 0bc3099407 fix shit 2019-06-11 15:31:11 +03:00
Mäkinen Sasu S 82e39a2215 rm . 2019-06-11 15:29:28 +03:00
Sasu MäkinenandGitHub d66b66aa3a Merge pull request #1003 from UniversityOfHelsinkiCS/trunk
add force sync to writer
2019-06-11 15:02:26 +03:00
Mäkinen Sasu S 13886b62cd Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-11 14:48:55 +03:00
Mäkinen Sasu S 9ce0ce1529 add force sync to writer 2019-06-11 14:48:50 +03:00
esakempandGitHub d1b52ce005 Merge pull request #1002 from UniversityOfHelsinkiCS/trunk
how to do migrations 101
2019-06-11 13:13:10 +03:00
esakemp d90b19ba8f small minttu 2019-06-11 12:23:58 +03:00
esakemp 2fb735a149 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-11 12:09:52 +03:00
esakemp cac6ca9c50 fix tag migrations 2019-06-11 12:03:41 +03:00
Sasu MäkinenandGitHub f9b123aa1b Merge pull request #1001 from UniversityOfHelsinkiCS/trunk
tags and security
2019-06-11 10:40:48 +03:00
Mäkinen Sasu S bd1b453b22 collapsable sortable table 2019-06-11 10:22:06 +03:00
esakemp 8869de5e3c tag changes 2019-06-11 09:35:54 +03:00
esakempandGitHub 75e85874c7 Merge pull request #1000 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/oodikone2-frontend/trunk/axios-0.18.1
[Security] Bump axios from 0.18.0 to 0.18.1 in /services/oodikone2-frontend
2019-06-11 08:24:44 +03:00
esakemp 9b833b0dd5 stupid lint 2019-06-11 08:00:35 +03:00
dependabot-preview[bot]andGitHub 324afad820 [Security] Bump axios in /services/oodikone2-frontend
Bumps [axios](https://github.com/axios/axios) from 0.18.0 to 0.18.1. **This update includes security fixes.**
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.18.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.18.0...v0.18.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-06-11 03:37:06 +00:00
esakemp e9305392e5 another small change to tags 2019-06-10 15:38:25 +03:00
esakemp 34370af90a small changes 2019-06-10 15:29:05 +03:00
esakemp 7d2841b586 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-10 15:24:44 +03:00
esakemp e1cc9fa6d1 very non minttu tags 2019-06-10 15:18:24 +03:00
Sasu MäkinenandGitHub 24ba49b5be Merge pull request #998 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/oodikone2-userservice/trunk/tar-4.4.10
[Security] Bump tar from 4.4.1 to 4.4.10 in /services/oodikone2-userservice
2019-06-10 12:51:50 +03:00
Sasu MäkinenandGitHub f09b2441ff Merge pull request #999 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/oodikone2-backend/trunk/axios-0.19.0
[Security] Bump axios from 0.18.0 to 0.19.0 in /services/backend/oodikone2-backend
2019-06-10 12:51:21 +03:00
dependabot-preview[bot]andGitHub 604503f778 [Security] Bump axios in /services/backend/oodikone2-backend
Bumps [axios](https://github.com/axios/axios) from 0.18.0 to 0.19.0. **This update includes security fixes.**
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.18.0...v0.19.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-06-10 09:35:05 +00:00
dependabot-preview[bot]andGitHub 766f6f1ff9 [Security] Bump tar in /services/oodikone2-userservice
Bumps [tar](https://github.com/npm/node-tar) from 4.4.1 to 4.4.10. **This update includes security fixes.**
- [Release notes](https://github.com/npm/node-tar/releases)
- [Commits](https://github.com/npm/node-tar/compare/v4.4.1...v4.4.10)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-06-10 09:23:39 +00:00
esakemp c190110e82 db changes to tag 2019-06-10 10:59:26 +03:00
esakemp 93b0d50aa4 small tag fix 2019-06-10 09:16:34 +03:00
Sasu MäkinenandGitHub 7f4d177ffc Merge pull request #997 from UniversityOfHelsinkiCS/trunk
delete tag ended studyrights
2019-06-07 15:40:22 +03:00
Mäkinen Sasu S fde131ab8e ended studyright instead of transferred from 2019-06-07 15:15:37 +03:00
esakemp 23e875f783 can delete tags from database 2019-06-07 14:52:20 +03:00
Sasu MäkinenandGitHub 9c93054e9a Merge pull request #994 from UniversityOfHelsinkiCS/trunk
tags, trasferredFrom and feature toggled shit away
2019-06-07 14:32:29 +03:00
esakemp 48259304b6 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-07 14:02:44 +03:00
esakemp 9edb2df3b5 can get data from student tag table 2019-06-07 14:02:31 +03:00
Mäkinen Sasu S 61b353682e feature toggled graduation median time and graduated in time 2019-06-07 13:57:46 +03:00
Mäkinen Sasu S 8f5f075309 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-07 13:08:03 +03:00
Mäkinen Sasu S 5492ed21ea added transferredFrom. These numbers all bullshit because the data is very bullshit 2019-06-07 12:44:43 +03:00
esakemp 4803b8f1fb tag posting 2019-06-07 12:30:16 +03:00
esakemp b8867fadba get tags from database to backend to frontend 2019-06-07 12:08:10 +03:00
Rochet2andGitHub b77a04ef33 Merge pull request #989 from UniversityOfHelsinkiCS/trunk
Reimplement labels
2019-06-07 10:53:16 +03:00
Rochet2 a9b2b1816d Reimplement labels 2019-06-06 19:03:24 +03:00
Rochet2andGitHub 04230a85b6 Merge pull request #988 from UniversityOfHelsinkiCS/trunk
add few years to course jump from students tatiscis
2019-06-06 16:04:45 +03:00
Mäkinen Sasu S 74da624b9e Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-06 15:49:12 +03:00
Mäkinen Sasu S 20b70f02c4 add few years to course jump from students tatiscis 2019-06-06 15:45:31 +03:00
Rochet2andGitHub 9acde13a84 Merge pull request #987 from UniversityOfHelsinkiCS/trunk
merge Trunk
2019-06-06 15:21:08 +03:00
esakemp c7e742f86c Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-06 14:57:51 +03:00
esakemp 8be1f6a1c4 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-06 14:48:25 +03:00
Mäkinen Sasu S 0e9d63d982 fix table header in student statistics and made a test for course jump 2019-06-06 14:46:04 +03:00
esakemp 9202c7d765 tag migrations 2019-06-06 14:24:14 +03:00
Mäkinen Sasu S af528092e9 fix hardcoded test string to actual variable 2019-06-06 14:24:01 +03:00
Rochet2 2b998a9270 Fix travis db setup 2019-06-06 13:15:51 +03:00
Rochet2 0abcfe54af Fix folder name copy paste 2019-06-06 11:33:25 +03:00
Rochet2 1a3cd1f234 Merge branch 'trunk' of github.com:UniversityOfHelsinkiCS/oodikone into trunk 2019-06-06 11:13:49 +03:00
Rochet2 66ca9518cf Fix KONE migration parameters 2019-06-05 20:48:34 +03:00
Rochet2 7071c43a45 Import databases with no owner - we dont want random owners 2019-06-05 20:47:47 +03:00
esakemp 8fa1293462 remove console.log 2019-06-05 15:24:50 +03:00
esakemp 3a8ad9eab0 betterify column name 2019-06-05 14:53:21 +03:00
esakempandGitHub fb3c8bb074 Merge pull request #982 from UniversityOfHelsinkiCS/trunk
columns in table, feedback maybe minthified
2019-06-05 14:03:58 +03:00
esakemp 6cad4bc191 feedback improvements 2019-06-05 12:58:12 +03:00
esakemp d128b1abe4 hyväksiluettu in table 2019-06-05 12:21:52 +03:00
esakemp 2385d85d45 overview updates 2019-06-05 10:37:11 +03:00
Sasu MäkinenandGitHub e5938d59cf Merge pull request #979 from UniversityOfHelsinkiCS/trunk
course statistics url
2019-06-04 17:52:20 +03:00
Sasu MäkinenandGitHub e49f986282 Merge branch 'master' into trunk 2019-06-04 17:18:05 +03:00
Mäkinen Sasu S 2509701a95 trailing spaces rm 2019-06-04 17:12:56 +03:00
Mäkinen Sasu S e290695902 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-04 17:07:20 +03:00
Mäkinen Sasu S 49a0302974 initial cours ejump from student. Needs more work 2019-06-04 17:07:15 +03:00
Rochet2andGitHub e5d8a82670 Disable cypress dashboard 2019-06-04 16:33:06 +03:00
Rochet2 768c242427 Fix Model.Update not working with prependSearchPath: true 2019-06-04 16:16:30 +03:00
Rochet2 d2b7180184 Fix hiding label column row when no actual labels 2019-06-04 16:16:29 +03:00
Rochet2 5421d80925 Change overview default order direction 2019-06-04 16:16:28 +03:00
Mäkinen Sasu S 1bfecb7311 Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-04 16:06:15 +03:00
Mäkinen Sasu S 975c5ffe6c course statistics now searches based on url, works as well (poorly) as population search so we will fix it later (tm) 2019-06-04 16:06:11 +03:00
Sasu MäkinenandGitHub 3b165258c8 Merge pull request #978 from UniversityOfHelsinkiCS/trunk
studyprogrammeeee
2019-06-04 15:55:00 +03:00
esakemp b5a1953ce2 show new numbers in overview 2019-06-04 15:36:32 +03:00
esakempandGitHub d6558a93e9 whoopsie 2019-06-04 15:13:05 +03:00
esakemp 693d677349 mergele, percentage 2019-06-04 15:08:14 +03:00
Mäkinen Sasu S 079c4f787d Merge branch 'trunk' of https://github.com/UniversityOfHelsinkiCS/oodikone into trunk 2019-06-04 14:32:01 +03:00
Mäkinen Sasu S 08f6bd6bfd change median graduation time from productivity to population throughput 2019-06-04 14:31:58 +03:00
Rochet2andGitHub f17ce53e4b Merge pull request #976 from UniversityOfHelsinkiCS/trunk
Release afterparty
2019-06-04 14:17:58 +03:00
Rochet2 9c2a270475 Require key in sortabletable props 2019-06-04 13:53:02 +03:00
Rochet2 c310ccc5c4 Fix student search sometimes not finding anything 2019-06-04 12:27:17 +03:00
Rochet2 4491bd5225 Fix sorting, make sure prop types prevent sort errors in future 2019-06-04 12:16:37 +03:00
Rochet2 1ca6031201 change >= to ≥ 2019-06-04 12:16:36 +03:00
Sasu MäkinenandGitHub 3dc67b7a28 Merge pull request #969 from UniversityOfHelsinkiCS/trunk
Make feedback visible to users
2019-06-04 12:06:24 +03:00
esakemp 1de6b809f2 betterifying own non smartness 2019-06-04 08:31:45 +03:00
99 changed files with 3395 additions and 943 deletions
+2 -1
View File
@@ -25,4 +25,5 @@ services/updater_scheduler/debug.log
services/updater_scheduler/active_student_numbers.txt
services/oodikone2-backend/debug.log
datastore
services/updater_api/debug.log
services/updater_api/debug.log
services/backend/updater_writer/dumps
+3 -3
View File
@@ -30,15 +30,15 @@ jobs:
- docker ps
- cat nginx/log
- docker-compose logs -f &
- until $(curl --output /dev/null --silent --fail http://localhost:1337/staging/); do printf '.'; sleep 5; done
- until $(curl --output /dev/null --silent --fail http://localhost:1337/); do printf '.'; sleep 5; done
- time npm run test_services
- time CYPRESS_baseUrl=http://localhost:1337/staging/ npm run cypress:record
- time CYPRESS_baseUrl=http://localhost:1337/ npm run cypress:record
- docker ps -a
- if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk '{ sum += $1 } END { print sum }') != '0' ]]; then echo 'Some process had nonzero exit code'; exit 1; fi
deploy: # deploy is always skipped for PR
provider: script
skip_cleanup: true
script: "docker-compose -f docker-compose.lateste2e.yml push && curl --fail -k -X POST -H \"Authorization: token ${TRAVIS_ACCESS_TOKEN}\" https://toska.cs.helsinki.fi/toskawatch/release/ && curl -X POST -H 'Content-type: application/json' --data \"{\\\"text\\\":\\\"Staging auto deployment started!\\\"}\" ${SLACKBOT_URL}"
script: "docker-compose -f docker-compose.lateste2e.yml push && curl -X POST -H 'Content-type: application/json' --data \"{\\\"text\\\":\\\"Staging auto deployment started!\\\"}\" ${SLACKBOT_URL}"
on:
branch: master
- # in parallel
+3 -11
View File
@@ -18,13 +18,10 @@ describe('Course Statistics tests', () => {
})
it('Searching single course having duplicate mappings shows course statistics', () => {
cy.contains("Fetch statistics").should('be.disabled')
cy.url().should('include', '/coursestatistics')
cy.contains("Search for courses")
cy.get("input[placeholder='Search by entering a course code']").type('TKT20003')
cy.contains("tr", "TKT20003").within(($row) => {
cy.get('button').click()
})
cy.contains("tr", "TKT20003").click()
cy.contains("Fetch statistics").should('be.enabled').click()
cy.contains("Search for courses").should('not.exist')
@@ -51,16 +48,11 @@ describe('Course Statistics tests', () => {
})
it('Searching multiple courses having duplicate mappings shows course statistics', () => {
cy.contains("Fetch statistics").should('be.disabled')
cy.url().should('include', '/coursestatistics')
cy.contains("Search for courses")
cy.get("input[placeholder='Search by entering a course code']").type('TKT')
cy.contains("tr", "TKT20003").within(($row) => {
cy.get('button').click()
})
cy.contains("tr", "TKT10002").within(($row) => {
cy.get('button').click()
})
cy.contains("tr", "TKT20003").click()
cy.contains("tr", "TKT10002").click()
cy.contains("Fetch statistics").should('be.enabled').click()
cy.contains("Search for courses").should('not.exist')
+1 -1
View File
@@ -80,7 +80,7 @@ describe('Population Statistics tests', () => {
cy.contains("add").click()
cy.contains("Add filters").siblings().within(() => {
cy.get(".form").should('have.length', 9)
cy.get(".form").should('have.length', 10)
})
checkAmountOfStudents(26)
@@ -56,6 +56,14 @@ describe('Student Statistics tests', () => {
cy.contains('Credits').should('not.exist')
})
it('Can jump to course', () => {
cy.get('.prompt').type('Oinonen')
cy.contains('011143561').click()
cy.contains('Toinen kotimainen kieli (40061)').siblings().within(() => { cy.get('.level').click() })
cy.url().should('include', '/coursestatistics')
cy.contains('Toinen kotimainen kieli')
})
it('Searching with bad inputs doesnt yield results', () => {
cy.get('.prompt').type('Oin')
cy.contains('Student number').should('not.exist')
+3 -3
View File
@@ -75,7 +75,7 @@ services:
REDIS: redis
DB_URL: postgres://postgres@db:5432/tkt_oodi
TEST_DB: postgres://postgres@db:5432/tkt_oodi_test
FRONT_URL: http://localhost:1337/staging
FRONT_URL: http://localhost:1337
TOKEN_SECRET: IWannaBeTheVeryBest
OODILEARN_URL: http://oodilearn:5000
USERSERVICE_URL: http://userservice:4567
@@ -97,7 +97,7 @@ services:
build:
context: services/oodikone2-frontend
args:
BASE_PATH: /staging/
BASE_PATH: /
ports:
- "5000:5000"
environment:
@@ -105,7 +105,7 @@ services:
BACKEND_ADDR: backend
ADMINER_URL: http://localhost:5050/?pgsql=db&username=postgres
container_name: frontend
userservice:
image: toska/oodikone2-userservice:staging
build:
+6
View File
@@ -132,6 +132,8 @@ services:
- ./services/backend/oodikone2-backend/:/usr/src/app
- ./services/backend/shared/models:/usr/src/app/src/models
- ./services/backend/shared/migrations:/usr/src/app/src/database/migrations
- ./services/backend/shared/migrations_kone:/usr/src/app/src/database/migrations_kone
container_name: backend
depends_on:
- db
@@ -245,6 +247,8 @@ services:
- ./services/backend/updater_writer/:/usr/src/app
- ./services/backend/shared/models:/usr/src/app/models
- ./services/backend/shared/migrations:/usr/src/app/database/migrations
- ./services/backend/shared/migrations_kone:/usr/src/app/database/migrations_kone
depends_on:
- nats
- db
@@ -254,6 +258,8 @@ services:
DB_URL: postgres://postgres@db:5432/tkt_oodi
TEST_DB: postgres://postgres@db:5432/tkt_oodi_test
TEST_DB_SCHEMA: updater_test
DB_SCHEMA_KONE: kone_data
scheduler_mongo:
+2 -2
View File
@@ -8,11 +8,11 @@ http {
server {
listen 80;
location /staging/ {
location / {
proxy_pass http://frontend:5000/;
}
location /staging/api/ {
location /api/ {
proxy_pass http://backend:8080/;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"cypress:open": "CYPRESS_baseUrl=http://localhost:8081 cypress open",
"cypress:run": "CYPRESS_baseUrl=http://localhost:8081 cypress run --config video=false -P ./",
"cypress:record": "cypress run --config videoUploadOnPasses=false --record -P ./",
"cypress:record": "cypress run --config videoUploadOnPasses=false -P ./",
"concurrently": "concurrently",
"test": "./run_all_tests.sh",
"test_services": "./run_service_tests.sh"
-2
View File
@@ -52,11 +52,9 @@ do
;;
"Download latest anon db")
get_anon_oodikone
unpack_oodikone_server_backup
;;
"Download latest real db")
get_oodikone_server_backup
unpack_oodikone_server_backup
;;
"Full reset")
purge
+18 -44
View File
@@ -1,9 +1,10 @@
#!/bin/bash
DIR_PATH=$(dirname "$0")
ANONDB_DIR=anonyymioodi
BACKUP_DIR=backups
PSQL_DB_BACKUP="$BACKUP_DIR/anon.bak"
USER_DB_BACKUP="$BACKUP_DIR/user-dump.bak"
PSQL_DB_BACKUP="$ANONDB_DIR/anon.sqz"
USER_DB_BACKUP="$ANONDB_DIR/user-dump.sqz"
PSQL_REAL_DB_BACKUP="$BACKUP_DIR/latest-pg.sqz"
USER_REAL_DB_BACKUP="$BACKUP_DIR/latest-user-pg.sqz"
@@ -51,31 +52,21 @@ get_anon_oodikone() {
fi
rm -rf anonyymioodi
GIT_SSH_COMMAND='ssh -i private.key' git clone git@github.com:UniversityOfHelsinkiCS/anonyymioodi.git
mv anonyymioodi/anon.bak.bz2 ./$BACKUP_DIR/anon.bak.bz2
mv anonyymioodi/user-dump.bak.bz2 ./$BACKUP_DIR/user-dump.bak.bz2
}
unpack_oodikone_server_backup() {
bunzip2 --force --decompress --verbose ./$BACKUP_DIR/*.bz2
}
restore_psql_from_backup () {
cat $PSQL_DB_BACKUP | docker exec -i -u postgres oodi_db psql -d tkt_oodi
time pg_restore -U postgres -h localhost -p 5421 --no-owner -F c --dbname=tkt_oodi -j4 $PSQL_DB_BACKUP
}
restore_real_psql_from_backup () {
ping_psql "oodi_db" "tkt_oodi_real"
time pg_restore -U postgres -h localhost -p 5421 --no-owner -F c --dbname=tkt_oodi_real -j4 $PSQL_REAL_DB_BACKUP
}
restore_userdb_from_backup () {
docker exec -u postgres oodi_user_db psql -c "CREATE DATABASE user_db"
cat $USER_DB_BACKUP | docker exec -i -u postgres oodi_user_db psql -d user_db
time pg_restore -U postgres -h localhost -p 5422 --no-owner -F c --dbname=user_db -j4 $USER_DB_BACKUP
}
restore_real_userdb_from_backup () {
docker exec -u postgres oodi_user_db psql -c "CREATE DATABASE user_db_real"
time pg_restore -U postgres -h localhost -p 5422 --no-owner -F c --dbname=user_db_real -j4 $USER_REAL_DB_BACKUP
}
@@ -84,12 +75,6 @@ restore_real_userdb_from_backup () {
# docker exec -t mongo_db mongorestore -d oodilearn "/dump"
# }
db_oodikone_reset () {
docker exec -u postgres oodi_db dropdb "tkt_oodi" || echo "dbdrop of tkt_oodi failed. "
docker exec -u postgres oodi_db createdb "tkt_oodi" || echo "createdb of tkt_oodi failed. "
restore_psql_from_backup
}
ping_psql () {
echo "Pinging psql in container $1 with db name $2"
retry docker exec -u postgres $1 pg_isready --dbname=$2
@@ -156,10 +141,6 @@ show_instructions () {
cat ./assets/instructions.txt
}
docker_restart_backend () {
docker-compose restart backend userservice
}
run_full_setup () {
echo "Setup npm packages"
install_cli_npm_packages
@@ -169,15 +150,13 @@ run_full_setup () {
get_oodikone_server_backup
echo "Getting anon backups from the private repository. "
get_anon_oodikone
echo "Unpacking compressed files"
unpack_oodikone_server_backup
echo "Building images, starting containers"
docker_build
echo "Building images"
docker-compose build
echo "Setup oodikone db from dump."
docker-compose up -d db user_db
db_setup_full
db_anon_setup_full
echo "Restarting Docker backend containers to run migrations, etc."
docker_restart_backend
docker-compose down
show_instructions
}
@@ -188,14 +167,12 @@ run_anon_full_setup () {
init_dirs
echo "Getting anon backups from the private repository. "
get_anon_oodikone
echo "Unpacking compressed files"
unpack_oodikone_server_backup
echo "Building images, starting containers"
docker_build
echo "Building images"
docker-compose build
echo "Setup oodikone db from dump."
docker-compose up -d db user_db
db_anon_setup_full
echo "Restarting Docker backend containers to run migrations, etc."
docker_restart_backend
docker-compose down
show_instructions
}
@@ -206,14 +183,11 @@ run_e2e_setup () {
init_dirs
echo "Getting anon backups from the private repository. "
get_anon_oodikone
echo "Unpacking compressed files"
unpack_oodikone_server_backup
echo "Building images, starting containers"
docker-compose -f $1 build && docker-compose -f $1 up -d
echo "Building images"
docker-compose -f $1 build
echo "Setup oodikone db from dump."
docker-compose -f $1 up -d db user_db
db_anon_setup_full
echo "Restarting Docker backend containers to run migrations, etc."
docker-compose -f $1 restart backend userservice
echo "Restarting Docker nginx because it has old backend IP otherwise"
docker-compose -f $1 restart nginx
echo "Starting services."
docker-compose -f $1 up -d
}
@@ -3,6 +3,8 @@ FROM node:10.15-alpine
COPY ./oodikone2-backend /usr/src/app
COPY ./shared/models /usr/src/app/src/models
COPY ./shared/migrations /usr/src/app/src/database/migrations
COPY ./shared/migrations_kone /usr/src/app/src/database/migrations_kone
WORKDIR /usr/src/app
RUN npm ci
+56 -24
View File
@@ -1702,7 +1702,7 @@
},
"chalk": {
"version": "0.4.0",
"resolved": "http://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz",
"integrity": "sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=",
"dev": true,
"requires": {
@@ -1713,7 +1713,7 @@
},
"pretty-ms": {
"version": "0.2.2",
"resolved": "http://registry.npmjs.org/pretty-ms/-/pretty-ms-0.2.2.tgz",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-0.2.2.tgz",
"integrity": "sha1-2oeaaC/zOjcBEEbxPWJ/Z8c7hPY=",
"dev": true,
"requires": {
@@ -1722,7 +1722,7 @@
},
"strip-ansi": {
"version": "0.1.1",
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz",
"integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=",
"dev": true
}
@@ -2403,12 +2403,19 @@
"dev": true
},
"axios": {
"version": "0.18.0",
"resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz",
"integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=",
"version": "0.19.0",
"resolved": "http://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
"integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
"requires": {
"follow-redirects": "^1.3.0",
"is-buffer": "^1.1.5"
"follow-redirects": "1.5.10",
"is-buffer": "^2.0.2"
},
"dependencies": {
"is-buffer": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
"integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
}
}
},
"babel-code-frame": {
@@ -3858,7 +3865,8 @@
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"optional": true
},
"content-disposition": {
"version": "0.5.2",
@@ -5128,7 +5136,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -5149,12 +5158,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5169,17 +5180,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -5296,7 +5310,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -5308,6 +5323,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -5322,6 +5338,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -5329,12 +5346,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -5353,6 +5372,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -5433,7 +5453,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -5445,6 +5466,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -5530,7 +5552,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -5566,6 +5589,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -5585,6 +5609,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -5628,12 +5653,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -10593,7 +10620,7 @@
},
"onetime": {
"version": "1.1.0",
"resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=",
"dev": true
},
@@ -11250,6 +11277,7 @@
"version": "0.1.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"kind-of": "^3.0.2",
"longest": "^1.0.1",
@@ -11600,7 +11628,8 @@
"is-buffer": {
"version": "1.1.6",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"is-builtin-module": {
"version": "1.0.0",
@@ -11711,6 +11740,7 @@
"version": "3.2.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"is-buffer": "^1.1.5"
}
@@ -11757,7 +11787,8 @@
"longest": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"lru-cache": {
"version": "4.1.3",
@@ -12023,7 +12054,8 @@
"repeat-string": {
"version": "1.6.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"require-directory": {
"version": "2.1.1",
@@ -38,7 +38,7 @@
"author": "Matti Luukkainen",
"license": "MIT",
"dependencies": {
"axios": "^0.18.0",
"axios": "^0.19.0",
"bluebird": "^3.5.3",
"body-parser": "^1.18.3",
"cors": "^2.8.5",
@@ -4,22 +4,20 @@ const conf = require('../conf-backend')
const sequelize = new Sequelize(conf.DB_URL, {
schema: conf.DB_SCHEMA,
searchPath: conf.DB_SCHEMA,
logging: false,
operatorsAliases: false,
dialectOptions: {
prependSearchPath: true
}
operatorsAliases: false
})
sequelize.query(`SET SESSION search_path to ${conf.DB_SCHEMA}`)
const sequelizeKone = new Sequelize(conf.DB_URL, {
schema: conf.DB_SCHEMA_KONE,
logging: false,
operatorsAliases: false,
searchPath: conf.DB_SCHEMA_KONE,
dialectOptions: {
prependSearchPath: true
}
logging: false,
operatorsAliases: false
})
sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
// See https://github.com/sequelize/sequelize/issues/10875
const runMigrations = async () => {
try {
@@ -37,6 +35,7 @@ const runMigrations = async () => {
],
path: `${process.cwd()}/src/database/migrations`,
pattern: /\.js$/,
schema: conf.DB_SCHEMA
}
})
const migrations = await migrator.up()
@@ -60,12 +59,11 @@ const runMigrationsKone = async () => {
migrations: {
params: [
sequelizeKone.getQueryInterface(),
sequelizeKone
Sequelize
],
path: `${process.cwd()}/src/database/migrations_kone`,
pattern: /\.js$/,
schema: conf.DB_SCHEMA_KONE,
wrap: fn => () => fn(sequelizeKone.getQueryInterface(), sequelizeKone)
schema: conf.DB_SCHEMA_KONE
},
})
const migrations = await migrator.up()
@@ -12,14 +12,17 @@ const auth = require('./middleware/auth')
const teachers = require('./routes/teachers')
const usage = require('./routes/usage')
const providers = require('./routes/providers')
const faculties = require('./routes/faculties')
const semesters = require('./routes/semesters')
const oodilearn = require('./routes/oodilearn')
const courseGroups = require('./routes/courseGroups')
const mandatoryCourses = require('./routes/mandatorycourses')
const mandatoryCourseLabels = require('./routes/mandatorycourselabels')
const ping = require('./routes/ping')
const oodi = require('./routes/oodi')
const task = require('./routes/tasks')
const feedback = require('./routes/feedback')
const tags = require('./routes/tags')
module.exports = (app, url) => {
app.use(url, log)
@@ -33,14 +36,17 @@ module.exports = (app, url) => {
app.use(url, population)
app.use(url, language)
app.use(url, providers)
app.use(url, faculties)
app.use(url, semesters)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
app.use(`${url}/users`, auth.roles(['users']), users)
app.use(`${url}/feedback`, auth.roles(['users']), feedback)
app.use(`${url}/feedback`, feedback)
app.use(`${url}/usage`, auth.roles(['usage']), usage)
app.use(`${url}/oodilearn`, auth.roles(['oodilearn']), oodilearn)
app.use(`${url}/course-groups`, auth.roles(['coursegroups']), courseGroups)
app.use(`${url}/mandatory_courses`, mandatoryCourses),
app.use(`${url}/mandatory-course-labels`, mandatoryCourseLabels),
app.use(`${url}/oodi`, auth.roles(['dev']), oodi)
app.use(url, auth.roles(['dev', 'admin']), task)
app.use(url, tags)
}
@@ -0,0 +1,9 @@
const router = require('express').Router()
const { all } = require('../services/organisations')
router.get('/faculties', async (req, res) => {
const faculties = await all()
res.json(faculties)
})
module.exports = router
@@ -5,10 +5,9 @@ const userService = require('../services/userService')
router.post('/email', async (req, res) => {
const { content } = req.body
const { uid } = req.headers
const { email } = await userService.byUsername(uid)
const { email, full_name } = await userService.byUsername(uid)
const formattedEmail = content.split('\n\n').map(line => `<p> ${line} </p>`).join('')
const feedback = mailservice.feedback(formattedEmail, uid, email)
const feedback = mailservice.feedback(formattedEmail, uid, email, full_name)
if (process.env.SMTP !== undefined && content) {
await mailservice.transporter.sendMail(feedback, (error) => {
@@ -0,0 +1,70 @@
const router = require('express').Router()
const MandatoryCourseLabels = require('../services/mandatoryCourseLabels')
router.get('/:programme/labels', async (req, res) => {
const { programme } = req.params
if (programme) {
try {
const labels = await MandatoryCourseLabels.labelsByStudyprogramme(programme)
res.status(200).json(labels)
} catch (err) {
console.error(err)
res.status(400).json(err.message)
}
} else {
res.status(422).end()
}
})
router.delete('/:programme/labels', async (req, res) => {
const { programme } = req.params
const { label } = req.body
if (programme && label) {
try {
await MandatoryCourseLabels.destroy(programme, label)
const labels = await MandatoryCourseLabels.labelsByStudyprogramme(programme)
res.status(200).json(labels)
} catch (err) {
console.error(err)
res.status(400).json(err.message)
}
} else {
res.status(422).end()
}
})
router.post('/:programme/move', async (req, res) => {
const { programme } = req.params
const { label, direction } = req.body
if (programme && label) {
try {
await MandatoryCourseLabels.move(programme, label, direction)
const labels = await MandatoryCourseLabels.labelsByStudyprogramme(programme)
res.status(200).json(labels)
} catch (err) {
console.error(err)
res.status(400).json(err.message)
}
} else {
res.status(422).end()
}
})
router.post('/:programme/labels', async (req, res) => {
const { programme } = req.params
const { label } = req.body
if (programme && label) {
try {
await MandatoryCourseLabels.create(programme, label)
const labels = await MandatoryCourseLabels.labelsByStudyprogramme(programme)
res.status(200).json(labels)
} catch (err) {
console.error(err)
res.status(400).json(err.message)
}
} else {
res.status(422).end()
}
})
module.exports = router
@@ -46,7 +46,7 @@ router.post('/:programme/label/:course', async (req, res) => {
.json({
name: updatedCourse.course.name,
code: updatedCourse.course_code,
label: updatedCourse.label
label: updatedCourse.mandatory_course_label
})
} catch (err) {
console.error(err)
@@ -0,0 +1,104 @@
const router = require('express').Router()
const Tags = require('../services/tags')
const TagStudent = require('../services/tagstudent')
router.get('/tags', async (req, res) => {
try {
const tags = await Tags.findTags()
res.status(200).json(tags)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.get('/tags/:studytrack', async (req, res) => {
const { studytrack } = req.params
try {
const tags = await Tags.findTagsByStudytrack(studytrack)
res.status(200).json(tags)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.post('/tags', async (req, res) => {
const { tag } = req.body
try {
const result = await Tags.createNewTag(tag)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err.message)
}
})
router.delete('/tags', async (req, res) => {
const { tag } = req.body
try {
const result = await Tags.deleteTag(tag)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.get('/studenttags', async (req, res) => {
try {
const result = await TagStudent.getStudentTags()
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.get('/studenttags/:studytrack', async (req, res) => {
const { studytrack } = req.params
try {
const result = await TagStudent.getStudentTagsByStudytrack(studytrack)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.get('/studenttags/:studentnumber', async (req, res) => {
const { studentnumber } = req.params
try {
const result = await TagStudent.getStudentTagsByStudentnumber(studentnumber)
console.log(result)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.post('/studenttags', async (req, res) => {
const { tag } = req.body
try {
const result = await TagStudent.createStudentTag(tag)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
router.delete('/studenttags', async (req, res) => {
const { id } = req.body
try {
const result = await TagStudent.deleteStudentTag(id)
res.status(200).json(result)
} catch (err) {
console.log(err)
res.status(400).json(err)
}
})
module.exports = router
@@ -63,4 +63,12 @@ router.delete('/:uid/elements', async (req, res) => {
res.json(user)
})
router.post('/:uid/faculties', async (req, res) => {
const { uid } = req.params
const { faculties } = req.body
const user = await userService.setFaculties(uid, faculties)
if (user && user.username != req.decodedToken.userId) await blacklist.addUserToBlacklist(user.username)
res.json(user)
})
module.exports = router
@@ -62,14 +62,14 @@ const message2 = email => {
<img style="max-width: 13.5%;height: auto;" src="https://i.imgur.com/tnNDAJk.png" /> `,
})
}
const feedback = (content, user, email) => {
const feedback = (content, user, email, full_name) => {
return ({
to: 'Toska <grp-toska@helsinki.fi>',
replyTo: email,
subject: `New message from Oodikone feedback`,
text: `New message from user ${user}`,
html: `${content}
<p>sent by user: ${user}, email: ${email}</p>
<p>sent by ${full_name}, userid: ${user}, email: ${email}</p>
<img src="cid:toskalogoustcid"/>`,
attachments: [
{
@@ -0,0 +1,66 @@
const Sequelize = require('sequelize')
const _ = require('lodash')
const { MandatoryCourseLabels } = require('../models')
const { Op } = Sequelize
const {
sequelize
} = require('../models/index')
const create = async (studyprogramme_id, { label }) => {
const labels = await labelsByStudyprogramme(studyprogramme_id)
await MandatoryCourseLabels.create({ studyprogramme_id, label, orderNumber: labels.length+1 })
}
const destroy = (studyprogramme_id, { id }) => {
return MandatoryCourseLabels.destroy({ where: { studyprogramme_id, id } })
}
const move = async (studyprogramme_id, { id }, direction) => {
const labels = await labelsByStudyprogramme(studyprogramme_id).map(e => e.get())
const orderedlabels = _.sortBy(labels, ['orderNumber'])
const indexToMove = orderedlabels.findIndex(e => e.id === id)
const indexToSwap = direction === 'up' ? indexToMove-1 : indexToMove+1
if (indexToSwap < 0 || indexToSwap >= orderedlabels.length) return null;
[orderedlabels[indexToMove], orderedlabels[indexToSwap]] = [orderedlabels[indexToSwap], orderedlabels[indexToMove]]
const newOrder = orderedlabels.map((e, index) => ({ ...e, orderNumber: index }))
await sequelize.transaction(async transaction => {
for (let e of newOrder) {
await MandatoryCourseLabels.update(
{orderNumber: e.orderNumber}, { where: { studyprogramme_id, id: e.id }, transaction }
)
}
})
}
const find = (studyprogramme_id, { id }) => {
return MandatoryCourseLabels.findOne({
attributes: ['id', 'label', 'orderNumber'],
where: {
studyprogramme_id: {
[Op.eq]: studyprogramme_id,
},
id: {
[Op.eq]: id,
}
}
})
}
const labelsByStudyprogramme = (studyProgrammeId) => {
return MandatoryCourseLabels.findAll({
attributes: ['id', 'label', 'orderNumber'],
where: {
studyprogramme_id: {
[Op.eq]: studyProgrammeId
}
}
})
}
module.exports = {
labelsByStudyprogramme,
create,
destroy,
move,
find
}
@@ -1,24 +1,31 @@
const Sequelize = require('sequelize')
const { Course, MandatoryCourse } = require('../models')
const { Course, MandatoryCourse, MandatoryCourseLabels } = require('../models')
const { Op } = Sequelize
const byStudyprogramme = (studyProgrammeId) => {
const courses = MandatoryCourse.findAll({
attributes: ['course_code', 'label'],
include: {
model: Course,
attributes: ['name'],
},
attributes: ['course_code'],
include: [
{
model: Course,
attributes: ['name'],
},
{
model: MandatoryCourseLabels,
attributes: ['id', 'label', 'orderNumber'],
}
],
where: {
studyprogramme_id: {
[Op.eq]: studyProgrammeId
}
}
})
return courses.map(course => ({
name: course.course.name,
code: course.course_code,
label: course.label
label: course.mandatory_course_label
}))
}
@@ -32,10 +39,16 @@ const create = (studyProgrammeId, code) => {
const find = (studyProgrammeId, code) => {
return MandatoryCourse.findOne({
attributes: ['course_code', 'label'],
include: {
model: Course,
attributes: ['name'],
},
include: [
{
model: Course,
attributes: ['name'],
},
{
model: MandatoryCourseLabels,
attributes: ['id', 'label', 'orderNumber'],
}
],
where: {
studyprogramme_id: {
[Op.eq]: studyProgrammeId
@@ -56,11 +69,10 @@ const remove = (studyProgrammeId, code) => {
})
}
const updateLabel = (studyProgrammeId, code, label) => {
if (label != null) label = label.trim()
const updateLabel = (studyProgrammeId, code, { id }) => {
return MandatoryCourse.update(
{
label: label
label: id
},
{
where: {
@@ -3,7 +3,8 @@ const moment = require('moment')
const { orderBy } = require('lodash')
const {
Student, Credit, Course, sequelize, Studyright, StudyrightExtent, ElementDetails,
Discipline, CourseType, SemesterEnrollment, Semester, Transfers, StudyrightElement
Discipline, CourseType, SemesterEnrollment, Semester, Transfers, StudyrightElement,
Tag, TagStudent
} = require('../models')
const { getMainCodesMap, byName } = require('./courses')
const { CourseStatsCounter } = require('./course_stats_counter')
@@ -24,8 +25,9 @@ const formatStudentForPopulationStatistics = ({
firstnames, lastname, studentnumber, dateofuniversityenrollment, creditcount,
matriculationexamination, credits, abbreviatedname, email, studyrights,
semester_enrollments, transfers, updatedAt, createdAt, gender_code,
gender_fi, gender_sv, gender_en
gender_fi, gender_sv, gender_en, tag_students
}, startDate, endDate) => {
const toCourse = ({ grade, attainment_date, credits, course, credittypecode, isStudyModule }) => {
course = course.get()
@@ -97,7 +99,7 @@ const formatStudentForPopulationStatistics = ({
email,
semesterenrollments,
updatedAt: updatedAt || createdAt,
tags: [],
tags: tag_students || [],
studyrightStart: startDate,
starting: moment(started).isBetween(startDate, endDate, null, '[]')
}
@@ -136,7 +138,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
const creditsOfStudent = ['320001', 'MH30_001'].includes(studyright[0]) ?
creditsOfStudentLaakis : creditsOfStudentOther
const students = await Student.findAll({
attributes: ['firstnames', 'lastname', 'studentnumber',
'dateofuniversityenrollment', 'creditcount', 'matriculationexamination',
@@ -210,7 +212,18 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
}
}
}
},
{
model: TagStudent,
attributes: ['id'],
include: [
{
model: Tag,
attributes: ['tag_id','tagname']
}
],
}
],
where: {
studentnumber: {
@@ -352,18 +365,18 @@ const formatStudentsForApi = async (students, startDate, endDate, { studyRights
stats.students.push(formatStudentForPopulationStatistics(student, startDate, endDate))
return stats
}, {
students: [],
extents: {},
semesters: {},
transfers: {
targets: {},
sources: {}
},
studyrights: {
degrees: [],
programmes: []
}
})
students: [],
extents: {},
semesters: {},
transfers: {
targets: {},
sources: {}
},
studyrights: {
degrees: [],
programmes: []
}
})
const transferredStudyright = (s) => {
const studyright = s.studyrights.find(s => s.studyrightElements
@@ -21,6 +21,65 @@ const formatCredit = credit => {
return { id, year, credits }
}
const getTransferredCredits = (provider, since) => Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false
},
include: {
model: Provider,
attributes: [],
required: true,
where: {
providercode: provider
}
}
},
where: {
credittypecode: {
[Op.eq]: [9]
},
attainment_date: {
[Op.gte]: since
}
}
})
const getCreditsForMajors = (provider, since, studentnumbers) => Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false
},
include: {
model: Provider,
attributes: [],
required: true,
where: {
providercode: provider
}
}
},
where: {
credittypecode: {
[Op.notIn]: [10, 9]
},
attainment_date: {
[Op.gte]: since
},
student_studentnumber: {
[Op.in]: studentnumbers
},
}
})
const getCreditsForProvider = (provider, since) => Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date'],
include: {
@@ -98,26 +157,10 @@ const graduatedStatsFromStudyrights = studyrights => {
stats[year] = {
graduated: graduated + 1,
timesToGraduation: stats[year] ?
[...stats[year].timesToGraduation, timeToGraduation || 0] : [ timeToGraduation || 0]
[...stats[year].timesToGraduation, timeToGraduation || 0] : [timeToGraduation || 0]
}
graduationTimes = [...graduationTimes, timeToGraduation || 0]
})
const median = (values) => {
if (values.length === 0) return 0
values.sort((a, b) => a - b)
var half = Math.floor(values.length / 2)
if (values.length % 2)
return values[half]
return (values[half - 1] + values[half]) / 2.0
}
Object.keys(stats).forEach(year => {
stats[year].medianGraduationTime = median(stats[year].timesToGraduation)
})
stats['medianGraduationTime'] = median(graduationTimes)
return stats
}
@@ -171,32 +214,40 @@ const thesisProductivityForStudytrack = async code => {
return thesisProductivityFromCredits(credits)
}
const combineStatistics = (creditStats, studyrightStats, thesisStats) => {
const combineStatistics = (creditStats, studyrightStats, thesisStats, creditsForMajors, transferredCredits) => {
const stats = { ...creditStats }
Object.keys(stats).forEach(year => {
const thesis = thesisStats[year] || {}
stats[year].graduated = studyrightStats[year] ? studyrightStats[year].graduated : 0
stats[year].medianGraduationTime = studyrightStats[year] ? studyrightStats[year].medianGraduationTime : 0
// stats[year].medianGraduationTime = studyrightStats[year] ? studyrightStats[year].medianGraduationTime : 0
stats[year].bThesis = thesis.bThesis || 0
stats[year].mThesis = thesis.mThesis || 0
stats[year].creditsForMajors = creditsForMajors[year] || 0
stats[year].transferredCredits = transferredCredits[year] || 0
})
return Object.values(stats)
}
const productivityStatsForStudytrack = async (studytrack, since) => {
const providercode = studytrackToProviderCode(studytrack)
const year = new Date(since).getFullYear()
const startDate = `${year}-${semesterStart['FALL']}`
const endDate = `${moment(since, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`
const studentnumbers = await studentnumbersWithAllStudyrightElements([studytrack], startDate, endDate, false, false)
const promises = [
graduatedStatsForStudytrack(studytrack, since),
productivityStatsForProvider(providercode, since),
thesisProductivityForStudytrack(studytrack)
thesisProductivityForStudytrack(studytrack),
productivityCreditsFromStudyprogrammeStudents(studytrack, since, studentnumbers),
transferredCreditsForProductivity(studytrack, since)
]
const [studyrightStats, creditStats, thesisStats] = await Promise.all(
const [studyrightStats, creditStats, thesisStats, creditsForMajors, transferredCredits] = await Promise.all(
promises
)
return {
id: studytrack,
status: null,
data: combineStatistics(creditStats, studyrightStats, thesisStats)
data: combineStatistics(creditStats, studyrightStats, thesisStats, creditsForMajors, transferredCredits)
}
}
@@ -341,13 +392,78 @@ const tranferredToStudyprogram = async (studentnumbers, startDate, studytrack, e
})
}
const productivityStats = async (studentnumbers, startDate, studytrack, endDate) => {
return Promise.all([creditsAfter(studentnumbers, startDate),
const endedStudyright = async (studentnumbers, startDate, studytrack, endDate) => {
const enddate = new Date() < new Date(endDate) ? new Date() : new Date(endDate)
return Studyright.findAndCountAll({
include: {
include: {
model: ElementDetails,
where: {
type: {
[Op.eq]: 20
}
}
},
model: StudyrightElement,
required: true,
where: {
code: {
[Op.eq]: studytrack
},
enddate: {
[Op.gte]: new Date(startDate),
[Op.lt]: enddate
}
}
},
where: {
student_studentnumber: {
[Op.in]: studentnumbers
},
graduated: {
[Op.eq]: 0
},
extentcode: {
[Op.eq]: 5
}
}
})
}
const formatCreditsForProductivity = (credits) => {
return credits.map(formatCredit).reduce(function (acc, curr) {
var key = curr['year']
if (!acc[key]) {
acc[key] = []
}
acc[key] = Number(acc[key]) + Number(curr.credits)
return acc
}, {})
}
const productivityCreditsFromStudyprogrammeStudents = async (studytrack, startDate, studentnumbers) => {
const providercode = studytrackToProviderCode(studytrack)
const credits = await getCreditsForMajors(providercode, startDate, studentnumbers)
const formattedStudentCredits = formatCreditsForProductivity(credits)
return formattedStudentCredits
}
const transferredCreditsForProductivity = async (studytrack, since) => {
const providercode = studytrackToProviderCode(studytrack)
const credits = await getTransferredCredits(providercode, since)
const formattedCredits = formatCreditsForProductivity(credits)
return formattedCredits
}
const statsForClass = async (studentnumbers, startDate, studytrack, endDate) => {
return Promise.all([
creditsAfter(studentnumbers, startDate),
graduationsFromClass(studentnumbers, studytrack),
thesesFromClass(studentnumbers, startDate, studytrack),
gendersFromClass(studentnumbers),
countriesFromClass(studentnumbers),
tranferredToStudyprogram(studentnumbers, startDate, studytrack, endDate)
tranferredToStudyprogram(studentnumbers, startDate, studytrack, endDate),
endedStudyright(studentnumbers, startDate, studytrack, endDate)
])
}
@@ -380,16 +496,33 @@ const throughputStatsForStudytrack = async (studytrack, since) => {
const years = getYears(since)
// studyprogramme starts with K if bachelors and M if masters
const graduationTimeLimit = studytrack[0] === 'K' ? 36 : 24
const median = (values) => {
if (values.length === 0) return 0
values.sort((a, b) => a - b)
var half = Math.floor(values.length / 2)
if (values.length % 2)
return values[half]
return (values[half - 1] + values[half]) / 2.0
}
let allGraduationTimes = []
const arr = await Promise.all(years.map(async year => {
const startDate = `${year}-${semesterStart['FALL']}`
const endDate = `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`
const studentnumbers = await studentnumbersWithAllStudyrightElements([studytrack], startDate, endDate, false, false)
const [credits, graduated, theses, genders, countries, transferred] =
await productivityStats(studentnumbers, startDate, studytrack, endDate)
const creditsForStudyprogramme =
await productivityCreditsFromStudyprogrammeStudents(studytrack, startDate, studentnumbers)
const [credits, graduated, theses, genders, countries, transferredTo, endedStudyright] =
await statsForClass(studentnumbers, startDate, studytrack, endDate)
//console.log(year)
//console.log(transferredFrom.rows.map(r => r.get({ plain: true })))
// theres so much shit in the data that transefferFrom doesnt rly mean anything
delete genders[null]
delete countries[null]
const creditValues = credits.reduce((acc, curr) => {
acc.mte30 = curr >= 30 ? acc.mte30 + 1 : acc.mte30
acc.mte60 = curr >= 60 ? acc.mte60 + 1 : acc.mte60
@@ -411,26 +544,33 @@ const throughputStatsForStudytrack = async (studytrack, since) => {
totals.countries[countryKey] + Number(countries[countryKey]) :
Number(countries[countryKey])
})
const inTargetTime = graduated.filter(g =>
moment(g.enddate).diff(g.startstududate, 'months') <= graduationTimeLimit).length
const graduationTimes = graduated.map(g => moment(g.enddate).diff(g.studystartdate, 'months'))
const inTargetTime = graduationTimes.filter(time =>
time <= graduationTimeLimit).length
allGraduationTimes = [...allGraduationTimes, ...graduationTimes]
totals.thesisM = theses.MASTER ? totals.thesisM + theses.MASTER : totals.thesisM
totals.thesisB = theses.BACHELOR ? totals.thesisB + theses.BACHELOR : totals.thesisB
totals.students = totals.students + credits.length
totals.graduated = totals.graduated + graduated.length,
totals.inTargetTime = totals.inTargetTime + inTargetTime,
totals.transferred = totals.transferred + transferred.count
totals.ended = totals.ended + endedStudyright.count,
totals.medianGraduationTime = median(allGraduationTimes)
totals.inTargetTime = totals.inTargetTime + inTargetTime
totals.transferred = totals.transferred + transferredTo.count
return {
year: `${year}-${year + 1}`,
credits: credits.map(cr => cr === null ? 0 : cr),
creditsForStudyprogramme: creditsForStudyprogramme,
graduated: graduated.length,
medianGraduationTime: median(graduationTimes),
inTargetTime,
thesisM: theses.MASTER || 0,
thesisB: theses.BACHELOR || 0,
genders,
countries,
creditValues,
transferred: transferred.count
transferred: transferredTo.count,
ended: endedStudyright.count
}
}))
@@ -125,8 +125,8 @@ test('graduatedStatsFromStudyrights calculates stats correctly', () => {
]
const stats = graduatedStatsFromStudyrights(studyrights)
expect(stats).toMatchObject({
2015: { graduated: 2., medianGraduationTime: 0, timesToGraduation: [ 0, 0 ]},
2014: { graduated: 1., medianGraduationTime: 0, timesToGraduation: [ 0 ]}
2015: { graduated: 2. },
2014: { graduated: 1. }
})
})
@@ -137,21 +137,31 @@ test('combineStatistics returns correctly formatted array', () => {
2014: { year: 2014, credits: 20 }
}
const studyrightStats = {
2015: { graduated: 2, medianGraduationTime: 1.5, timesToGraduation: [1, 2] },
2016: { graduated: 1, medianGraduationTime: 0, timesToGraduation: [ 0 ] }
2015: { graduated: 2 },
2016: { graduated: 1 }
}
const thesisStats = {
2014: { mThesis: 1 },
2015: { mThesis: 2, bThesis: 1 }
}
const stats = combineStatistics(creditStats, studyrightStats, thesisStats)
const creditsForMajors = {
2014: 10,
2015: 22
}
const transferredCredits = {
2014: 2,
2015: 4
}
const stats = combineStatistics(creditStats, studyrightStats, thesisStats, creditsForMajors, transferredCredits)
expect(stats).toContainEqual({
year: 2015,
mThesis: 2,
bThesis: 1,
credits: 40,
medianGraduationTime: 1.5,
graduated: 2
graduated: 2,
creditsForMajors: 22,
transferredCredits: 4
})
expect(stats).toContainEqual({
year: 2014,
@@ -159,7 +169,8 @@ test('combineStatistics returns correctly formatted array', () => {
bThesis: 0,
credits: 20,
graduated: 0,
medianGraduationTime: 0
creditsForMajors: 10,
transferredCredits: 2
})
expect(stats).toContainEqual({
year: 2016,
@@ -167,7 +178,8 @@ test('combineStatistics returns correctly formatted array', () => {
bThesis: 0,
credits: 5,
graduated: 1,
medianGraduationTime: 0
creditsForMajors: 0,
transferredCredits: 0
})
})
@@ -179,7 +191,8 @@ test('productivityStatsForStudytrack integrates', async () => {
bThesis: 0,
mThesis: 1,
credits: 40,
medianGraduationTime: 0
creditsForMajors: 0,
transferredCredits: 0
})
expect(stats.data).toContainEqual({
year: 2016,
@@ -187,7 +200,8 @@ test('productivityStatsForStudytrack integrates', async () => {
mThesis: 0,
bThesis: 0,
credits: 5,
medianGraduationTime: 0
creditsForMajors: 0,
transferredCredits: 0
})
})
@@ -0,0 +1,37 @@
const Sequelize = require('sequelize')
const { Tag } = require('../models')
const Op = Sequelize.Op
const findTags = () => Tag.findAll({})
const findTagsByStudytrack = (studytrack) => {
return Tag.findAll({
where: {
studytrack: {
[Op.eq]: studytrack
}
}
})
}
const createNewTag = async (tag) => {
return Tag.create(tag)
}
const deleteTag = async (tag) => {
return Tag.destroy({
where: {
tag_id: {
[Op.eq]: tag.tag_id
}
}
})
}
module.exports = {
createNewTag,
findTags,
findTagsByStudytrack,
deleteTag
}
@@ -0,0 +1,52 @@
const Sequelize = require('sequelize')
const { TagStudent, Tag } = require('../models')
const Op = Sequelize.Op
const getStudentTags = () => TagStudent.findAll({})
const getStudentTagsByStudytrack = (studytrack) => {
return TagStudent.findAll({
include: {
model: Tag,
attributes: ['tag_id', 'tagname'],
where: {
studytrack: {
[Op.eq]: studytrack
}
}
}
})
}
const getStudentTagsByStudentnumber = async (studentnumber) => {
return TagStudent.findAll({
where: {
studentnumber: {
[Op.eq]: studentnumber
}
}
})
}
const createStudentTag = async (tag) => {
return TagStudent.create(tag)
}
const deleteStudentTag = async (id) => {
return TagStudent.destroy({
where: {
id: {
[Op.eq]: id
}
}
})
}
module.exports = {
getStudentTags,
getStudentTagsByStudentnumber,
getStudentTagsByStudytrack,
createStudentTag,
deleteStudentTag
}
@@ -22,7 +22,7 @@ const findAllEnabled = async () => {
const login = async (uid, full_name, hyGroups, affiliations, email) => {
const response = await client.post('/login', {
uid, full_name, hyGroups, affiliations, email,
uid, full_name, hyGroups, affiliations, email,
})
return response.data
}
@@ -83,6 +83,11 @@ const removeElementDetails = async (uid, codes) => {
return response.data.user
}
const setFaculties = async (uid, faculties) => {
const response = await client.post('/set_faculties', { uid, faculties })
return response.data.user
}
const getUnitsFromElementDetails = async username => {
const url = `/user/elementdetails/${username}`
const response = await client.get(url)
@@ -116,5 +121,6 @@ module.exports = {
modifyAccess,
getAccessGroups,
getUnitsFromElementDetails,
getRolesFor
getRolesFor,
setFaculties
}
@@ -0,0 +1,57 @@
const conf = require('../../conf-backend')
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction(async transaction => {
await queryInterface.createTable(
'mandatory_course_labels',
{
studyprogramme_id: {
type: Sequelize.STRING
},
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true
},
label: {
type: Sequelize.STRING
},
orderNumber: {
type: Sequelize.INTEGER
},
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
}
},
{ transaction }
)
await queryInterface.addIndex('mandatory_course_labels', ['studyprogramme_id'], { transaction })
// Temporarily hardcode schema here since sequelize has issues:
// https://github.com/sequelize/sequelize/issues/10875
await queryInterface.removeColumn({ tableName: 'mandatory_courses', schema: conf.DB_SCHEMA_KONE },
'label', { transaction, }
)
await queryInterface.addColumn(
{ tableName: 'mandatory_courses', schema: 'kone_data' },
'label',
{
type: Sequelize.BIGINT,
references: {
model: 'mandatory_course_labels',
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
{ transaction }
)
})
},
down: async () => {
}
}
@@ -0,0 +1,8 @@
module.exports = {
up: async (queryInterface) => {
await queryInterface.dropTable('tag')
await queryInterface.dropTable('tag_student')
},
down: async () => {
}
}
@@ -0,0 +1,26 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('tag', {
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
},
tagname: {
type: Sequelize.STRING,
},
tag_id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true
},
studytrack: {
type: Sequelize.STRING,
primaryKey: true,
}
})
},
down: async () => {
}
}
@@ -0,0 +1,8 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn({ tableName: 'tag', schema: 'kone_data' }, 'tag_id')
await queryInterface.addColumn({ tableName: 'tag', schema: 'kone_data' }, 'tag_id', { type: Sequelize.BIGINT, primaryKey: true, allowNull: false, autoIncrement: true, unique:true })
},
down: async () => {
}
}
@@ -0,0 +1,25 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('tag_student', {
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
},
tag_id: {
type: Sequelize.BIGINT,
primaryKey: true,
reference: {
model: 'tag',
key: 'tag_id'
}
},
studentnumber: {
type: Sequelize.STRING
}
})
},
down: async () => {
}
}
@@ -0,0 +1,17 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn({ tableName: 'tag_student', schema: 'kone_data' }, 'tag_id')
await queryInterface.addColumn({ tableName: 'tag_student', schema: 'kone_data' }, 'id', { type: Sequelize.BIGINT, primaryKey: true, allowNull: false, autoIncrement: true })
await queryInterface.sequelize.query(`ALTER TABLE tag_student ADD PRIMARY KEY (id);`)
await queryInterface.addColumn({ tableName: 'tag_student', schema: 'kone_data' }, 'tag_id', {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'tag',
key: 'tag_id'
},
})
},
down: async () => {
}
}
+88 -45
View File
@@ -18,9 +18,9 @@ const CourseGroup = sequelize.define('course_groups', {
unique: true
}
},
{
schema: conf.DB_SCHEMA_KONE
})
{
schema: conf.DB_SCHEMA_KONE
})
const TeacherCourseGroup = sequelize.define('teacher_course_groups', {
course_group_id: {
@@ -30,9 +30,9 @@ const TeacherCourseGroup = sequelize.define('teacher_course_groups', {
type: Sequelize.STRING,
}
},
{
schema: conf.DB_SCHEMA_KONE
})
{
schema: conf.DB_SCHEMA_KONE
})
const MandatoryCourse = sequelize.define('mandatory_courses', {
course_code: {
@@ -42,12 +42,12 @@ const MandatoryCourse = sequelize.define('mandatory_courses', {
type: Sequelize.STRING
},
label: {
type: Sequelize.STRING
type: Sequelize.BIGINT
}
},
{
schema: conf.DB_SCHEMA_KONE
})
{
schema: conf.DB_SCHEMA_KONE
})
const Filters = sequelize.define('filters',
{
@@ -104,33 +104,69 @@ const UsageStatistic = sequelize.define('usage_statistics', {
},
}, {
timestamps: false,
schema: conf.DB_SCHEMA_KONE
})
timestamps: false,
schema: conf.DB_SCHEMA_KONE
})
const TagStudent = sequelize.define('tag_student',
{
taggedstudents_studentnumber: { type: Sequelize.STRING },
tags_tagname: { type: Sequelize.STRING },
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true
},
studentnumber: {
type: Sequelize.STRING,
},
tag_id: {
type: Sequelize.BIGINT,
},
},
{
tableName: 'tag_student',
timestamps: false,
schema: conf.DB_SCHEMA_KONE
}
)
TagStudent.removeAttribute('id')
const Tag = sequelize.define('tag',
{
tagname: {
primaryKey: true,
type: Sequelize.STRING
},
tag_id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true
},
studytrack: {
primaryKey: true,
type: Sequelize.STRING
}
},
{
tableName: 'tag',
timestamps: false,
schema: conf.DB_SCHEMA_KONE
}
)
const MandatoryCourseLabels = sequelize.define('mandatory_course_labels',
{
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true
},
studyprogramme_id: {
type: Sequelize.STRING,
},
label: {
type: Sequelize.STRING
},
orderNumber: {
type: Sequelize.INTEGER,
},
},
{
schema: conf.DB_SCHEMA_KONE
}
)
@@ -141,10 +177,10 @@ const MigrationKone = sequelize.define('migrationsKone', {
primaryKey: true
}
}, {
tableName: 'migrations',
timestamps: false,
schema: conf.DB_SCHEMA_KONE
})
tableName: 'migrations',
timestamps: false,
schema: conf.DB_SCHEMA_KONE
})
const Student = sequelize.define('student',
{
@@ -463,16 +499,16 @@ const SemesterEnrollment = sequelize.define('semester_enrollment', {
type: Sequelize.DATE
}
}, {
indexes: [
{
fields: ['semestercode', 'studentnumber'],
unique: true
}, {
fields: ['studentnumber'],
name: 'semester_enrollment_studentnumber'
}
]
})
indexes: [
{
fields: ['semestercode', 'studentnumber'],
unique: true
}, {
fields: ['studentnumber'],
name: 'semester_enrollment_studentnumber'
}
]
})
const Provider = sequelize.define('provider', {
providercode: {
@@ -489,13 +525,13 @@ const Transfers = sequelize.define('transfers', {
type: Sequelize.DATE
}
}, {
indexes: [
{
fields: ['studentnumber'],
name: 'transfers_studentnumber'
}
]
})
indexes: [
{
fields: ['studentnumber'],
name: 'transfers_studentnumber'
}
]
})
const CourseProvider = sequelize.define('course_providers', {}, {
indexes: [
@@ -549,9 +585,9 @@ const Migration = sequelize.define('migrations', {
primaryKey: true
}
}, {
tableName: 'migrations',
timestamps: false
})
tableName: 'migrations',
timestamps: false
})
const CreditTeacher = sequelize.define('credit_teacher', {
credit_id: {
@@ -617,9 +653,15 @@ Credit.belongsTo(Course, { foreignKey: 'course_code' })
Course.hasMany(Credit, { foreignKey: 'course_code' })
MandatoryCourse.belongsTo(Course, { foreignKey: 'course_code' })
MandatoryCourse.belongsTo(MandatoryCourseLabels, { foreignKey: 'label', sourceKey: 'id' })
MandatoryCourseLabels.hasMany(MandatoryCourse, { foreignKey: 'label', sourceKey: 'id' })
Student.hasMany(TagStudent, { foreignKey: 'taggedstudents_studentnumber', sourceKey: 'studentnumber' })
Tag.hasMany(TagStudent, { foreignKey: 'tags_tagname', sourceKey: 'tagname' })
TagStudent.belongsTo(Tag, { foreignKey: 'tag_id', sourceKey: 'tag_id' })
TagStudent.belongsTo(Student, { foreignKey: 'studentnumber' })
Student.hasMany(TagStudent, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
Tag.hasOne(ElementDetails, { foreignKey: 'code', sourceKey: 'studytrack' })
Tag.hasMany(TagStudent, { foreignKey: 'tag_id', sourceKey: 'tag_id' })
Studyright.belongsTo(Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
Student.hasMany(Studyright, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
@@ -723,5 +765,6 @@ module.exports = {
ThesisCourse,
CourseDuplicates,
ThesisTypeEnums,
MandatoryCourseLabels,
ErrorData
}
+4 -1
View File
@@ -1,11 +1,14 @@
FROM node:10.15-alpine
RUN apk add postgresql-client
COPY ./updater_writer /usr/src/app
COPY ./shared/models /usr/src/app/models
COPY ./shared/migrations /usr/src/app/database/migrations
COPY ./shared/migrations_kone /usr/src/app/database/migrations_kone
WORKDIR /usr/src/app
RUN npm ci
COPY ./updater_writer/package.json /usr/src/cache/package.json
COPY ./updater_writer/package-lock.json /usr/src/cache/package-lock.json
@@ -1,26 +1,43 @@
const Sequelize = require('sequelize')
const conf = require('../conf-backend')
console.log('SCHEMA', conf.DB_SCHEMA)
const sequelize = new Sequelize(conf.DB_URL, {
schema: conf.DB_SCHEMA,
searchPath: conf.DB_SCHEMA,
logging: false,
operatorsAliases: false
})
sequelize.query(`SET SESSION search_path to ${conf.DB_SCHEMA}`)
const createSchemaIfNotExists = async schema => {
try {
await sequelize.createSchema(schema)
} catch (e) {
return
}
const sequelizeKone = new Sequelize(conf.DB_URL, {
schema: conf.DB_SCHEMA_KONE,
searchPath: conf.DB_SCHEMA_KONE,
logging: false,
operatorsAliases: false
})
sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
// See https://github.com/sequelize/sequelize/issues/10875
}
const forceSyncDatabase = async () => {
await createSchemaIfNotExists(conf.DB_SCHEMA)
try {
await sequelize.getQueryInterface().createSchema(conf.DB_SCHEMA)
} catch (e) {
// console.log(e)
}
try {
await sequelizeKone.getQueryInterface().createSchema(conf.DB_SCHEMA_KONE)
} catch (e) {
// console.log(e)
}
console.log('OODI SYNC')
await sequelize.sync({ force: true })
console.log('KONE SYNC')
await sequelizeKone.sync({ force: true })
}
module.exports = { sequelize, forceSyncDatabase }
module.exports = {
sequelize,
sequelizeKone,
forceSyncDatabase
}
@@ -0,0 +1,27 @@
const spawn = require('child_process').spawn
const fs = require('fs')
const { sync } = require('./force_sync_database')
const { DB_SCHEMA } = require('../conf-backend')
const dumpDatabase = async () => {
const { DB_URL } = process.env
const dumpOptions = ['-Fc', `-n${DB_SCHEMA}`, `${DB_URL}`]
console.log(dumpOptions)
const dumpProcess = spawn('pg_dump', dumpOptions, { stdio: ['ignore', 'pipe', 'inherit'] })
dumpProcess.on('error', (err) => {
console.log(' dump process error:', err)
})
const writeStream = fs.createWriteStream(`./dumps/oodidb_prod-${new Date().toISOString()}`)
dumpProcess.stdout.pipe(writeStream)
dumpProcess.on('exit', async (code) => {
if (code !== 0) {
throw new Error('pg_dump: Bad exit code (' + code + ')');
} else {
console.log('database dump created, force syncing database')
await sync()
}
})
}
module.exports = { dumpDatabase }
@@ -0,0 +1,17 @@
const { seedAllMigrations } = require('./seed_migrations')
const { forceSyncDatabase } = require('./connection')
const sync = async () => {
try {
await forceSyncDatabase()
await seedAllMigrations()
console.log('Force sync succeeded. ')
process.exit(0)
} catch (e) {
console.log(`Force sync failed: ${e}`)
process.exit(1)
}
}
//sync()
module.exports = { sync }
@@ -1,20 +1,27 @@
const fs = require('fs')
const { Migration } = require('../models/index')
const { Migration, MigrationKone } = require('../models/index')
const DEFAULT_PATH = `${process.cwd()}/database/migrations`
const DEFAULT_PATH_KONE = `${process.cwd()}/database/migrations_kone`
const saveMigrationsToDatabase = async filenames => Promise.all(filenames.map(name => Migration.upsert({ name })))
const saveMigrationsToDatabase = Migration => async filenames =>
Promise.all(filenames.map(name => Migration.upsert({ name })))
const seedMigrations = async (migrationfilepath=DEFAULT_PATH) => {
const filenames = fs.readdirSync(migrationfilepath)
await saveMigrationsToDatabase(filenames)
await saveMigrationsToDatabase(Migration)(filenames)
}
const run = async () => {
const seedMigrationsKone = async (migrationfilepath=DEFAULT_PATH_KONE) => {
const filenames = fs.readdirSync(migrationfilepath)
await saveMigrationsToDatabase(MigrationKone)(filenames)
}
const seedAllMigrations = async () => {
await seedMigrations()
await seedMigrationsKone()
}
module.exports = {
run,
seedMigrations
seedAllMigrations
}
+13 -2
View File
@@ -1,5 +1,6 @@
const stan = require('node-nats-streaming').connect('updaterNATS', process.env.HOSTNAME, process.env.NATS_URI);
const { updateStudent, updateMeta, updateAttainmentMeta } = require('./updater/database_updater')
const { dumpDatabase } = require('./database/dump_database')
const fs = require('fs')
console.log(`STARTING WITH ${process.env.HOSTNAME} as id`)
@@ -13,11 +14,12 @@ stan.on('connect', function () {
const sub = stan.subscribe('UpdateWrite', 'updater.workers', opts)
const attSub = stan.subscribe('UpdateAttainmentDates', opts)
const dumpSub = stan.subscribe('DumpDatabase')
sub.on('message', async (msg) => {
const data = JSON.parse(msg.getData())
if (data.studentInfo) {
await updateStudent(data)
await updateStudent(data, stan)
} else {
await updateMeta(data)
}
@@ -28,5 +30,14 @@ stan.on('connect', function () {
await updateAttainmentMeta()
msg.ack()
})
dumpSub.on('message', async (_) => {
await dumpDatabase()
stan.publish('ScheduleAll', null, (err, guid) => {
if (err) {
console.log(err)
} else {
console.log('calling re-scheduling database')
}
})
})
})
+3 -1
View File
@@ -7,7 +7,9 @@
"test": "NODE_ENV=test jest --runInBand --verbose",
"test_docker": "docker exec -t updater_writer /bin/bash -c 'npm test'",
"start": "node index.js",
"dev": "nodemon index.js"
"dev": "nodemon index.js",
"force_sync": "node -e \"require('./database/force_sync_database').sync()\"",
"dump": "node -e \"require('./database/dump_database').dumpDatabase()\""
},
"author": "",
"license": "ISC",
@@ -51,7 +51,7 @@ const updateStudyRights = (studyRights, transaction) => studyRights.map(async ({
])
})
const updateStudent = async (student) => {
const updateStudent = async (student, stan) => {
const { studentInfo, studyAttainments, semesterEnrollments, studyRights } = student
const transaction = await sequelize.transaction()
try {
@@ -72,6 +72,7 @@ const updateStudent = async (student) => {
console.log('Transaction aborted')
} else if (err.message === 'deadlock detected') {
console.log('Deadlock suicide')
stan.close()
process.exit(1)
} else {
console.log(err.parent)
+67 -23
View File
@@ -117,7 +117,7 @@
},
"minimist": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
},
"ms": {
@@ -5060,7 +5060,7 @@
},
"util": {
"version": "0.10.3",
"resolved": "http://registry.npmjs.org/util/-/util-0.10.3.tgz",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
"integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
"dev": true,
"requires": {
@@ -5115,12 +5115,35 @@
"dev": true
},
"axios": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz",
"integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=",
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz",
"integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==",
"requires": {
"follow-redirects": "^1.3.0",
"is-buffer": "^1.1.5"
"follow-redirects": "1.5.10",
"is-buffer": "^2.0.2"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"requires": {
"debug": "=3.1.0"
}
},
"is-buffer": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
"integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
}
}
},
"axobject-query": {
@@ -6399,7 +6422,7 @@
"dependencies": {
"callsites": {
"version": "2.0.0",
"resolved": "http://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
"integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=",
"dev": true
}
@@ -9542,6 +9565,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.4.1.tgz",
"integrity": "sha512-uxYePVPogtya1ktGnAAXOacnbIuRMB4dkvqeNz2qTtTQsuzSfbDolV+wMMKxAmCx0bLgAKLbBOkjItMbbkR1vg==",
"dev": true,
"requires": {
"debug": "^3.1.0"
},
@@ -9550,6 +9574,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -9665,7 +9690,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -9686,12 +9712,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -9706,17 +9734,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -9833,7 +9864,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -9845,6 +9877,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -9859,6 +9892,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -9866,12 +9900,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -9890,6 +9926,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -9970,7 +10007,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -9982,6 +10020,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -10067,7 +10106,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -10103,6 +10143,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -10122,6 +10163,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -10165,12 +10207,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -16755,7 +16799,7 @@
},
"readable-stream": {
"version": "2.3.6",
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"dev": true,
"requires": {
@@ -16770,7 +16814,7 @@
},
"string_decoder": {
"version": "1.1.1",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
@@ -17979,7 +18023,7 @@
},
"readable-stream": {
"version": "2.3.6",
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
@@ -17993,7 +18037,7 @@
},
"string_decoder": {
"version": "1.1.1",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
+1 -1
View File
@@ -57,7 +57,7 @@
"@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0",
"@sentry/browser": "^4.5.3",
"axios": "^0.18.0",
"axios": "^0.18.1",
"babel-polyfill": "^6.26.0",
"dotenv": "^6.2.0",
"highcharts": "^6.2.0",
@@ -151,9 +151,7 @@ export const handleRequest = store => next => async (action) => {
}
export const logout = async () => {
const stagingPath = '/staging'
const returnUrl = window.location.pathname.includes(stagingPath) ?
`${window.location.origin}${stagingPath}` : window.location.origin
const returnUrl = window.location.origin
const response = await getAxios().delete('/logout', { data: { returnUrl } })
localStorage.removeItem(TOKEN_NAME)
window.location = response.data.logoutUrl
@@ -165,9 +165,8 @@ export const getStudentTotalCredits = student => student.courses
export const getStudentGradeMean = (student) => {
const gradedCourses = student.courses.filter(c => Number(c.grade))
const gradeTotal = gradedCourses.reduce((a, b) => a + b.grade, 0)
const gradeTotal = gradedCourses.reduce((a, b) => a + Number(b.grade), 0)
const mean = gradeTotal / gradedCourses.length || 0
return mean
}
@@ -1,12 +1,12 @@
import React from 'react'
import _ from 'lodash'
import { Segment, Table, Button } from 'semantic-ui-react'
import { Segment, Table } from 'semantic-ui-react'
import { func, arrayOf, shape, string, bool } from 'prop-types'
import { getActiveYears } from '../courseStatisticsUtils'
import './courseTable.css'
const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, controlIcon, mandatory = false }) => {
const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, mandatory = false }) => {
const noContent = courses.length === 0
const sortedCourses = !noContent && _.sortBy(courses, course => course.name)
@@ -17,33 +17,23 @@ const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, co
)
const toCourseRow = course => (
<Table.Row key={course.code}>
<Table.Row style={{ cursor: 'pointer' }} key={course.code} onClick={() => (course.min_attainment_date || mandatory ? onSelectCourse(course) : null)} >
<Table.Cell width={10}>
<div>{course.name}</div>
<div>{getActiveYears(course)}</div>
</Table.Cell>
<Table.Cell content={course.code} />
<Table.Cell>
{course.min_attainment_date || mandatory ?
<Button
basic
className="controlIcon"
icon={controlIcon}
onClick={() => onSelectCourse(course)}
/> : null }
</Table.Cell>
</Table.Row>
)
return (
!hidden && (
<Segment basic style={{ padding: '0' }} >
<Table>
<Table selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell content={title} />
<Table.HeaderCell content="Code" />
<Table.HeaderCell content="Select" />
</Table.Row>
</Table.Header>
<Table.Body>
@@ -19,7 +19,7 @@ const getHeader = (categoryName) => {
}
const CumulativeTable = ({ categoryName, data, onClickCourse }) => (
<Table>
<Table style={{ cursor: 'pointer' }} selectable>
{getHeader(categoryName)}
<Table.Body>
{ data.map(course =>
@@ -1,59 +1,83 @@
/* eslint-disable quotes */
import React from 'react'
import { Grid } from 'semantic-ui-react'
import { bool } from 'prop-types'
import { gradeGraphOptions } from '../../../../constants'
import {
viewModeNames,
graphSeriesTypes,
getDataObject,
getMaxValueOfSeries,
dataSeriesType,
viewModeType,
getGradeSpread,
getThesisGradeSpread,
isThesisSeries
isThesisSeries,
absoluteToRelative
} from './util'
import StackedBarChart from '../../../StackedBarChart'
const getGradeSeries = (series, seriesType) => {
const { name, multiplier } = seriesType
const getGradeSeries = (series) => {
const isGradeSeries = !isThesisSeries(series)
const newSeries = isGradeSeries
? getGradeSpread(series, multiplier)
: getThesisGradeSpread(series, multiplier)
const newSeries = isGradeSeries ?
getGradeSpread(series)
: getThesisGradeSpread(series)
const sumAll = Object.values(newSeries)[0].map((_, idx) => Object.values(newSeries).map(serie => serie[idx]).reduce((a, b) => a + b, 0))
return isGradeSeries
? [
getDataObject(`${name} 0`, newSeries[0], 'a'),
getDataObject(`${name} 1`, newSeries[1], 'b'),
getDataObject(`${name} 2`, newSeries[2], 'c'),
getDataObject(`${name} 3`, newSeries[3], 'd'),
getDataObject(`${name} 4`, newSeries[4], 'e'),
getDataObject(`${name} 5`, newSeries[5], 'f'),
getDataObject(`${name} Hyv.`, newSeries['Hyv.'], 'g')
]
: [
getDataObject(`${name} I`, newSeries.I, 'a'),
getDataObject(`${name} A`, newSeries.A, 'b'),
getDataObject(`${name} NSLA`, newSeries.NSLA, 'c'),
getDataObject(`${name} LUB`, newSeries.LUB, 'd'),
getDataObject(`${name} CL`, newSeries.CL, 'e'),
getDataObject(`${name} MCLA`, newSeries.MCLA, 'f'),
getDataObject(`${name} ECLA`, newSeries.ECLA, 'g'),
getDataObject(`${name} L`, newSeries.L, 'h')
]
? {
absolute: [
getDataObject('0', newSeries[0], 'a'),
getDataObject('1', newSeries[1], 'b'),
getDataObject('2', newSeries[2], 'c'),
getDataObject('3', newSeries[3], 'd'),
getDataObject('4', newSeries[4], 'e'),
getDataObject('5', newSeries[5], 'f'),
getDataObject('Hyv.', newSeries['Hyv.'], 'g')
],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('1', newSeries[1].map(absoluteToRelative(sumAll)), 'b'),
getDataObject('2', newSeries[2].map(absoluteToRelative(sumAll)), 'c'),
getDataObject('3', newSeries[3].map(absoluteToRelative(sumAll)), 'd'),
getDataObject('4', newSeries[4].map(absoluteToRelative(sumAll)), 'e'),
getDataObject('5', newSeries[5].map(absoluteToRelative(sumAll)), 'f'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'g')
]
}
: {
absolute: [
getDataObject(' I', newSeries.I, 'a'),
getDataObject('A', newSeries.A, 'b'),
getDataObject('NSLA', newSeries.NSLA, 'c'),
getDataObject('LUB', newSeries.LUB, 'd'),
getDataObject('CL', newSeries.CL, 'e'),
getDataObject('MCLA', newSeries.MCLA, 'f'),
getDataObject('ECLA', newSeries.ECLA, 'g'),
getDataObject('L', newSeries.L, 'h')
],
relative: [
getDataObject(' I', newSeries.I.map(absoluteToRelative(sumAll)), 'a'),
getDataObject('A', newSeries.A.map(absoluteToRelative(sumAll)), 'b'),
getDataObject('NSLA', newSeries.NSLA.map(absoluteToRelative(sumAll)), 'c'),
getDataObject('LUB', newSeries.LUB.map(absoluteToRelative(sumAll)), 'd'),
getDataObject('CL', newSeries.CL.map(absoluteToRelative(sumAll)), 'e'),
getDataObject('MCLA', newSeries.MCLA.map(absoluteToRelative(sumAll)), 'f'),
getDataObject('ECLA', newSeries.ECLA.map(absoluteToRelative(sumAll)), 'g'),
getDataObject('L', newSeries.L.map(absoluteToRelative(sumAll)), 'h')
]
}
}
const getGradeCumSeriesFromStats = (stats, seriesType) => {
const getGradeCumSeriesFromStats = (stats) => {
const series = stats.flatMap(s => s.cumulative.grades)
return getGradeSeries(series, seriesType)
return getGradeSeries(series)
}
const getGradeStudSeriesFromStats = (stats, seriesType) => {
const getGradeStudSeriesFromStats = (stats) => {
const series = stats.flatMap(s => s.students.grades)
return getGradeSeries(series, seriesType)
return getGradeSeries(series)
}
const Distribution = ({ primary, comparison, viewMode }) => {
const Distribution = ({ primary, comparison, viewMode, isRelative }) => {
const isCumulativeMode = viewMode === viewModeNames.CUMULATIVE
const primaryStats = primary.stats
const statYears = primaryStats.map(year => year.name)
@@ -61,33 +85,49 @@ const Distribution = ({ primary, comparison, viewMode }) => {
const gradeGraphSerieFn = isCumulativeMode ? getGradeCumSeriesFromStats : getGradeStudSeriesFromStats
const gradeGraphSerie = [
...gradeGraphSerieFn(primaryStats, graphSeriesTypes.PRIMARY),
...gradeGraphSerieFn(comparisonStats, graphSeriesTypes.COMPARISON)
]
const gradeGraphSerie = gradeGraphSerieFn(primaryStats)
const comparisonGraphSerie = gradeGraphSerieFn(comparisonStats)
const maxGradeValue = getMaxValueOfSeries(gradeGraphSerie)
const maxGradeValue = isRelative ? 1 : getMaxValueOfSeries(gradeGraphSerie.absolute)
const distributionOptions = gradeGraphOptions(statYears, maxGradeValue)
const primaryDistributionOptions = comparison ? gradeGraphOptions(statYears, maxGradeValue, 'Primary Grades') : gradeGraphOptions(statYears, maxGradeValue, 'Grades')
const comparisonDistributionOptions = gradeGraphOptions(statYears, maxGradeValue, 'Comparison Grades')
return (
<Grid.Column>
<StackedBarChart
options={distributionOptions}
series={gradeGraphSerie}
/>
</Grid.Column>
<>
<Grid.Row>
<Grid.Column>
<StackedBarChart
options={primaryDistributionOptions}
series={isRelative ? gradeGraphSerie.relative : gradeGraphSerie.absolute}
/>
</Grid.Column>
</Grid.Row>
{comparison &&
<Grid.Row>
<Grid.Column>
<StackedBarChart
options={comparisonDistributionOptions}
series={isRelative ? comparisonGraphSerie.relative : comparisonGraphSerie.absolute}
/>
</Grid.Column>
</Grid.Row>
}
</>
)
}
Distribution.propTypes = {
primary: dataSeriesType.isRequired,
comparison: dataSeriesType,
viewMode: viewModeType.isRequired
viewMode: viewModeType.isRequired,
isRelative: bool
}
Distribution.defaultProps = {
comparison: undefined
comparison: undefined,
isRelative: false
}
export default Distribution
@@ -1,39 +1,47 @@
/* eslint-disable quotes */
import React from 'react'
import { Grid } from 'semantic-ui-react'
import { bool } from 'prop-types'
import StackedBarChart from '../../../StackedBarChart'
import { passRateCumGraphOptions, passRateStudGraphOptions } from '../../../../constants'
import {
viewModeNames,
graphSeriesTypes,
getDataObject,
getMaxValueOfSeries,
dataSeriesType,
viewModeType
viewModeType,
absoluteToRelative
} from './util'
const getPassRateCumSeriesFromStats = (stats, seriesType) => {
const { name, multiplier } = seriesType
const getPassRateCumSeriesFromStats = (stats) => {
const all = []
const passed = []
const failed = []
stats.forEach((year) => {
const { passed: p, failed: f } = year.cumulative.categories
all.push((p * multiplier) + (f * multiplier))
passed.push(p * multiplier)
failed.push(f * multiplier)
all.push(p + f)
passed.push(p)
failed.push(f)
})
return [
getDataObject(`${name} all`, all, 'a'),
getDataObject(`${name} passed`, passed, 'b'),
getDataObject(`${name} failed`, failed, 'c')
]
return {
absolute: [
getDataObject(`all`, all, 'a'),
getDataObject(`passed`, passed, 'b'),
getDataObject(`failed`, failed, 'c')
],
relative: [
// eslint-disable-next-line no-unused-vars
getDataObject(`all`, all.map(_ => 1), 'a'),
getDataObject(`passed`, passed.map(absoluteToRelative(all)), 'b'),
getDataObject(`failed`, failed.map(absoluteToRelative(all)), 'c')
]
}
}
const getPassRateStudSeriesFromStats = (stats, seriesType) => {
const { name, multiplier } = seriesType
const getPassRateStudSeriesFromStats = (stats) => {
const all = []
const failedFirst = []
const failedRetry = []
@@ -48,23 +56,33 @@ const getPassRateStudSeriesFromStats = (stats, seriesType) => {
passedRetry: pr
} = year.students.categories
all.push((ff * multiplier) + (fr * multiplier) + (pf * multiplier) + (pr * multiplier))
failedFirst.push(ff * multiplier)
failedRetry.push(fr * multiplier)
passedFirst.push(pf * multiplier)
passedRetry.push(pr * multiplier)
all.push((ff || 0) + (fr || 0) + (pf || 0) + (pr || 0))
failedFirst.push(ff || 0)
failedRetry.push(fr || 0)
passedFirst.push(pf || 0)
passedRetry.push(pr || 0)
})
return [
getDataObject(`${name} all`, all, 'a'),
getDataObject(`${name} passed on first try`, passedFirst, 'b'),
getDataObject(`${name} passed after retry`, passedRetry, 'b'),
getDataObject(`${name} failed on first try`, failedFirst, 'c'),
getDataObject(`${name} failed after retry`, failedRetry, 'c')
]
return {
absolute: [
getDataObject(`all`, all, 'a'),
getDataObject(` passed on first try`, passedFirst, 'b'),
getDataObject(`passed after retry`, passedRetry, 'b'),
getDataObject(`failed on first try`, failedFirst, 'c'),
getDataObject(`failed after retry`, failedRetry, 'c')
],
relative: [
// eslint-disable-next-line no-unused-vars
getDataObject(`all`, all.map(_ => 1), 'a'),
getDataObject(` passed on first try`, passedFirst.map(absoluteToRelative(all)), 'b'),
getDataObject(`passed after retry`, passedRetry.map(absoluteToRelative(all)), 'b'),
getDataObject(`failed on first try`, failedFirst.map(absoluteToRelative(all)), 'c'),
getDataObject(`failed after retry`, failedRetry.map(absoluteToRelative(all)), 'c')
]
}
}
const PassRate = ({ primary, comparison, viewMode }) => {
const PassRate = ({ primary, comparison, viewMode, isRelative = false }) => {
const isCumulativeMode = viewMode === viewModeNames.CUMULATIVE
const primaryStats = primary.stats
@@ -72,33 +90,49 @@ const PassRate = ({ primary, comparison, viewMode }) => {
const comparisonStats = comparison ? comparison.stats : []
const passGraphSerieFn = isCumulativeMode ? getPassRateCumSeriesFromStats : getPassRateStudSeriesFromStats
const passGraphSerie = [
...passGraphSerieFn(primaryStats, graphSeriesTypes.PRIMARY),
...passGraphSerieFn(comparisonStats, graphSeriesTypes.COMPARISON)
]
const passGraphSerie = passGraphSerieFn(primaryStats)
const comparisonGraphSerie = passGraphSerieFn(comparisonStats)
const maxPassRateVal = getMaxValueOfSeries(passGraphSerie)
const maxPassRateVal = isRelative ? 1 : getMaxValueOfSeries(passGraphSerie.absolute)
const graphOptionsFn = isCumulativeMode ? passRateCumGraphOptions : passRateStudGraphOptions
const graphOptions = graphOptionsFn(statYears, maxPassRateVal)
const primaryGraphOptions = comparison ? graphOptionsFn(statYears, maxPassRateVal, 'Primary pass rate chart') : graphOptionsFn(statYears, maxPassRateVal, 'Pass rate chart')
const comparisonGraphOptions = graphOptionsFn(statYears, maxPassRateVal, 'Comparison pass rate chart')
return (
<Grid.Column>
<StackedBarChart
options={graphOptions}
series={passGraphSerie}
/>
</Grid.Column>
<>
<Grid.Row>
<Grid.Column>
<StackedBarChart
options={primaryGraphOptions}
series={isRelative ? passGraphSerie.relative : passGraphSerie.absolute}
/>
</Grid.Column>
</Grid.Row>
{
comparison &&
<Grid.Row>
<Grid.Column>
<StackedBarChart
options={comparisonGraphOptions}
series={isRelative ? comparisonGraphSerie.relative : comparisonGraphSerie.absolute}
/>
</Grid.Column>
</Grid.Row>
}
</>
)
}
PassRate.propTypes = {
primary: dataSeriesType.isRequired,
comparison: dataSeriesType,
viewMode: viewModeType.isRequired
viewMode: viewModeType.isRequired,
isRelative: bool
}
PassRate.defaultProps = {
comparison: undefined
comparison: undefined,
isRelative: false
}
export default PassRate
@@ -6,11 +6,6 @@ export const viewModeNames = {
GRADES: 'Grades'
}
export const graphSeriesTypes = {
PRIMARY: { name: 'primary', multiplier: 1 },
COMPARISON: { name: 'comparison', multiplier: -1 }
}
export const getDataObject = (name, data, stack) => ({ name, data, stack })
export const getMaxValueOfSeries = series => Object.values(series).reduce((acc, cur) => {
@@ -33,7 +28,9 @@ export const isThesisGrades = grades => Object.keys(grades)
export const isThesisSeries = series => series && series.some(s => isThesisGrades(s))
export const getThesisGradeSpread = (series, multiplier = 1) => {
export const absoluteToRelative = all => (p, i) => (p / all[i]) || 0
export const getThesisGradeSpread = (series) => {
const thesisGradeAccumulator = {
L: [],
ECLA: [],
@@ -48,7 +45,7 @@ export const getThesisGradeSpread = (series, multiplier = 1) => {
const currentEntries = Object.entries(cur)
currentEntries.forEach(([k, v]) => {
acc[k].push(v * multiplier)
acc[k].push(v)
})
Object.entries(acc)
@@ -62,7 +59,7 @@ export const getThesisGradeSpread = (series, multiplier = 1) => {
}, { ...thesisGradeAccumulator })
}
export const getGradeSpread = (series, multiplier = 1) => {
export const getGradeSpread = (series) => {
const failedKeys = ['Eisa', 'Hyl.', '0', 'Luop']
const baseAccumalator = {
@@ -82,10 +79,10 @@ export const getGradeSpread = (series, multiplier = 1) => {
if (failedKeys.includes(k)) {
failed += v
} else {
acc[k].push(v * multiplier)
acc[k].push(v)
}
})
acc[0].push(failed * multiplier)
acc[0].push(failed)
Object.entries(acc)
.forEach(([k, v]) => {
if (v.length < i + 1) {
@@ -96,4 +93,3 @@ export const getGradeSpread = (series, multiplier = 1) => {
return acc
}, { ...baseAccumalator })
}
@@ -16,15 +16,17 @@ const paneViewIndex = {
class ResultTabs extends Component {
state = {
activeIndex: paneViewIndex.TABLE,
viewMode: viewModeNames.CUMULATIVE
viewMode: viewModeNames.CUMULATIVE,
isRelative: false
}
getPanes = () => {
const { primary, comparison } = this.props
const { viewMode } = this.state
const { viewMode, isRelative } = this.state
const paneMenuItems = [
{ menuItem: { key: 'Table', icon: 'table', content: 'Table' },
{
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
renderFn: () =>
(<Tables
comparison={comparison}
@@ -32,20 +34,24 @@ class ResultTabs extends Component {
viewMode={viewMode}
/>)
},
{ menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
{
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
renderFn: () =>
(<PassRate
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative}
/>)
},
{ menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
{
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
renderFn: () =>
(<Distribution
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative}
/>)
}
]
@@ -59,9 +65,7 @@ class ResultTabs extends Component {
<Grid.Row className="modeSelectorRow">
{this.renderViewModeSelector()}
</Grid.Row>
<Grid.Row>
{renderFn()}
</Grid.Row>
{renderFn()}
</Grid>
)
}
@@ -105,15 +109,27 @@ class ResultTabs extends Component {
const newMode = isToggleChecked ? viewModeNames.CUMULATIVE : viewModeNames.STUDENT
const toggleId = 'viewModeToggle'
return (
<div className="toggleContainer">
<label className="toggleLabel" htmlFor={toggleId}>{viewModeNames.CUMULATIVE}</label>
<Radio
id={toggleId}
checked={isToggleChecked}
toggle
onChange={() => this.handleModeChange(newMode)}
/>
<label className="toggleLabel" htmlFor={toggleId}>{viewModeNames.STUDENT}</label>
<div style={{ display: 'flex' }}>
<div className="toggleContainer">
<label className="toggleLabel" htmlFor={toggleId}>{viewModeNames.CUMULATIVE}</label>
<Radio
id={toggleId}
checked={isToggleChecked}
toggle
onChange={() => this.handleModeChange(newMode)}
/>
<label className="toggleLabel" htmlFor={toggleId}>{viewModeNames.STUDENT}</label>
</div>
{this.props.comparison &&
<div className="toggleContainer">
<label className="toggleLabel">Absolute</label>
<Radio
toggle
onChange={() => this.setState({ isRelative: !this.state.isRelative })}
/>
<label className="toggleLabel">Relative</label>
</div>
}
</div>
)
}
@@ -4,12 +4,13 @@ import { Form } from 'semantic-ui-react'
const YearFilter = ({ years, fromYear, toYear, handleChange, separate, onToggleCheckbox }) => (
<Fragment>
<Form.Group widths="equal">
<Form.Group widths="equal" inline>
<Form.Dropdown
label="From:"
name="fromYear"
options={years}
selection
inline
placeholder="Select academic year"
onChange={handleChange}
value={fromYear}
@@ -18,6 +19,7 @@ const YearFilter = ({ years, fromYear, toYear, handleChange, separate, onToggleC
label="To:"
name="toYear"
options={years}
inline
selection
placeholder="Select academic year"
onChange={handleChange}
@@ -1,10 +1,12 @@
import React, { Component } from 'react'
import React, { Component, Fragment } from 'react'
import { Segment, Header, Form } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router'
import qs from 'query-string'
import { func, arrayOf, shape, bool } from 'prop-types'
import { getSemesters } from '../../../redux/semesters'
import { clearCourses, findCoursesV2 } from '../../../redux/coursesearch'
import { getCourseStats } from '../../../redux/coursestats'
import { getCourseStats, clearCourseStats } from '../../../redux/coursestats'
import AutoSubmitSearchInput from '../../AutoSubmitSearchInput'
import CourseTable from '../CourseTable'
import { getCourseSearchResults } from '../../../selectors/courses'
@@ -30,8 +32,21 @@ class SearchForm extends Component {
}
componentDidMount() {
this.props.getSemesters()
this.props.clearCourses()
const { location } = this.props
if (location.search) {
this.fetchStatisticsFromUrlParams()
} else {
this.props.getSemesters()
this.props.clearCourses()
}
}
componentDidUpdate(prevProps) {
const { location } = this.props
const queryParamsChanged = prevProps.location.search !== this.props.location.search
if (location.search && queryParamsChanged) {
this.fetchStatisticsFromUrlParams()
}
}
onSelectCourse = (course) => {
@@ -69,7 +84,36 @@ class SearchForm extends Component {
separate
}
await this.props.getCourseStats(params)
// await this.props.getCourseStats(params)
this.pushQueryToUrl(params)
}
fetchStatisticsFromUrlParams() {
const query = this.parseQueryFromUrl()
this.setState({ ...query, selectedcourses: query.courseCodes })
this.props.getCourseStats(query)
}
pushQueryToUrl = (query) => {
const { history } = this.props
const { courseCodes, ...rest } = query
const queryObject = { ...rest, courseCodes: JSON.stringify(courseCodes) }
const searchString = qs.stringify(queryObject)
history.push({ search: searchString })
}
parseQueryFromUrl = () => {
const { location } = this.props
const { courseCodes, fromYear, toYear, separate, ...rest } = qs.parse(location.search)
const query = {
...this.state.INITIAL,
...rest,
courseCodes: JSON.parse(courseCodes),
fromYear: JSON.parse(fromYear),
toYear: JSON.parse(toYear),
separate: JSON.parse(separate)
}
return query
}
fetchCourses = () => {
@@ -103,7 +147,6 @@ class SearchForm extends Component {
coursename,
coursecode
} = this.state
const courses = matchingCourses.filter(c => !selectedcourses[c.code])
const disabled = (!fromYear || Object.keys(selectedcourses).length === 0) || isLoading
@@ -114,45 +157,9 @@ class SearchForm extends Component {
return (
<Segment loading={isLoading}>
<Form>
<Header content="Search parameters" as="h3" />
<YearFilter
fromYear={fromYear}
toYear={toYear}
years={years}
separate={separate}
handleChange={this.handleChange}
onToggleCheckbox={this.onToggleCheckbox}
/>
<CourseTable
title="Selected courses"
hidden={noSelectedCourses}
courses={selected}
onSelectCourse={this.onSelectCourse}
controlIcon="remove"
/>
<Form.Button
type="button"
disabled={disabled}
fluid
basic
positive
content="Fetch statistics"
onClick={this.onSubmitFormClick}
/>
<Header content="Search for courses" />
<div style={{ marginBottom: '15px' }}>
<Form.Group widths="equal">
<Form.Field>
<label>Code:</label>
<AutoSubmitSearchInput
doSearch={this.fetchCourses}
placeholder="Search by entering a course code"
value={coursecode}
onChange={cc => this.setState({ coursecode: cc })}
loading={this.props.coursesLoading}
minSearchLength={0}
/>
</Form.Field>
<Form.Field>
<label>Name:</label>
<AutoSubmitSearchInput
@@ -164,7 +171,48 @@ class SearchForm extends Component {
minSearchLength={0}
/>
</Form.Field>
<Form.Field>
<label>Code:</label>
<AutoSubmitSearchInput
doSearch={this.fetchCourses}
placeholder="Search by entering a course code"
value={coursecode}
onChange={cc => this.setState({ coursecode: cc })}
loading={this.props.coursesLoading}
minSearchLength={0}
/>
</Form.Field>
</Form.Group>
<CourseTable
title="Selected courses"
hidden={noSelectedCourses}
courses={selected}
onSelectCourse={this.onSelectCourse}
controlIcon="remove"
/>
{!noSelectedCourses &&
<Fragment>
<YearFilter
fromYear={fromYear}
toYear={toYear}
years={years}
separate={separate}
handleChange={this.handleChange}
onToggleCheckbox={this.onToggleCheckbox}
/>
<Form.Button
type="button"
disabled={disabled}
fluid
size="huge"
primary
basic
positive
content="Fetch statistics"
onClick={this.onSubmitFormClick}
/>
</Fragment>
}
<CourseTable
hidden={noQueryStrings || isLoading}
courses={courses}
@@ -187,7 +235,9 @@ SearchForm.propTypes = {
matchingCourses: arrayOf(shape({})).isRequired,
years: arrayOf(shape({})).isRequired,
isLoading: bool.isRequired,
coursesLoading: bool.isRequired
coursesLoading: bool.isRequired,
history: shape({}).isRequired,
location: shape({}).isRequired
}
const mapStateToProps = (state) => {
@@ -205,9 +255,10 @@ const mapStateToProps = (state) => {
}
}
export default connect(mapStateToProps, {
export default withRouter(connect(mapStateToProps, {
getSemesters,
getCourseStats,
clearCourses,
findCoursesV2
})(SearchForm)
findCoursesV2,
clearCourseStats
})(SearchForm))
@@ -1,7 +1,8 @@
import React, { Component } from 'react'
import { Header, Segment, Tab } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { bool } from 'prop-types'
import { withRouter } from 'react-router'
import { bool, shape, func } from 'prop-types'
import './courseStatistics.css'
import SearchForm from './SearchForm'
import SingleCourseTab from './SingleCourseTab'
@@ -26,10 +27,10 @@ class CourseStatistics extends Component {
return finishedGet ? { ...INITIAL } : { pending: props.pending }
}
state={ ...INITIAL }
state = { ...INITIAL }
getPanes = () => {
const { singleCourseStats } = this.props
const { singleCourseStats, clearCourseStats: clearaust, history } = this.props
const panes = [
{
menuItem: MENU.SUM,
@@ -44,7 +45,11 @@ class CourseStatistics extends Component {
key: 'query',
content: MENU.QUERY,
icon: 'search',
position: 'right'
position: 'right',
onClick: () => {
history.push('/coursestatistics')
clearaust()
}
},
render: () => <SearchForm />
}
@@ -72,7 +77,7 @@ class CourseStatistics extends Component {
Course Statistics
</Header>
<Segment className="contentSegment" >
{ statsIsEmpty ? <SearchForm /> : (
{statsIsEmpty ? <SearchForm /> : (
<Tab
menu={{ attached: false, borderless: false }}
panes={panes}
@@ -88,7 +93,9 @@ class CourseStatistics extends Component {
CourseStatistics.propTypes = {
statsIsEmpty: bool.isRequired,
singleCourseStats: bool.isRequired
singleCourseStats: bool.isRequired,
history: shape({}).isRequired,
clearCourseStats: func.isRequired
}
const mapStateToProps = ({ courseStats }) => {
@@ -101,4 +108,4 @@ const mapStateToProps = ({ courseStats }) => {
}
}
export default connect(mapStateToProps, { clearCourseStats })(CourseStatistics)
export default withRouter(connect(mapStateToProps, { clearCourseStats })(CourseStatistics))
@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import { Segment, Icon, Button, Form, Dropdown, Popup } from 'semantic-ui-react'
import { func, shape, arrayOf } from 'prop-types'
import _ from 'lodash'
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
import { tagFilter } from '../../populationFilters'
const TagFilter = ({ setPopulationFilterAction, removePopulationFilterAction, filter, samples }) => {
const [options, setOptions] = useState([])
const [selectedTag, setSelectedTag] = useState()
const createOptions = () => {
const tags = samples.map(s => s.tags.map(t => ({ tagname: t.tag.tagname, tag_id: t.tag.tag_id })))
const merge = tags.reduce((a, b) => a.concat(b), [])
const uniqueTags = _.uniqBy(merge, 'tag_id')
const createdOptions = uniqueTags.map(tag => ({ key: tag.tag_id, text: tag.tagname, value: tag.tag_id }))
setOptions(createdOptions)
}
useEffect(() => {
createOptions()
}, [])
const handleFilter = () => {
setPopulationFilterAction(tagFilter({ tag: selectedTag }))
}
const handleChange = (e, { value }) => {
// any better solutions?
const selection = options.filter(tag => tag.value === value)
setSelectedTag(selection[0])
}
const clearFilter = () => {
removePopulationFilterAction(filter.id)
}
if (filter.notSet) {
return (
<Segment>
<Form>
<Popup
trigger={<Icon style={{ float: 'right' }} name="info" />}
/>
<Form.Group inline>
<Form.Field>
<label>Select students that have tag </label>
</Form.Field>
<Form.Field>
<Dropdown
placeholder="select tag"
options={options}
onChange={handleChange}
selectOnBlur={false}
/>
</Form.Field>
<Form.Field>
<Button onClick={handleFilter}>
set filter
</Button>
</Form.Field>
</Form.Group>
</Form>
</Segment>
)
}
return (
<Segment>
Students that have a tag {filter.params.text}
<span style={{ float: 'right' }}>
<Icon name="remove" onClick={clearFilter} />
</span>
</Segment>
)
}
TagFilter.propTypes = {
setPopulationFilterAction: func.isRequired,
removePopulationFilterAction: func.isRequired,
filter: shape({}).isRequired,
samples: arrayOf(shape({})).isRequired
}
export default connect(
null,
{ setPopulationFilterAction: setPopulationFilter, removePopulationFilterAction: removePopulationFilter }
)(TagFilter)
@@ -17,6 +17,7 @@ import ExtentGraduated from './ExtentGraduated'
import SimpleExtentGraduated from './SimpleExtentGraduated'
import Preset from './Preset'
import SexFilter from './SexFilter'
import TagFilter from './TagFilter'
import DisciplineTypes from './DisciplineTypes'
import EnrollmentStatus from './EnrollmentStatus'
import TransferFilter from './TransferFilter'
@@ -29,7 +30,7 @@ import {
clearPopulationFilters, setComplementFilter, savePopulationFilters, setPopulationFilter
} from '../../redux/populationFilters'
import { presetFilter, getFilterFunction } from '../../populationFilters'
import { getTextIn } from '../../common'
import { getTextIn, userIsAdmin } from '../../common'
const componentFor = {
@@ -48,7 +49,8 @@ const componentFor = {
CanceledStudyright,
PriorityStudyright,
TransferToStudyrightFilter,
SimpleExtentGraduated
SimpleExtentGraduated,
TagFilter
}
const advancedFilters = { // Filters that are too hard to use for common folk
@@ -82,11 +84,14 @@ class PopulationFilters extends Component {
presetDescription: '',
presetFilters: [],
advancedUser: false,
modalOpen: false
modalOpen: false,
isAdmin: false
}
async componentDidMount() {
this.initialFilterLoading()
const admin = await userIsAdmin()
this.setState({ isAdmin: admin })
}
initialFilterLoading = async () => {
@@ -216,9 +221,17 @@ class PopulationFilters extends Component {
</div>
{unsetFilters.map(filterName => {//eslint-disable-line
if (componentFor[filterName]) { // THIS IS KINDA HACKED SOLUTION PLS FIX
return React.createElement(componentFor[filterName], {
filter: { notSet: true }, key: filterName, samples: this.props.samples, transfers, extents, allStudyRights
})
// this is awful, shame on who ever wrote this, pls fix
if (this.state.isAdmin) {
return React.createElement(componentFor[filterName], {
filter: { notSet: true }, key: filterName, samples: this.props.samples, transfers, extents, allStudyRights
})
}
if (!this.state.isAdmin && filterName !== 'TagFilter') {
return React.createElement(componentFor[filterName], {
filter: { notSet: true }, key: filterName, samples: this.props.samples, transfers, extents, allStudyRights
})
}
}
else {
return React.createElement(Preset, {
@@ -1,7 +1,7 @@
import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { string, arrayOf, object, func, bool, shape } from 'prop-types'
import { Header, Segment, Button, Icon, Popup, Tab, Grid } from 'semantic-ui-react'
import { Header, Segment, Button, Icon, Popup, Tab, Grid, Checkbox } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import _ from 'lodash'
import XLSX from 'xlsx'
@@ -9,6 +9,7 @@ import { getStudentTotalCredits, copyToClipboard, userRoles, reformatDate, getTe
import { PRIORITYCODE_TEXTS } from '../../constants'
import { toggleStudentListVisibility } from '../../redux/settings'
import { getTagsByStudytrackAction } from '../../redux/tags'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import '../PopulationCourseStats/populationCourseStats.css'
@@ -16,18 +17,35 @@ import SortableTable from '../SortableTable'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
import CheckStudentList from '../CheckStudentList'
import TagStudent from '../TagStudent'
import TagPopulation from '../TagPopulation'
const popupTimeoutLength = 1000
class PopulationStudents extends Component {
state = { containsStudyTracks: false, students: [] }
state = {
containsStudyTracks: false,
students: [],
checked: false,
checkedStudents: []
}
async componentDidMount() {
const roles = await userRoles()
const admin = roles.includes('admin')
await this.props.getTagsByStudytrack(this.props.queryStudyrights[0])
this.setState({ admin, containsStudyTracks: this.containsStudyTracks() })
const initialCheckedStudents = []
this.props.selectedStudents.forEach((sn) => {
const check = {
studentnumber: sn,
checked: false
}
initialCheckedStudents.push(check)
})
this.setState({ checkedStudents: initialCheckedStudents })
}
containsStudyTracks = () => {
@@ -68,6 +86,40 @@ class PopulationStudents extends Component {
clearTimeout(this.timeout)
}
handleAllCheck = () => {
const newCheckedStudents = []
this.props.selectedStudents.forEach((sn) => {
const check = {
studentnumber: sn,
checked: !this.state.checked
}
newCheckedStudents.push(check)
})
this.setState({ checkedStudents: newCheckedStudents })
this.setState({ checked: !this.state.checked })
}
handleSingleCheck = (studentnumber) => {
const checker = this.state.checkedStudents.find(check => check.studentnumber === studentnumber)
const idx = this.state.checkedStudents.indexOf(checker)
const tempArr = [...this.state.checkedStudents]
tempArr.splice(idx, 1, ({ studentnumber: checker.studentnumber, checked: !checker.checked }))
this.setState({ checkedStudents: tempArr })
}
falsifyChecks = () => {
const newCheckedStudents = []
this.props.selectedStudents.forEach((sn) => {
const check = {
studentnumber: sn,
checked: false
}
newCheckedStudents.push(check)
})
this.setState({ checkedStudents: newCheckedStudents })
this.setState({ checked: false })
}
renderStudentTable() {
if (!this.props.showList) {
return null
@@ -93,12 +145,12 @@ class PopulationStudents extends Component {
const priorityText = (studyRights) => {
const codes = this.studyrightCodes(studyRights, 'prioritycode')
return codes.map(code => PRIORITYCODE_TEXTS[code] ? PRIORITYCODE_TEXTS[code] : code).join(', ') // eslint-disable-line
return codes.map(code => (PRIORITYCODE_TEXTS[code] ? PRIORITYCODE_TEXTS[code] : code)).join(', ')
}
const extentCodes = (studyRights) => {
const codes = this.studyrightCodes(studyRights, 'extentcode')
return codes.join(', ') // eslint-disable-line
return codes.join(', ')
}
const studytrack = studyrights => (
@@ -277,37 +329,47 @@ class PopulationStudents extends Component {
}
)
const mandatoryCourseLabels = []
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
const label = e.label || ''
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
acc[label].push(e)
if (e.label) mandatoryCourseLabels.push(e.label)
else mandatoryCourseLabels.push({ id: 'null', label: '' })
return acc
}, {})
const mandatoryCourseLabels = _.orderBy(
Object.keys(labelToMandatoryCourses),
[e => e.length === 0, e => e],
['asc', 'asc']
const sortedlabels = _.orderBy(
_.uniqBy(mandatoryCourseLabels, l => l.label),
[e => e.orderNumber],
['asc']
)
const labelColumns = []
if (sortedlabels.filter(e => e.label !== '').length > 0) {
labelColumns.push(
{
key: 'general',
title: <b>Labels:</b>,
parent: true,
headerProps: { colSpan: nameColumns.length, style: { textAlign: 'right' } }
},
...sortedlabels.map(e => ({
key: e.id,
title: (
<div style={{ overflowX: 'hidden' }}><div style={{ width: 0 }}>{e.label}</div></div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label }
}))
)
}
const mandatoryCourseColumns = [
...nameColumns,
...(mandatoryCourseLabels.reduce((acc, e) => acc + e.length, 0) > 0) ? [{
key: 'general',
title: <b>Labels:</b>,
parent: true,
headerProps: { colSpan: nameColumns.length, style: { textAlign: 'right' } }
}] : [],
...mandatoryCourseLabels.map(e => ({
key: e,
title: (
<div style={{ overflowX: 'hidden' }}><div style={{ width: 0 }}>{e}</div></div>
),
parent: true,
headerProps: { colSpan: labelToMandatoryCourses[e].length, width: labelToMandatoryCourses[e].length, title: e }
})),
..._.flatten(mandatoryCourseLabels.map(e => _.sortBy(
labelToMandatoryCourses[e],
...labelColumns,
..._.flatten(sortedlabels.map(e => _.sortBy(
labelToMandatoryCourses[e.label],
[(m) => {
const res = m.code.match(/\d+/)
return res ? Number(res[0]) : Number.MAX_VALUE
@@ -320,10 +382,30 @@ class PopulationStudents extends Component {
getRowContent: s => (
hasPassedMandatory(s.studentNumber, m.code) ? (<Icon fitted name="check" color="green" />) : (null)
),
child: true
child: true,
childOf: e.label
}))))
]
const tagRows = this.props.selectedStudents
.map(sn => students[sn])
.map((s) => {
const check = this.state.checkedStudents.find(c => c.studentnumber === s.studentNumber) || false
return (
<div key={s.studentNumber}>
<Checkbox
checked={check.checked}
onChange={() => this.handleSingleCheck(s.studentNumber)}
/>
<TagStudent
tags={this.props.tags}
studentnumber={s.studentNumber}
studentstags={s.tags}
studytrack={this.props.queryStudyrights[0]}
/>
</div>)
})
const panes = [
{
menuItem: 'General',
@@ -364,6 +446,8 @@ class PopulationStudents extends Component {
singleLine: true,
textAlign: 'center'
}}
collapsingHeaders
showNames={this.props.showNames}
columns={mandatoryCourseColumns}
data={this.props.selectedStudents.map(sn => students[sn])}
/>
@@ -375,6 +459,22 @@ class PopulationStudents extends Component {
</div>
</Tab.Pane>
)
},
{
menuItem: 'Tags',
render: () => (
<Tab.Pane>
<TagPopulation
allChecker={this.state.checked}
handleAllCheck={() => this.handleAllCheck()}
falsifyChecks={() => this.falsifyChecks()}
tags={this.props.tags}
checkedStudents={this.state.checkedStudents}
studytrack={this.props.queryStudyrights[0]}
/>
{tagRows}
</Tab.Pane>
)
}
]
@@ -409,7 +509,6 @@ class PopulationStudents extends Component {
XLSX.utils.book_append_sheet(workbook, worksheet)
return workbook
}
return (
<Fragment>
<Grid columns="two">
@@ -421,7 +520,7 @@ class PopulationStudents extends Component {
</Button>
</Grid.Column>
</Grid>
<Tab panes={panes} />
{this.state.admin ? (<Tab panes={panes} />) : (<Tab panes={panes.slice(0, 2)} />)}
</Fragment>
)
}
@@ -465,10 +564,12 @@ PopulationStudents.propTypes = {
}).isRequired,
code: string.isRequired
})).isRequired,
mandatoryPassed: shape({}).isRequired
mandatoryPassed: shape({}).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
getTagsByStudytrack: func.isRequired
}
const mapStateToProps = ({ settings, populations, populationCourses, populationMandatoryCourses }) => {
const mapStateToProps = ({ settings, populations, populationCourses, populationMandatoryCourses, tags }) => {
const mandatoryCodes = populationMandatoryCourses.data.map(c => c.code)
let mandatoryPassed = {}
@@ -488,11 +589,12 @@ const mapStateToProps = ({ settings, populations, populationCourses, populationM
language: settings.language,
queryStudyrights: Object.values(populations.query.studyRights),
mandatoryCourses: populationMandatoryCourses.data,
mandatoryPassed
mandatoryPassed,
tags: tags.data
}
}
export default connect(
mapStateToProps,
{ toggleStudentListVisibility }
{ toggleStudentListVisibility, getTagsByStudytrack: getTagsByStudytrackAction }
)(withRouter(PopulationStudents))
@@ -1,4 +1,4 @@
import React, { Component } from 'react'
import React, { Component, Fragment } from 'react'
import { Table } from 'semantic-ui-react'
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
import _ from 'lodash'
@@ -9,57 +9,95 @@ const DIRECTIONS = {
}
class SortableTable extends Component {
state={
direction: this.props.defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC,
selected: this.props.defaultsortkey == null ? this.props.columns[0].key : this.props.defaultsortkey
}
state = {
direction: this.props.defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC,
selected: this.props.defaultsortkey == null ? this.props.columns[0].key : this.props.defaultsortkey,
collapsed: []
}
handleSort = column => () => {
const { selected, direction } = this.state
if (selected === column) {
this.setState({
direction: direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC
})
} else {
this.setState({
selected: column,
direction: DIRECTIONS.DESC
})
handleSort = column => () => {
const { selected, direction } = this.state
if (selected === column) {
this.setState({
direction: direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC
})
} else {
this.setState({
selected: column,
direction: DIRECTIONS.DESC
})
}
}
handleCollapse = column => () => {
const { collapsed } = this.state
if (collapsed.map(c => c.headerProps.title).includes(column.headerProps.title)) {
this.setState({ collapsed: [...collapsed.filter(c => c.headerProps.title !== column.headerProps.title)] })
} else {
this.setState({ collapsed: [...collapsed, column] })
}
}
sortedRows = () => {
const { selected, direction } = this.state
const column = this.props.columns.find(c => c.key === selected)
if (!column) {
return this.props.data
}
const { getRowVal } = column
const sorted = _.sortBy(this.props.data, [getRowVal])
return direction === DIRECTIONS.ASC ? sorted : sorted.reverse()
}
verticalTitle = title => (
// https://stackoverflow.com/a/41396815
<div style={{ writingMode: 'vertical-rl', minWidth: '32px', textAlign: 'left' }}>
{title}
</div>
)
render() {
const calculateSkippedColumns = () => {
const { columns, showNames } = this.props
const { collapsed } = this.state
const defaultColumnCount = showNames ? 6 : 3
if (collapsed) {
const collapsedKeys = collapsed.map(c => c.key)
return collapsedKeys.reduce((acc, curr) => {
const previousCols = columns.filter(c => c.key < curr)
const sumOfPreviousColSpans = previousCols.reduce((a, b) => a + b.headerProps.colSpan, 0)
return [
...acc,
sumOfPreviousColSpans + columns.find(c => c.key === curr).headerProps.colSpan + defaultColumnCount]
}, [])
}
return []
}
const { tableProps, getRowProps, columns, getRowKey, collapsingHeaders } = this.props
const { selected, direction, collapsed } = this.state
const columnsWithCollapsedHeaders = collapsingHeaders ? [...columns.filter(c => (c.headerProps && (!collapsed.map(cell => cell.headerProps.title).includes(c.headerProps.title) && !c.collapsed))), ...this.state.collapsed].sort((a, b) => a.key - b.key) : columns
sortedRows = () => {
const { selected, direction } = this.state
const column = this.props.columns.find(c => c.key === selected)
if (!column) {
return this.props.data
}
const { getRowVal } = column
const sorted = _.sortBy(this.props.data, [getRowVal])
return direction === DIRECTIONS.ASC ? sorted : sorted.reverse()
}
const sortDirection = name => (selected === name ? direction : null)
render() {
const { tableProps, getRowProps, columns, getRowKey } = this.props
const { selected, direction } = this.state
const sortDirection = name => (selected === name ? direction : null)
return (
<Table sortable {...tableProps}>
<Table.Header>
<Table.Row>
{columns.filter(c => !c.child && !(c.title == null)).map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={c.parent ? undefined : this.handleSort(c.key)}
sorted={c.parent ? undefined : sortDirection(c.key)}
{...c.headerProps}
/>
))
}
</Table.Row>
<Table.Row>
{columns.filter(c => c.child && !(c.title == null)).map(c => (
return (
<Table sortable {...tableProps}>
<Table.Header>
<Table.Row>
{columnsWithCollapsedHeaders.filter(c => !c.child && !(c.title == null)).map(c => (
<Table.HeaderCell
key={c.key}
content={c.title}
onClick={c.parent && collapsingHeaders ? this.handleCollapse({ title: this.verticalTitle(<Fragment>{c.headerProps.title}</Fragment>), headerProps: { ...c.headerProps, colSpan: 1, rowSpan: 2 }, key: c.key, collapsed: true, parent: c.parent }) : this.handleSort(c.key)}
sorted={c.parent ? undefined : sortDirection(c.key)}
{...c.headerProps}
/>
))
}
</Table.Row>
<Table.Row>
{columns.filter(c => c.child && !(c.title == null) && !collapsed.map(cell => cell.headerProps.title).includes(c.childOf)).map(c =>
(
<Table.HeaderCell
key={c.key}
content={c.title}
@@ -67,29 +105,40 @@ class SortableTable extends Component {
sorted={sortDirection(c.key)}
{...c.headerProps}
/>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{ this.sortedRows().map(row => (
<Table.Row
key={getRowKey(row)}
{...getRowProps && getRowProps(row)}
>
{columns.filter(c => !c.parent).map(c => (
))
}
</Table.Row>
</Table.Header>
<Table.Body>
{this.sortedRows().map(row => (
<Table.Row
key={getRowKey(row)}
{...getRowProps && getRowProps(row)}
>
{columns.filter(c => !c.parent).map((c, i) => {
if (collapsed.map(cell => cell.headerProps.title).includes(c.childOf)) {
const skippedColumns = calculateSkippedColumns()
if (skippedColumns.includes(i + 1)) {
return <Table.Cell warning />
}
return null
}
return (
<Table.Cell
key={c.key}
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
{...c.cellProps}
{...c.getCellProps && c.getCellProps(row)}
/>
))}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
)
})
}
</Table.Row>
))}
</Table.Body>
</Table>
)
}
}
SortableTable.propTypes = {
@@ -97,7 +146,7 @@ SortableTable.propTypes = {
getRowKey: func.isRequired,
getRowProps: func,
columns: arrayOf(shape({
key: string,
key: string.isRequired,
title: oneOfType([element, string]),
headerProps: shape({}),
getRowVal: func,
@@ -109,14 +158,18 @@ SortableTable.propTypes = {
})).isRequired,
data: arrayOf(shape({})).isRequired,
defaultdescending: bool,
defaultsortkey: string
defaultsortkey: string,
collapsingHeaders: bool,
showNames: bool
}
SortableTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined
}
export default SortableTable
@@ -3,12 +3,16 @@ import { func, shape, string, boolean, arrayOf, integer } from 'prop-types'
import { connect } from 'react-redux'
import { Segment, Table, Icon } from 'semantic-ui-react'
import { isEmpty, sortBy } from 'lodash'
import moment from 'moment'
import qs from 'query-string'
import { withRouter } from 'react-router-dom'
import { getStudent, removeStudentSelection, resetStudent } from '../../redux/students'
import StudentInfoCard from '../StudentInfoCard'
import CreditAccumulationGraph from '../CreditAccumulationGraph'
import SearchResultTable from '../SearchResultTable'
import { byDateDesc, reformatDate, getTextIn } from '../../common'
import { clearCourseStats } from '../../redux/coursestats'
class StudentDetails extends Component {
componentDidMount() {
@@ -26,6 +30,16 @@ class StudentDetails extends Component {
}
}
pushQueryToUrl = (query) => {
const { history } = this.props
const { courseCodes, ...rest } = query
const queryObject = { ...rest, courseCodes: JSON.stringify(courseCodes) }
const searchString = qs.stringify(queryObject)
this.props.clearCourseStats()
history.push('/coursestatistics/')
history.push({ search: searchString })
}
renderCreditsGraph = () => {
const { translate, student } = this.props
return (
@@ -46,7 +60,8 @@ class StudentDetails extends Component {
translate('common.date'),
translate('common.course'),
translate('common.grade'),
translate('common.credits')
translate('common.credits'),
''
]
const courseRows = student.courses.sort(byDateDesc).map((c) => {
const {
@@ -60,11 +75,13 @@ class StudentDetails extends Component {
} else {
icon = <Icon name="circle outline" color="red" />
}
const year = -(moment(new Date('1.1.1950')).diff(new Date(date), 'years')) // :D
return [
reformatDate(date, 'DD.MM.YYYY'),
`${isStudyModuleCredit ? `${getTextIn(course.name, language)} [Study Module]` : getTextIn(course.name, language)} (${course.code})`,
<div>{icon}{grade}</div>,
credits
credits,
<Icon style={{ cursor: 'pointer' }} name="level up" onClick={() => this.pushQueryToUrl({ courseCodes: [course.code], separate: false, fromYear: year - 1, toYear: year + 1 })} />
]
})
return (
@@ -204,6 +221,7 @@ StudentDetails.propTypes = {
removeStudentSelection: func.isRequired,
studentNumber: string,
translate: func.isRequired,
clearCourseStats: func.isRequired,
student: shape({
courses: arrayOf(shape({
course: shape({
@@ -235,6 +253,7 @@ const mapStateToProps = ({ students, settings }) => ({
})
const mapDispatchToProps = dispatch => ({
removeStudentSelection: () => dispatch(removeStudentSelection()),
clearCourseStats: () => dispatch(clearCourseStats()),
resetStudent: () => dispatch(resetStudent()),
getStudent: studentNumber =>
dispatch(getStudent(studentNumber))
@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import _ from 'lodash'
import { Segment, Form, Table, Button, Modal, Header } from 'semantic-ui-react'
import { string, func, shape, arrayOf } from 'prop-types'
import {
GetMandatoryCourseLabels,
AddMandatoryCourseLabel,
DeleteMandatoryCourseLabel,
MoveMandatoryCourseLabel
} from '../../../redux/mandatoryCourseLabels'
const MandatoryCourseLabels = ({
studyProgramme,
labels,
getLabels,
removeLabel,
moveLabel,
addLabel,
getMandatoryCourses
}) => {
useEffect(() => { getLabels(studyProgramme) }, [])
const [newLabel, setNewLabel] = useState('')
return (
<Segment basic>
<Header>Labels</Header>
<Form>
<Form.Group>
<Form.Input placeholder="Label text.." value={newLabel} onChange={(__, action) => setNewLabel(action.value)} />
<Form.Button content="Add" disabled={newLabel.length === 0 || labels.some(e => newLabel.trim() === e.label)} onClick={() => addLabel(studyProgramme, { label: newLabel.trim() }) && setNewLabel('')} />
</Form.Group>
</Form>
<Table compact>
<Table.Body>
{_.orderBy(labels, ['orderNumber']).map((label, index) => (
<Table.Row key={label.id}>
<Table.Cell>{label.label}</Table.Cell>
<Table.Cell collapsing>
<Button
icon="angle up"
disabled={index === 0}
onClick={() => moveLabel(studyProgramme, label, 'up')}
/>
<Button
icon="angle down"
disabled={index === labels.length - 1}
onClick={() => moveLabel(studyProgramme, label, 'down')}
/>
<Modal
trigger={<Button icon="remove" negative />}
header="Really remove label?"
content={`Are you sure you want to remove the label "${
label.label
}"? This removes the label from all courses it is attached to.`}
actions={[
'Cancel',
{
key: 'remove',
content: 'Remove',
positive: true,
onClick: () => removeLabel(studyProgramme, label).then(() => getMandatoryCourses(studyProgramme))
}
]}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
)
}
MandatoryCourseLabels.propTypes = {
studyProgramme: string.isRequired,
getLabels: func.isRequired,
addLabel: func.isRequired,
removeLabel: func.isRequired,
moveLabel: func.isRequired,
labels: arrayOf(shape({ code: string, label: string, name: shape({}) })).isRequired,
getMandatoryCourses: func.isRequired
}
const mapStateToProps = ({ mandatoryCourseLabels }) => ({
labels: mandatoryCourseLabels.data
})
const mapDispatchToProps = dispatch => ({
getLabels: studyProgramme => dispatch(GetMandatoryCourseLabels(studyProgramme)),
addLabel: (studyProgramme, label) => dispatch(AddMandatoryCourseLabel(studyProgramme, label)),
removeLabel: (studyProgramme, label) => dispatch(DeleteMandatoryCourseLabel(studyProgramme, label)),
moveLabel: (studyProgramme, label, direction) => dispatch(MoveMandatoryCourseLabel(studyProgramme, label, direction))
})
export default connect(mapStateToProps, mapDispatchToProps)(MandatoryCourseLabels)
@@ -1,45 +1,78 @@
import React, { useState } from 'react'
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { sortBy } from 'lodash'
import { arrayOf, string, shape, func } from 'prop-types'
import { Button, Form } from 'semantic-ui-react'
import { Button, Dropdown } from 'semantic-ui-react'
import SortableTable from '../../SortableTable'
import { getTextIn } from '../../../common'
import {
GetMandatoryCourseLabels
} from '../../../redux/mandatoryCourseLabels'
const MandatoryCourseTable = ({
studyProgramme,
mandatoryCourses,
language,
deleteMandatoryCourse,
setMandatoryCourseLabel,
labels,
getLabels
}) => {
useEffect(() => { getLabels(studyProgramme) }, [])
const MandatoryCourseTable = ({ studyProgramme, mandatoryCourses, language, deleteMandatoryCourse, setMandatoryCourseLabel }) => {
const deleteButton = code => (
<Button
onClick={() => deleteMandatoryCourse(studyProgramme, code)}
>
<Button onClick={() => deleteMandatoryCourse(studyProgramme, code)}>
Delete
</Button>
)
const initialLabels = mandatoryCourses.reduce((acc, e) => {
acc[e.code] = e.label
return acc
}, {})
const [labels, setLabels] = useState(initialLabels)
const labelInput = code => (
<Form>
<Form.Group>
<Form.Input defaultValue={initialLabels[code]} onChange={e => setLabels({ ...labels, [code]: e.target.value })} />
<Form.Button
disabled={initialLabels[code] === labels[code] || labels[code] == null || (initialLabels[code] == null && labels[code] === '')}
onClick={() => setMandatoryCourseLabel(studyProgramme, code, labels[code])}
>
Save label
</Form.Button>
</Form.Group>
</Form>
)
const idtolabel = labels.reduce((acc, e) => { acc[e.id] = e; return acc }, {})
const options = sortBy(labels, ['label']).map(e => ({ key: e.id, text: e.label, value: e.id }))
const columns = [
{ key: 'name', title: 'Name', getRowVal: course => getTextIn(course.name, language) },
{
key: 'name',
title: 'Name',
getRowVal: course => getTextIn(course.name, language)
},
{ key: 'code', title: 'Code', getRowVal: course => course.code },
{ key: 'label', title: 'Label', getRowVal: course => labels[course.code], getRowContent: course => labelInput(course.code) },
{ key: 'delete', title: 'Delete', getRowVal: course => deleteButton(course.code) }
{
key: 'label',
title: 'Label',
getRowVal: course =>
course.label &&
idtolabel[course.label.id] &&
idtolabel[course.label.id].label,
getRowContent: course => (
<Dropdown
clearable
selectOnBlur={false}
selectOnNavigation={false}
placeholder="select label"
defaultValue={course.label && course.label.id}
disabled={options.length === 0}
options={options}
onChange={(_, { value }) =>
setMandatoryCourseLabel(studyProgramme, course.code, {
id: value === '' ? null : value
})
}
/>
)
},
{
key: 'delete',
title: 'Delete',
getRowVal: course => deleteButton(course.code)
}
]
return <SortableTable columns={columns} data={mandatoryCourses} getRowKey={row => row.code} />
return (
<SortableTable
columns={columns}
data={mandatoryCourses}
getRowKey={row => row.code}
/>
)
}
MandatoryCourseTable.propTypes = {
@@ -47,7 +80,17 @@ MandatoryCourseTable.propTypes = {
studyProgramme: string.isRequired,
deleteMandatoryCourse: func.isRequired,
setMandatoryCourseLabel: func.isRequired,
language: string.isRequired
language: string.isRequired,
labels: arrayOf(shape({ code: string, label: string, name: shape({}) })).isRequired,
getLabels: func.isRequired
}
export default MandatoryCourseTable
const mapStateToProps = ({ mandatoryCourseLabels }) => ({
labels: mandatoryCourseLabels.data
})
const mapDispatchToProps = dispatch => ({
getLabels: studyProgramme => dispatch(GetMandatoryCourseLabels(studyProgramme))
})
export default connect(mapStateToProps, mapDispatchToProps)(MandatoryCourseTable)
@@ -12,13 +12,20 @@ const ProductivityTable = ({ productivity, thesis, loading, error, studyprogramm
if (thesis) {
thesisTypes = thesis.map(t => t.thesisType)
}
const headerList = ['Year', 'Credits', thesisTypes.includes('MASTER') && 'Masters Thesis', thesisTypes.includes('BACHELOR') && 'Bachelors Thesis', 'Graduated', 'Graduation median time'].filter(_ => _)
const headerList = [
'Year',
'Credits',
thesisTypes.includes('MASTER') && 'Masters Thesis',
thesisTypes.includes('BACHELOR') && 'Bachelors Thesis',
'Graduated',
'Credits for studyprogramme majors',
'Credits for non major students',
'Hyväksiluettu (not included in Credits column)'
].filter(_ => _)
const refresh = () => {
callApi('/v2/studyprogrammes/productivity/recalculate', 'get', null, { code: studyprogramme })
.then(() => { dispatchGetProductivity(studyprogramme) })
}
console.log(productivity)
return (
<React.Fragment>
<Header>
@@ -32,7 +39,7 @@ const ProductivityTable = ({ productivity, thesis, loading, error, studyprogramm
productivity.lastUpdated
? moment(productivity.lastUpdated).format('HH:mm:ss MM-DD-YYYY')
: 'unknown'
} ${productivity.status || ''}`}
} ${productivity.status || ''}`}
</Header.Subheader>
)}
</Grid.Column>
@@ -58,21 +65,23 @@ const ProductivityTable = ({ productivity, thesis, loading, error, studyprogramm
<Table.Body>
{productivity && productivity.data
? productivity.data
.sort((year1, year2) => year2.year - year1.year)
.map(year => (
<Table.Row key={year.year}>
<Table.Cell>{year.year}</Table.Cell>
<Table.Cell>{Math.floor(year.credits)}</Table.Cell>
{thesisTypes.includes('BACHELOR') && (
<Table.Cell>{year.bThesis}</Table.Cell>
)}
{thesisTypes.includes('MASTER') && (
<Table.Cell>{year.mThesis}</Table.Cell>
)}
<Table.Cell>{year.graduated}</Table.Cell>
<Table.Cell>{year.medianGraduationTime} months</Table.Cell>
</Table.Row>
))
.sort((year1, year2) => year2.year - year1.year)
.map(year => (
<Table.Row key={year.year}>
<Table.Cell>{year.year}</Table.Cell>
<Table.Cell>{Math.floor(year.credits)}</Table.Cell>
{thesisTypes.includes('BACHELOR') && (
<Table.Cell>{year.bThesis}</Table.Cell>
)}
{thesisTypes.includes('MASTER') && (
<Table.Cell>{year.mThesis}</Table.Cell>
)}
<Table.Cell>{year.graduated}</Table.Cell>
<Table.Cell>{year.creditsForMajors}</Table.Cell>
<Table.Cell>{year.credits - year.creditsForMajors}</Table.Cell>
<Table.Cell>{year.transferredCredits}</Table.Cell>
</Table.Row>
))
: null}
</Table.Body>
</Table>
@@ -89,7 +98,9 @@ ProductivityTable.propTypes = {
credits: number,
mThesis: number,
bThesis: number,
graduated: number
graduated: number,
creditsForMajors: number,
transferredCredits: number
}))
}),
thesis: arrayOf(shape({
@@ -1,7 +1,7 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { func, shape, string } from 'prop-types'
import { Message } from 'semantic-ui-react'
import { Message, Tab } from 'semantic-ui-react'
import {
getMandatoryCourses,
addMandatoryCourse,
@@ -10,6 +10,7 @@ import {
} from '../../../redux/populationMandatoryCourses'
import MandatoryCourseTable from '../MandatoryCourseTable'
import AddMandatoryCourses from '../AddMandatoryCourses'
import MandatoryCourseLabels from '../MandatoryCourseLabels'
class StudyProgrammeMandatoryCourses extends Component {
static propTypes = {
@@ -24,7 +25,9 @@ class StudyProgrammeMandatoryCourses extends Component {
componentDidMount() {
const { studyProgramme } = this.props
if (studyProgramme) this.props.getMandatoryCourses(studyProgramme)
if (studyProgramme) {
this.props.getMandatoryCourses(studyProgramme)
}
}
componentDidUpdate(prevProps) {
@@ -38,6 +41,38 @@ class StudyProgrammeMandatoryCourses extends Component {
const { studyProgramme, mandatoryCourses, language } = this.props
if (!studyProgramme) return null
const panes = [
{
menuItem: 'Mandatory courses',
render: () => (
<Tab.Pane>
<AddMandatoryCourses
addMandatoryCourse={this.props.addMandatoryCourse}
studyProgramme={studyProgramme}
/>
<MandatoryCourseTable
mandatoryCourses={mandatoryCourses.data}
studyProgramme={studyProgramme}
deleteMandatoryCourse={this.props.deleteMandatoryCourse}
setMandatoryCourseLabel={this.props.setMandatoryCourseLabel}
language={language}
/>
</Tab.Pane>
)
},
{
menuItem: 'Group labels',
render: () => (
<Tab.Pane>
<MandatoryCourseLabels
studyProgramme={studyProgramme}
getMandatoryCourses={this.props.getMandatoryCourses}
/>
</Tab.Pane>
)
}
]
return (
<React.Fragment>
<Message
@@ -45,17 +80,7 @@ class StudyProgrammeMandatoryCourses extends Component {
in the 'Study programme' > 'Search by class' results page. The label is used to group the courses in different views.
You can for example use labels '1. year', '2. year' to define groups of 1st year and second year mandatory courses."
/>
<AddMandatoryCourses
addMandatoryCourse={this.props.addMandatoryCourse}
studyProgramme={studyProgramme}
/>
<MandatoryCourseTable
mandatoryCourses={mandatoryCourses.data}
studyProgramme={studyProgramme}
deleteMandatoryCourse={this.props.deleteMandatoryCourse}
setMandatoryCourseLabel={this.props.setMandatoryCourseLabel}
language={language}
/>
<Tab panes={panes} />
</React.Fragment >
)
}
@@ -34,12 +34,12 @@ class StudyProgrammeSelector extends Component {
{
key: 'name',
title: 'name',
getRowContent: prog => getTextIn(prog.name, language)
getRowVal: prog => getTextIn(prog.name, language)
},
{
key: 'code',
title: 'code',
getRowContent: prog => prog.code
getRowVal: prog => prog.code
}
]
if (studyprogrammes == null) {
@@ -51,7 +51,6 @@ class StudyProgrammeSelector extends Component {
getRowKey={programme => programme.code}
getRowProps={programme => ({ onClick: () => this.props.handleSelect(programme.code), style: { cursor: 'pointer' } })}
data={studyprogrammes}
defaultdescending
/>
)
}
@@ -63,7 +62,7 @@ const mapStateToProps = ({ populationDegreesAndProgrammes, settings }) => {
return {
studyprogrammes: programmes ? Object.values(programmes).filter(programme =>
programme.code.includes('_')) : null,
programme.code.includes('_') || programme.code.startsWith('T92')) : null,
language
}
}
@@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { Button, Input, List, Label, Icon, Header } from 'semantic-ui-react'
import { arrayOf, string, shape, func } from 'prop-types'
import { getTagsByStudytrackAction, createTagAction, deleteTagAction } from '../../../redux/tags'
const Tags = ({ createTag, deleteTag, getTagsByStudytrack, tags, studyprogramme }) => {
const [tagname, setTagname] = useState('')
useEffect(() => {
getTagsByStudytrack(studyprogramme)
}, [])
const handleDeleteTag = (event, tag) => {
event.preventDefault()
deleteTag(tag)
getTagsByStudytrack(studyprogramme)
}
const handleSubmit = (event) => {
event.preventDefault()
const newTag = {
tagname,
studytrack: studyprogramme
}
createTag(newTag)
setTagname('')
getTagsByStudytrack(studyprogramme)
}
const handleChange = ({ target }) => {
setTagname(target.value)
}
const rows = tags.map(tag => (
<List.Item key={tag.tag_id}>
<List.Content>
<Label>
{tag.tagname} <Icon name="delete" link onClick={event => handleDeleteTag(event, tag)} />
</Label>
</List.Content>
</List.Item >
))
return (
<List>
<Input onChange={handleChange} value={tagname} />
<Button onClick={handleSubmit}>add new tag</Button>
<Header size="medium">Study programme tags</Header>
{rows}
</List>
)
}
const mapStateToProps = ({ tags }) => ({
tags: tags.data
})
Tags.propTypes = {
getTagsByStudytrack: func.isRequired,
createTag: func.isRequired,
deleteTag: func.isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
studyprogramme: string.isRequired
}
export default withRouter(connect(mapStateToProps, { createTag: createTagAction, deleteTag: deleteTagAction, getTagsByStudytrack: getTagsByStudytrackAction })(Tags))
@@ -1,4 +1,4 @@
import React from 'react'
import React, { Fragment, useEffect, useState } from 'react'
import moment from 'moment'
import { Header, Loader, Table, Button, Grid, Icon } from 'semantic-ui-react'
import { shape, number, arrayOf, bool, string, func } from 'prop-types'
@@ -7,9 +7,17 @@ import { withRouter } from 'react-router'
import { flatten, uniq } from 'lodash'
import { callApi } from '../../../apiConnection'
import { getThroughput } from '../../../redux/throughput'
import { userRoles } from '../../../common'
const ThroughputTable = ({ history, throughput, thesis, loading, error, studyprogramme,
dispatchGetThroughput }) => {
const [roles, setRoles] = useState(undefined)
const setFuckingRoles = async () => {
setRoles(await userRoles())
}
useEffect(() => {
setFuckingRoles()
}, [])
const showPopulationStatistics = (yearLabel) => {
const year = Number(yearLabel.slice(0, 4))
const months = Math.ceil(moment.duration(moment().diff(`${year}-08-01`)).asMonths())
@@ -17,6 +25,10 @@ const ThroughputTable = ({ history, throughput, thesis, loading, error, studypro
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${year}`)
}
if (error) return <h1>Oh no so error {error}</h1>
let GRADUATED_FEATURE_TOGGLED_ON = false
if (roles) {
GRADUATED_FEATURE_TOGGLED_ON = roles.includes('dev')
}
const data = throughput && throughput.data ? throughput.data.filter(year => year.credits.length > 0) : []
const genders = data.length > 0 ? uniq(flatten(data.map(year => Object.keys(year.genders)))) : []
const countries = data.length > 0 && throughput.totals.countries ? uniq(flatten(data.map(year => Object.keys(year.countries)))).sort() : []
@@ -67,7 +79,7 @@ const ThroughputTable = ({ history, throughput, thesis, loading, error, studypro
<Table.HeaderCell colSpan={genders.length + 1}>Students</Table.HeaderCell> :
<Table.HeaderCell rowSpan="2">Students</Table.HeaderCell>
}
<Table.HeaderCell colSpan="2">Graduated</Table.HeaderCell>
<Table.HeaderCell colSpan={GRADUATED_FEATURE_TOGGLED_ON ? '3' : '1'}>Graduated</Table.HeaderCell>
<Table.HeaderCell rowSpan="2">Transferred to this program</Table.HeaderCell>
{
@@ -87,13 +99,18 @@ const ThroughputTable = ({ history, throughput, thesis, loading, error, studypro
{renderGenders ? <Table.HeaderCell content="Total" /> : null}
{genders.map(gender => <Table.HeaderCell key={gender} content={gender} />)}
<Table.HeaderCell >Graduated overall</Table.HeaderCell>
<Table.HeaderCell >Graduated in time</Table.HeaderCell>
{GRADUATED_FEATURE_TOGGLED_ON &&
<Fragment>
<Table.HeaderCell >Graduated in time</Table.HeaderCell>
<Table.HeaderCell >Graduation median time</Table.HeaderCell>
</Fragment>
}
{renderCountries ? countries.map(country => <Table.HeaderCell key={country} content={country} />) : null}
<Table.HeaderCell content=">= 30" />
<Table.HeaderCell content=">= 60" />
<Table.HeaderCell content=">= 90" />
<Table.HeaderCell content=">= 120" />
<Table.HeaderCell content=">= 150" />
<Table.HeaderCell content="≥ 30" />
<Table.HeaderCell content="≥ 60" />
<Table.HeaderCell content="≥ 90" />
<Table.HeaderCell content="≥ 120" />
<Table.HeaderCell content="≥ 150" />
{thesisTypes.includes('MASTER') && (
<Table.HeaderCell content="Master" />
)}
@@ -120,7 +137,12 @@ const ThroughputTable = ({ history, throughput, thesis, loading, error, studypro
</Table.Cell>
))}
<Table.Cell>{year.graduated}</Table.Cell>
<Table.Cell>{year.inTargetTime}</Table.Cell>
{GRADUATED_FEATURE_TOGGLED_ON &&
<Fragment>
<Table.Cell>{year.inTargetTime}</Table.Cell>
<Table.Cell>{year.medianGraduationTime ? `${year.medianGraduationTime} months` : '∞'}</Table.Cell>
</Fragment>
}
<Table.Cell>{year.transferred}</Table.Cell>
{renderCountries ? countries.map(country => (
<Table.Cell key={year.year + country}>
@@ -151,7 +173,12 @@ const ThroughputTable = ({ history, throughput, thesis, loading, error, studypro
</Table.HeaderCell>
))}
<Table.HeaderCell>{throughput.totals.graduated}</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.inTargetTime}</Table.HeaderCell>
{GRADUATED_FEATURE_TOGGLED_ON &&
<Fragment>
<Table.HeaderCell>{throughput.totals.inTargetTime}</Table.HeaderCell>
<Table.HeaderCell>{throughput.totals.medianGraduationTime ? `${throughput.totals.medianGraduationTime} months` : '∞'}</Table.HeaderCell>
</Fragment>
}
<Table.HeaderCell>{throughput.totals.transferred}</Table.HeaderCell>
{renderCountries ? Object.keys(throughput.totals.countries).map(countryKey => (
<Table.HeaderCell key={`${countryKey}total`}>
@@ -11,6 +11,7 @@ import AggregateView from '../CourseGroups/AggregateView'
import ThesisCourses from './ThesisCourses'
import '../PopulationQueryCard/populationQueryCard.css'
import { getRolesWithoutRefreshToken, getRightsWithoutRefreshToken, getTextIn } from '../../common'
import Tags from './Tags'
class StudyProgramme extends Component {
static propTypes = {
@@ -63,6 +64,12 @@ class StudyProgramme extends Component {
menuItem: 'Thesis Courses',
render: () => <ThesisCourses studyprogramme={studyProgrammeId} />
})
if (getRolesWithoutRefreshToken().includes('admin')) {
panes.push({
menuItem: 'Tags',
render: () => <Tags studyprogramme={studyProgrammeId} />
})
}
return panes
}
@@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { Button, Dropdown, Checkbox } from 'semantic-ui-react'
import { arrayOf, string, shape, func, bool } from 'prop-types'
import {
createStudentTagAction,
getStudentTagsByStudytrackAction
} from '../../redux/tagstudent'
const TagPopulation = ({
allChecker,
handleAllCheck,
falsifyChecks,
createStudentTag,
tags,
checkedStudents,
getStudentTagsByStudytrack,
studytrack,
created }) => {
const [options, setOptions] = useState([])
const [selectedValue, setSelected] = useState('')
useEffect(() => {
const createdOptions = tags.map(tag => ({ key: tag.tag_id, text: tag.tagname, value: tag.tag_id }))
setOptions(createdOptions)
}, [])
useEffect(() => {
if (created) {
getStudentTagsByStudytrack(studytrack)
}
}, [created])
const handleChange = (event, { value }) => {
event.preventDefault()
setSelected(value)
}
const handleSubmit = (event) => {
event.preventDefault()
falsifyChecks()
checkedStudents.forEach((student) => {
if (student.checked) {
const tag = {
tag_id: selectedValue,
studentnumber: student.studentnumber
}
createStudentTag(tag)
setSelected('')
}
})
}
return (
<div>
<Checkbox
checked={allChecker}
onChange={handleAllCheck}
/>
<Dropdown
placeholder="Tag"
search
selection
options={options}
onChange={handleChange}
value={selectedValue}
/>
<Button onClick={handleSubmit}>add tag to multiple students</Button>
</div>
)
}
TagPopulation.propTypes = {
createStudentTag: func.isRequired,
getStudentTagsByStudytrack: func.isRequired,
checkedStudents: arrayOf(shape({ studentnumber: string, checked: bool })).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
studytrack: string.isRequired,
created: bool.isRequired,
falsifyChecks: func.isRequired,
allChecker: bool.isRequired,
handleAllCheck: func.isRequired
}
const mapStateToProps = ({ tagstudent }) => ({
created: tagstudent.created
})
export default withRouter(connect(mapStateToProps, { createStudentTag: createStudentTagAction, getStudentTagsByStudytrack: getStudentTagsByStudytrackAction })(TagPopulation))
@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { Dropdown, List, Label, Icon } from 'semantic-ui-react'
import { arrayOf, string, shape, func, bool } from 'prop-types'
import {
createStudentTagAction,
deleteStudentTagAction,
getStudentTagsByStudytrackAction
} from '../../redux/tagstudent'
const TagStudent = ({
createStudentTag,
deleteStudentTag,
getStudentTagsByStudytrack,
studentnumber,
studentstags,
tags,
studytrack,
success,
data }) => {
const [allTags, setTags] = useState([])
const [studentsTagIds, setStudentsTagIds] = useState([])
const [tagOptions, setTagOptions] = useState([])
useEffect(() => {
setTags(tags)
const tagIds = studentstags.map(t => ({ id: t.id, tag_id: t.tag.tag_id }))
setStudentsTagIds(tagIds)
const initialTagOptions = tags.filter(tag => !tagIds.includes(tag.tag_id)).map(tag => ({
key: tag.tag_id,
text: tag.tagname,
value: tag.tag_id
}))
setTagOptions(initialTagOptions)
}, [])
useEffect(() => {
if (success) {
const studentData = data.filter(row => row.studentnumber === studentnumber)
const newTagIds = studentData.map(t => t.tag_id)
const filteredData = allTags.filter(tag => !newTagIds.includes(tag.tag_id)).map(tag => ({
key: tag.tag_id,
text: tag.tagname,
value: tag.tag_id
}))
setStudentsTagIds(studentData)
setTagOptions(filteredData)
}
}, [success])
const handleChange = async (event, { value }) => {
event.preventDefault()
const tag = {
tag_id: value,
studentnumber
}
await createStudentTag(tag)
getStudentTagsByStudytrack(studytrack)
}
const deleteTag = async (event, tag) => {
event.preventDefault()
const removableTag = studentsTagIds.find(t => t.tag_id === tag.tag_id)
await deleteStudentTag(removableTag.id)
getStudentTagsByStudytrack(studytrack)
}
const studentsTags = allTags
.filter(tag => studentsTagIds.map(t => t.tag_id).includes(tag.tag_id))
.map(tag => (
<List.Item key={tag.tag_id} >
<List.Content>
<Label>
{tag.tagname} <Icon name="delete" link onClick={event => deleteTag(event, tag)} />
</Label>
</List.Content>
</List.Item>))
return (
<List horizontal>
<List.Item >
<List.Content>
{studentnumber}
</List.Content>
</List.Item>
{studentsTags}
<List.Item>
<List.Content>
<Dropdown
placeholder="Tag"
search
selection
options={tagOptions}
onChange={handleChange}
/>
</List.Content>
</List.Item>
</List>
)
}
TagStudent.propTypes = {
createStudentTag: func.isRequired,
deleteStudentTag: func.isRequired,
getStudentTagsByStudytrack: func.isRequired,
studentnumber: string.isRequired,
studentstags: arrayOf(shape({ tag: shape({ tagname: string, tag_id: string }), id: string })).isRequired,
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
studytrack: string.isRequired,
success: bool.isRequired,
data: arrayOf(shape({ studentnumber: string, tag_id: string })).isRequired
}
const mapStateToProps = ({ tagstudent }) => ({
success: tagstudent.success,
data: tagstudent.data
})
export default withRouter(connect(mapStateToProps, {
createStudentTag: createStudentTagAction,
deleteStudentTag: deleteStudentTagAction,
getStudentTagsByStudytrack: getStudentTagsByStudytrackAction
})(TagStudent))
@@ -88,7 +88,7 @@ class TeacherLeaderBoard extends Component {
<TeacherStatisticsTable
statistics={statistics}
onClickFn={e => this.props.history.push(`/teachers/${e.target.innerText}`)}
onClickFn={id => this.props.history.push(`/teachers/${id}`)}
/>
</Segment>
</div>
@@ -134,8 +134,8 @@ class TeacherStatistics extends Component {
<Segment>
<TeacherStatisticsTable
statistics={statistics}
onClickFn={e =>
this.props.history.push(`/teachers/${e.target.innerText}`)
onClickFn={id =>
this.props.history.push(`/teachers/${id}`)
}
/>
</Segment>
@@ -1,55 +1,52 @@
import React, { Component } from 'react'
import { Table, Segment } from 'semantic-ui-react'
import { shape, arrayOf, any, string, number } from 'prop-types'
import { shape, arrayOf, any, string, number, func } from 'prop-types'
import _ from 'lodash'
const calculatePassrate = (pass, fail) => (100 * pass) / (pass + fail)
class TeacherStatisticsTable extends Component {
state={
selected: 'credits',
direction: 'descending'
state = {
selected: 'credits',
direction: 'descending'
}
handleSort = column => () => {
const { selected, direction } = this.state
if (selected === column) {
this.setState({
direction: direction === 'ascending' ? 'descending' : 'ascending'
})
} else {
this.setState({
selected: column,
direction: 'descending'
})
}
}
handleSort = column => () => {
const { selected, direction } = this.state
if (selected === column) {
this.setState({
direction: direction === 'ascending' ? 'descending' : 'ascending'
})
} else {
this.setState({
selected: column,
direction: 'descending'
})
}
}
sortStatistics = (statistics) => {
const { selected, direction } = this.state
const formatted = statistics.map(stat => ({
...stat,
passrate: calculatePassrate(stat.passed, stat.failed)
}))
const sorted = _.sortBy(formatted, selected)
return direction === 'ascending' ? sorted : sorted.reverse()
}
sortStatistics = (statistics) => {
const { selected, direction } = this.state
const formatted = statistics.map(stat => ({
...stat,
passrate: calculatePassrate(stat.passed, stat.failed)
}))
const sorted = _.sortBy(formatted, selected)
return direction === 'ascending' ? sorted : sorted.reverse()
}
render() {
const { statistics, onClickFn } = this.props
const { selected, direction } = this.state
render() {
const { statistics } = this.props
const { selected, direction } = this.state
const sortDirection = name => (selected === name ? direction : null)
const sortDirection = name => (selected === name ? direction : null)
return (statistics.length === 0 ? <Segment basic content="No statistics found for the given query." /> :
<Table structured celled textAlign="center" sortable>
return (statistics.length === 0 ?
<Segment basic content="No statistics found for the given query." />
:
<Table structured celled textAlign="center" sortable selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell
content="ID"
onClick={this.handleSort('id')}
sorted={sortDirection('id')}
/>
<Table.HeaderCell
content="Name"
textAlign="left"
@@ -74,21 +71,19 @@ class TeacherStatisticsTable extends Component {
</Table.Row>
</Table.Header>
<Table.Body>
{ this.sortStatistics(statistics).map(({ id, name, credits, passrate, transferred }) => (
<Table.Row key={id}>
<Table.Cell content={id} width={1} onClick={this.props.onClickFn} /> {/* eslint-disable-line*/}
{this.sortStatistics(statistics).map(({ id, name, credits, passrate, transferred }) => (
<Table.Row key={id} onClick={() => onClickFn(id)} style={{ cursor: 'pointer' }} >
<Table.Cell content={name} textAlign="left" />
<Table.Cell content={credits} width={2} />
<Table.Cell content={transferred} width={2} />
<Table.Cell content={`${parseFloat(passrate).toFixed(2)} %`} width={2} />
</Table.Row>
))}
))}
</Table.Body>
</Table>
)
}
)
}
}
TeacherStatisticsTable.propTypes = {
statistics: arrayOf(shape({
id: any,
@@ -97,7 +92,9 @@ TeacherStatisticsTable.propTypes = {
credits: any,
failed: number,
passed: number
})).isRequired
})).isRequired,
onClickFn: func.isRequired
}
export default TeacherStatisticsTable
@@ -7,7 +7,7 @@ import { modifyAccessGroups } from '../../redux/users'
import { getIdWithoutRefreshToken, setToken } from '../../common'
class AccessGroups extends Component {
state={
state = {
selected: this.props.userGroups
}
@@ -1,11 +1,12 @@
import React, { Component } from 'react'
import { Button, Card, Divider, List, Icon, Popup } from 'semantic-ui-react'
import { Button, Card, Divider, List, Icon, Popup, Dropdown, Header } from 'semantic-ui-react'
import { connect } from 'react-redux'
import _ from 'lodash'
import { withRouter } from 'react-router'
import { string, number, shape, bool, arrayOf, func, object } from 'prop-types'
import { getTextIn, getRolesWithoutRefreshToken } from '../../common'
import { removeUserUnits, getAccessGroups } from '../../redux/users'
import { removeUserUnits, getAccessGroups, setFaculties } from '../../redux/users'
import { getFaculties } from '../../redux/faculties'
import { getDegreesAndProgrammesUnfiltered } from '../../redux/populationDegreesAndProgrammesUnfiltered'
import { superLogin } from '../../apiConnection'
import AccessRights from './AccessRights'
@@ -33,6 +34,7 @@ class UserPage extends Component {
this.props.getDegreesAndProgrammesUnfiltered()
await this.props.getAccessGroups()
}
await this.props.getFaculties()
}
getDisabledUnits = (units, enabled) => {
@@ -85,20 +87,11 @@ class UserPage extends Component {
renderUnitList = (elementdetails, user) => {
const { language } = this.props
const nameInLanguage = element => getTextIn(element.name, language)
const byCode = (a, b) => {
const codeA = a.type === 30 && a.associations ? `${a.associations[0]}${nameInLanguage(a)}` : a.code
const codeB = b.type === 30 && b.associations ? `${b.associations[0]}${nameInLanguage(b)}` : b.code
return codeA < codeB ? -1 : 1
}
if (!elementdetails) return null
return (
<List divided>
{elementdetails.sort(byCode).map(element => (
{_.sortBy(elementdetails, 'code').map(element => (
<List.Item key={element.code}>
<List.Content floated="right">
<Button
@@ -120,28 +113,7 @@ class UserPage extends Component {
}
render() {
const { user, associations } = this.props
// ugly trick to add associations to study tracks, should be moved to backend
if (associations.programmes) {
const programmes = user.elementdetails.filter(e => e.type === 20).map(e => e.code)
user.elementdetails = user.elementdetails.map((element) => {
const e = Object.assign(element)
if (associations && (element.type === 30)) {
const studyright = associations.studyTracks[element.code]
if (studyright && studyright.programmes) {
e.associations = _.intersection(
programmes,
Object.keys(studyright.programmes)
)
} else {
e.associations = programmes
}
} else {
e.associations = []
}
return e
})
}
const { user, language } = this.props
return this.props.accessGroups ?
<div>
<Button icon="arrow circle left" content="Back" onClick={this.props.goBack} />
@@ -191,9 +163,19 @@ class UserPage extends Component {
color: 'darkred',
border: '1px'
}}
>everything!
>Admin access!
</p> : null}
{this.renderUnitList(user.elementdetails, user)}
{this.renderUnitList(user.programme, user)}
<Header content="Faculties" />
<Dropdown
placeholder="Select faculties"
fluid
multiple
search
value={this.props.user.faculty.map(f => f.faculty_code)}
options={_.sortBy(this.props.faculties.map(f => ({ key: f.code, text: getTextIn(f.name, language), description: f.code, value: f.code })), ['text'])}
onChange={(__, { value: facultycodes }) => this.props.setFaculties(user.id, facultycodes)}
/>
</Card.Description>
</Card.Content>
</Card>
@@ -213,9 +195,23 @@ UserPage.propTypes = {
code: string,
name: shape({}),
type: number
})),
programme: arrayOf(shape({
code: string,
name: shape({}),
type: number
})),
faculty: arrayOf(shape({
faculty_code: string,
programme: arrayOf(shape({
code: string,
name: shape({}),
type: number
}))
}))
}).isRequired,
removeUserUnits: func.isRequired,
setFaculties: func.isRequired,
language: string.isRequired,
goBack: func.isRequired,
getDegreesAndProgrammesUnfiltered: func.isRequired,
@@ -225,12 +221,15 @@ UserPage.propTypes = {
push: func.isRequired
}).isRequired,
getAccessGroups: func.isRequired,
getFaculties: func.isRequired,
faculties: arrayOf(shape({ code: string, name: shape({}) })).isRequired,
accessGroups: arrayOf(object).isRequired,
isAdmin: bool.isRequired
}
const mapStateToProps = state => ({
language: state.settings.language,
units: state.units.data,
faculties: state.faculties.data,
associations: state.populationDegreesAndProgrammesUnfiltered.data,
pending: !!state.populationDegreesAndProgrammesUnfiltered.pending,
accessGroups: state.users.accessGroupsData || [],
@@ -239,6 +238,8 @@ const mapStateToProps = state => ({
export default connect(mapStateToProps, {
removeUserUnits,
setFaculties,
getDegreesAndProgrammesUnfiltered,
getAccessGroups
getAccessGroups,
getFaculties
})(withRouter(UserPage))
@@ -63,14 +63,14 @@ export const routes = {
usage: { menuRoute: '/usage', route: '/usage', translateId: 'usage', reqRights: ['usage'] },
sandbox: { menuRoute: '/sandbox', route: '/sandbox', translateId: 'sandbox', reqRights: ['dev'] },
oodilearn: { menuRoute: '/oodilearn', route: '/oodilearn', translateId: 'oodilearn', reqRights: ['oodilearn'] },
feedback: { menuRoute: '/feedback', route: '/feedback', translateId: 'feedback', reqRights: ['users'] }
feedback: { menuRoute: '/feedback', route: '/feedback', translateId: 'feedback' }
}
export const hiddenRoutes = {
}
const assumeBasename = () => {
const POSSIBLE_BASENAMES = ['staging', 'testing']
const POSSIBLE_BASENAMES = ['testing']
const haystack = window.location.pathname.split('/')
const needle = haystack.find(path => POSSIBLE_BASENAMES.includes(path))
return needle ? `/${needle}/` : '/'
@@ -86,16 +86,16 @@ export const DEFAULT_LANG = 'en'
export const DISPLAY_DATE_FORMAT = 'DD.MM.YYYY'
export const API_DATE_FORMAT = 'YYYY.MM.DD'
export const TOKEN_NAME = window.location.pathname.includes('/staging') ? 'staging_token' : window.location.pathname.includes('/testing') ? 'testing_token' : 'token' //eslint-disable-line
export const TOKEN_NAME = window.location.pathname.includes('staging') ? 'staging_token' : window.location.pathname.includes('/testing') ? 'testing_token' : 'token' //eslint-disable-line
export const passRateCumGraphOptions = (categories, max) => ({
export const passRateCumGraphOptions = (categories, max, title) => ({
chart: {
type: 'column'
},
colors: [chartblue, green, red],
title: {
text: 'Pass rate chart'
text: title
},
xAxis: {
@@ -119,14 +119,14 @@ export const passRateCumGraphOptions = (categories, max) => ({
}
})
export const passRateStudGraphOptions = (categories, max) => ({
export const passRateStudGraphOptions = (categories, max, title) => ({
chart: {
type: 'column'
},
colors: [chartblue, chartlgreen, chartdarkg, chartlred, chartdarkred],
title: {
text: 'Pass rate chart'
text: title
},
xAxis: {
@@ -150,14 +150,14 @@ export const passRateStudGraphOptions = (categories, max) => ({
}
})
export const gradeGraphOptions = (categories, max) => ({
export const gradeGraphOptions = (categories, max, title) => ({
chart: {
type: 'column'
},
colors: [red, chartblue, chartblue, chartblue, chartblue, chartblue, green, green],
title: {
text: 'Grades'
text: title
},
legend: {
@@ -245,7 +245,6 @@ export const gradeMeanFilter = (params) => {
},
filter: (student) => {
const gradeMeanOfStudent = getStudentGradeMean(student)
if (comparator === 'less') {
return gradeMean > gradeMeanOfStudent
}
@@ -254,6 +253,22 @@ export const gradeMeanFilter = (params) => {
})
}
export const tagFilter = (params) => {
const { text, value } = params.tag
return ({
id: uuidv4(),
type: 'TagFilter',
params: {
text,
value
},
filter: (student) => {
const studentTagIds = student.tags.map(t => t.tag.tag_id)
return studentTagIds.includes(value)
}
})
}
export const presetFilter = preset => ({
id: preset.id,
type: 'Preset',
@@ -268,6 +283,7 @@ const typeList = {
CreditsAtLeast: creditsAtLeast,
HasMatriculation: matriculationFilter,
SexFilter: sexFilter,
GradeMeanFilter: gradeMeanFilter,
CourseParticipation: courseParticipation,
StartingThisSemester: startingThisSemester,
ExtentGraduated: extentGraduated,
@@ -278,7 +294,8 @@ const typeList = {
CanceledStudyright: canceledStudyright,
Preset: presetFilter,
TransferToStudyrightFilter: transferTo,
PriorityStudyright: priorityStudyright
PriorityStudyright: priorityStudyright,
TagFilter: tagFilter
}
export const getFilterFunction = (type, params, populationCourses) => {
switch (type) {
@@ -0,0 +1,35 @@
import { callController } from '../apiConnection'
export const getFaculties = () => {
const route = '/faculties'
const prefix = 'GET_FACULTIES_'
return callController(route, prefix)
}
const reducer = (state = { data: [], pending: false, error: false }, action) => {
switch (action.type) {
case 'GET_FACULTIES_ATTEMPT':
return {
...state,
pending: true,
error: false
}
case 'GET_FACULTIES_FAILURE':
return {
...state,
pending: false,
error: true
}
case 'GET_FACULTIES_SUCCESS':
return {
...state,
data: action.response,
pending: false,
error: true
}
default:
return state
}
}
export default reducer
@@ -10,6 +10,7 @@ import populationDegreesAndProgrammesUnfiltered from './populationDegreesAndProg
import populationMandatoryCourses from './populationMandatoryCourses'
import studyProgrammeProductivity from './productivity'
import studyProgrammeThroughput from './throughput'
import faculties from './faculties'
import units from './units'
import students from './students'
import errors from './errors'
@@ -42,11 +43,15 @@ import oodilearnPopulationCourseSelect from './oodilearnPopulationCourseSelect'
import thesisCourses from './thesisCourses'
import accessGroups from './accessGroups'
import feedback from './feedback'
import mandatoryCourseLabels from './mandatoryCourseLabels'
import tags from './tags'
import tagstudent from './tagstudent'
export default combineReducers({
locale,
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationFilters,
@@ -55,6 +60,7 @@ export default combineReducers({
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
faculties,
units,
students,
errors,
@@ -86,5 +92,7 @@ export default combineReducers({
oodilearnPopulationCourseSelect,
thesisCourses,
accessGroups,
feedback
feedback,
tags,
tagstudent
})
@@ -0,0 +1,112 @@
import { callController } from '../apiConnection/index'
export const GetMandatoryCourseLabels = (programme) => {
const prefix = 'GET_MANDATORY_COURSE_LABELS_'
const route = `/mandatory-course-labels/${programme}/labels`
return callController(route, prefix)
}
export const AddMandatoryCourseLabel = (programme, label) => {
const prefix = 'ADD_MANDATORY_COURSE_LABELS_'
const route = `/mandatory-course-labels/${programme}/labels`
const method = 'post'
const data = { label }
return callController(route, prefix, data, method)
}
export const DeleteMandatoryCourseLabel = (programme, label) => {
const prefix = 'DELETE_MANDATORY_COURSE_LABELS_'
const route = `/mandatory-course-labels/${programme}/labels`
const method = 'delete'
const data = { label }
return callController(route, prefix, data, method)
}
export const MoveMandatoryCourseLabel = (programme, label, direction) => {
const prefix = 'MOVE_MANDATORY_COURSE_LABELS_'
const route = `/mandatory-course-labels/${programme}/move`
const method = 'post'
const data = { label, direction }
return callController(route, prefix, data, method)
}
const reducer = (state = { data: [] }, action) => {
switch (action.type) {
case 'GET_MANDATORY_COURSE_LABELS_ATTEMPT':
return {
...state,
pending: true
}
case 'GET_MANDATORY_COURSE_LABELS_FAILURE':
return {
...state,
pending: false,
error: true
}
case 'GET_MANDATORY_COURSE_LABELS_SUCCESS':
return {
...state,
pending: false,
error: false,
data: action.response
}
case 'ADD_MANDATORY_COURSE_LABELS_ATTEMPT':
return {
...state,
pending: true
}
case 'ADD_MANDATORY_COURSE_LABELS_FAILURE':
return {
...state,
pending: false,
error: true
}
case 'ADD_MANDATORY_COURSE_LABELS_SUCCESS':
return {
...state,
pending: false,
error: false,
data: action.response
}
case 'DELETE_MANDATORY_COURSE_LABELS_ATTEMPT':
return {
...state,
pending: true
}
case 'DELETE_MANDATORY_COURSE_LABELS_FAILURE':
return {
...state,
pending: false,
error: true
}
case 'DELETE_MANDATORY_COURSE_LABELS_SUCCESS':
return {
...state,
pending: false,
error: false,
data: action.response
}
case 'MOVE_MANDATORY_COURSE_LABELS_ATTEMPT':
return {
...state,
pending: true
}
case 'MOVE_MANDATORY_COURSE_LABELS_FAILURE':
return {
...state,
pending: false,
error: true
}
case 'MOVE_MANDATORY_COURSE_LABELS_SUCCESS':
return {
...state,
pending: false,
error: false,
data: action.response
}
default:
return state
}
}
export default reducer
@@ -39,6 +39,7 @@ const reducer = (state = { data: [] }, action) => {
pending: false,
error: true,
selected: state.selected,
lastSearch: state.lastSearch,
data: state.data
}
case 'FIND_STUDENTS_SUCCESS':
@@ -46,6 +47,7 @@ const reducer = (state = { data: [] }, action) => {
pending: false,
error: false,
selected: state.selected,
lastSearch: state.lastSearch,
data: state.lastSearch === action.query ?
[...state.data.filter(student => student.fetched), ...action.response] :
state.data
@@ -55,6 +57,7 @@ const reducer = (state = { data: [] }, action) => {
pending: false,
error: false,
selected: action.response.studentNumber,
lastSearch: state.lastSearch,
data: [...state.data.filter(student =>
student.studentNumber !== action.response.studentNumber),
{ ...action.response, ...{ fetched: true } }
@@ -0,0 +1,101 @@
import { callController } from '../apiConnection'
export const getTagsAction = () => {
const route = '/tags'
const prefix = 'GET_TAGS_'
return callController(route, prefix)
}
export const getTagsByStudytrackAction = (studytrack) => {
const route = `/tags/${studytrack}`
const prefix = 'GET_TAGS_BY_ST_'
return callController(route, prefix)
}
export const createTagAction = (tag) => {
const route = '/tags'
const prefix = 'CREATE_TAG_'
const method = 'post'
const data = { tag }
return callController(route, prefix, data, method)
}
export const deleteTagAction = (tag) => {
const route = '/tags'
const prefix = 'DELETE_TAG_'
const method = 'delete'
const data = { tag }
return callController(route, prefix, data, method)
}
const reducer = (state = { data: [] }, action) => {
switch (action.type) {
case 'GET_TAGS_ATTEMPT':
return {
...state,
pending: true
}
case 'GET_TAGS_SUCCESS':
return {
...state,
pending: false,
data: action.response || {}
}
case 'GET_TAGS_BY_ST_ATTEMPT':
return {
...state,
pending: true
}
case 'GET_TAGS_BY_ST_SUCCESS':
return {
...state,
pending: false,
data: action.response || {}
}
case 'GET_TAGS_BY_ST_FAILURE':
return {
...state,
pending: false,
data: action.response || {}
}
case 'GET_TAGS_FAILURE':
return {
...state,
pending: false
}
case 'CREATE_TAG_ATTEMPT':
return {
...state,
pending: true
}
case 'CREATE_TAG_FAILURE':
return {
...state,
pending: false
}
case 'CREATE_TAG_SUCCESS':
return {
...state,
pending: false
}
case 'DELETE_TAG_ATTEMPT':
return {
...state,
pending: true
}
case 'DELETE_TAG_FAILURE':
return {
...state,
pending: false
}
case 'DELETE_TAG_SUCCESS':
return {
...state,
pending: false
}
default:
return state
}
}
export default reducer
@@ -0,0 +1,129 @@
import { callController } from '../apiConnection'
export const getStudentTagsAction = () => {
const route = '/studenttags'
const prefix = 'GET_STUDENT_TAGS_'
return callController(route, prefix)
}
export const createStudentTagAction = (tag) => {
const route = '/studenttags'
const prefix = 'CREATE_STUDENT_TAG_'
const method = 'post'
const data = { tag }
return callController(route, prefix, data, method)
}
export const getStudentTagsByStudytrackAction = (studytrack) => {
const route = `/studenttags/${studytrack}`
const prefix = 'GET_STUDENT_TAGS_BY_ST_'
const data = { studytrack }
return callController(route, prefix, data)
}
export const getStudentTagsByStudentnumberAction = (studentnumber) => {
const route = `/studenttags/${studentnumber}`
const prefix = 'GET_STUDENT_TAG_BY_SN_'
return callController(route, prefix)
}
export const deleteStudentTagAction = (id) => {
const route = '/studenttags'
const prefix = 'DELETE_STUDENT_TAG_'
const method = 'delete'
const data = { id }
return callController(route, prefix, data, method)
}
const reducer = (state = { data: [], success: false, created: false }, action) => {
switch (action.type) {
case 'GET_STUDENT_TAGS_ATTEMPT':
return {
...state,
pending: true
}
case 'GET_STUDENT_TAGS_SUCCESS':
return {
...state,
pending: false,
data: action.response || {}
}
case 'GET_STUDENT_TAGS_FAILURE':
return {
...state,
pending: false
}
case 'GET_STUDENT_TAGS_BY_ST_ATTEMPT':
return {
...state,
pending: true,
success: false
}
case 'GET_STUDENT_TAGS_BY_ST_FAILURE':
return {
...state,
pending: false,
success: false
}
case 'GET_STUDENT_TAGS_BY_ST_SUCCESS':
return {
...state,
pending: false,
data: action.response || [],
success: true
}
case 'GET_STUDENT_TAG_BY_SN_ATTEMPT':
return {
...state,
pending: true
}
case 'GET_STUDENT_TAG_BY_SN_FAILURE':
return {
...state,
pending: false
}
case 'GET_STUDENT_TAG_BY_SN_SUCCESS':
return {
...state,
pending: false,
data: action.response || []
}
case 'CREATE_STUDENT_TAG_ATTEMPT':
return {
...state,
pending: true,
created: false
}
case 'CREATE_STUDENT_TAG_FAILURE':
return {
...state,
pending: false,
created: false
}
case 'CREATE_STUDENT_TAG_SUCCESS':
return {
...state,
pending: false,
created: true
}
case 'DELETE_STUDENT_TAG_ATTEMPT':
return {
...state,
pending: true
}
case 'DELETE_STUDENT_TAG_FAILURE':
return {
...state,
pending: false
}
case 'DELETE_STUDENT_TAG_SUCCESS':
return {
...state,
pending: false
}
default:
return state
}
}
export default reducer
@@ -39,6 +39,13 @@ export const removeUserUnits = (uid, codes) => {
return callController(route, prefix, data, 'delete')
}
export const setFaculties = (uid, faculties) => {
const route = `/users/${uid}/faculties`
const data = { faculties }
const prefix = 'SET_USER_FACULTIES_'
return callController(route, prefix, data, 'post')
}
export const sendEmail = (email) => {
const route = '/users/email'
const prefix = 'SEND_EMAIL_'
@@ -135,6 +142,23 @@ const reducer = (state = { data: [], enabledOnly: true }, action) => {
data: state.data.filter(user => user.id !== action.response.id)
.concat(action.response)
}
case 'SET_USER_FACULTIES_ATTEMPT':
return {
...state,
setfacultypending: true
}
case 'SET_USER_FACULTIES_FAILURE':
return {
...state,
setfacultypending: false
}
case 'SET_USER_FACULTIES_SUCCESS':
return {
...state,
setfacultypending: false,
data: state.data.filter(user => user.id !== action.response.id)
.concat(action.response)
}
case 'SEND_EMAIL_ATTEMPT':
return {
...state,
@@ -85,9 +85,8 @@ module.exports = (env, args) => {
port: devServerPort,
proxy: [
{
context: ['/api', '/staging/api'],
target: backendURL,
pathRewrite: { '^/staging': '' }
context: ['/api'],
target: backendURL
}
]
}
+23 -19
View File
@@ -16,22 +16,12 @@ app.get('/ping', (req, res) => res.json({ message: 'pong '}))
app.get('/findall', async (req, res) => {
const users = await User.findAll()
const returnedUsers = users.map(u => {
const enabled = requiredGroup === null || u.hy_group.some(e => e.code === requiredGroup)
return {...u.get(), is_enabled: enabled, hy_group: null}
})
res.json(returnedUsers)
res.json(users.map(User.getUserData))
})
app.get('/findallenabled', async (req, res) => {
const users = await User.findAll()
const returnedUsers = users.filter(u => (
requiredGroup === null || u.hy_group.some(e => e.code === requiredGroup)
)).map(u => {
const enabled = requiredGroup === null || u.hy_group.some(e => e.code === requiredGroup)
return {...u.get(), is_enabled: enabled, hy_group: null}
})
res.json(returnedUsers)
res.json(users.map(User.getUserData).filter(u => u.is_enabled))
})
app.get('/user/:uid', async (req, res) => {
@@ -40,12 +30,13 @@ app.get('/user/:uid', async (req, res) => {
console.log(JSON.stringify(user))
res.json(user)
res.json(User.getUserData(user))
})
app.get('/user/elementdetails/:username', async (req, res) => {
const username = req.params.username
const elementdetails = await User.getUserElementDetails(username)
const user = await byUsername(username)
const elementdetails = User.getUserElementDetails(user)
console.log(JSON.stringify(elementdetails))
@@ -58,7 +49,7 @@ app.get('/user/id/:id', async (req, res) => {
console.log(JSON.stringify(user))
res.json(user)
res.json(User.getUserData(user))
})
app.post('/user', async (req, res) => {
@@ -95,7 +86,7 @@ app.put('/user/:uid', async (req, res) => {
}
await User.updateUser(user, req.body)
const returnedUser = await User.byUsername(uid)
res.json(returnedUser)
res.json(User.getUserData(returnedUser))
})
app.post('/modifyaccess', async (req, res) => {
@@ -103,7 +94,7 @@ app.post('/modifyaccess', async (req, res) => {
try {
await User.modifyRights(uid, accessgroups)
const user = await User.byId(uid)
res.status(200).json(user)
res.status(200).json(User.getUserData(user))
} catch (e) {
res.status(400).json({ e })
}
@@ -115,7 +106,7 @@ app.post('/add_rights', async (req, res) => {
try {
await User.enableElementDetails(uid, codes)
const user = await User.byId(uid)
res.status(200).json({ user })
res.status(200).json({ user: User.getUserData(user) })
} catch (e) {
res.status(400).json({ e })
@@ -127,9 +118,22 @@ app.post('/remove_rights', async (req, res) => {
try {
await User.removeElementDetails(uid, codes)
const user = await User.byId(uid)
res.status(200).json({ user })
res.status(200).json({ user: User.getUserData(user) })
} catch (e) {
console.log(e)
res.status(400).json({ e })
}
})
app.post('/set_faculties', async (req, res) => {
const { uid, faculties } = req.body
try {
await User.setFaculties(uid, faculties)
const user = await User.byId(uid)
res.status(200).json({ user: User.getUserData(user) })
} catch (e) {
console.log(e)
res.status(400).json({ e })
}
})
+134 -74
View File
@@ -780,7 +780,8 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -798,11 +799,13 @@
},
"balanced-match": {
"version": "1.0.0",
"bundled": true
"bundled": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -810,20 +813,22 @@
},
"chownr": {
"version": "1.1.1",
"bundled": true,
"optional": true
"bundled": true
},
"code-point-at": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -856,7 +861,6 @@
"fs-minipass": {
"version": "1.2.5",
"bundled": true,
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
@@ -926,7 +930,8 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true
"bundled": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -936,6 +941,7 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -948,13 +954,15 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true
"bundled": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
@@ -967,7 +975,6 @@
"minizlib": {
"version": "1.2.1",
"bundled": true,
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
@@ -975,6 +982,7 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -1047,7 +1055,8 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -1057,6 +1066,7 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -1162,6 +1172,7 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -1179,6 +1190,7 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -1188,20 +1200,6 @@
"bundled": true,
"optional": true
},
"tar": {
"version": "4.4.8",
"bundled": true,
"optional": true,
"requires": {
"chownr": "^1.1.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.3.4",
"minizlib": "^1.1.1",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.2",
"yallist": "^3.0.2"
}
},
"util-deprecate": {
"version": "1.0.2",
"bundled": true,
@@ -1217,7 +1215,8 @@
},
"wrappy": {
"version": "1.0.2",
"bundled": true
"bundled": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
@@ -1232,6 +1231,12 @@
}
}
},
"chownr": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz",
"integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==",
"optional": true
},
"ci-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz",
@@ -2137,6 +2142,15 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
},
"fs-minipass": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.6.tgz",
"integrity": "sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==",
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -2163,7 +2177,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -2184,12 +2199,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2197,24 +2214,25 @@
},
"chownr": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"bundled": true
},
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -2252,8 +2270,6 @@
"fs-minipass": {
"version": "1.2.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
@@ -2331,7 +2347,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -2343,6 +2360,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -2357,6 +2375,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -2364,12 +2383,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.2.4",
"bundled": true,
"dev": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@@ -2378,8 +2397,6 @@
"minizlib": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
@@ -2388,6 +2405,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -2468,7 +2486,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -2480,6 +2499,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -2564,8 +2584,7 @@
},
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
"dev": true
"bundled": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -2601,6 +2620,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -2620,6 +2640,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -2630,21 +2651,6 @@
"dev": true,
"optional": true
},
"tar": {
"version": "4.4.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"chownr": "^1.0.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.2.4",
"minizlib": "^1.1.0",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.1",
"yallist": "^3.0.2"
}
},
"util-deprecate": {
"version": "1.0.2",
"bundled": true,
@@ -2663,12 +2669,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
"dev": true
"bundled": true
}
}
},
@@ -2816,12 +2822,12 @@
"dev": true
},
"handlebars": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.12.tgz",
"integrity": "sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz",
"integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==",
"dev": true,
"requires": {
"async": "^2.5.0",
"neo-async": "^2.6.0",
"optimist": "^0.6.1",
"source-map": "^0.6.1",
"uglify-js": "^3.1.4"
@@ -4315,9 +4321,9 @@
"dev": true
},
"js-yaml": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.1.tgz",
"integrity": "sha512-um46hB9wNOKlwkHgiuyEVAybXBjwFUV0Z/RaHJblRd9DXltue9FTYvzCr9ErQrK9Adz5MU4gHWVaNUfdmrC8qA==",
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
"integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
"dev": true,
"requires": {
"argparse": "^1.0.7",
@@ -4727,9 +4733,36 @@
},
"minimist": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
},
"minipass": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz",
"integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
},
"dependencies": {
"yallist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz",
"integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
"optional": true
}
}
},
"minizlib": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz",
"integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==",
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
},
"mixin-deep": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz",
@@ -4753,7 +4786,6 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true,
"requires": {
"minimist": "0.0.8"
},
@@ -4761,8 +4793,7 @@
"minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
}
}
},
@@ -4819,6 +4850,12 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
"integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
},
"neo-async": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz",
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==",
"dev": true
},
"node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -6266,6 +6303,29 @@
"integrity": "sha1-rifbOPZgp64uHDt9G8KQgZuFGeY=",
"dev": true
},
"tar": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz",
"integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==",
"optional": true,
"requires": {
"chownr": "^1.1.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.3.5",
"minizlib": "^1.2.1",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.2",
"yallist": "^3.0.3"
},
"dependencies": {
"yallist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz",
"integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
"optional": true
}
}
},
"term-size": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz",
@@ -0,0 +1,49 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('user_faculties',
{
userId: {
primaryKey: true,
type: Sequelize.BIGINT,
references: {
model: 'users',
key: 'id'
}
},
faculty_code: {
primaryKey: true,
type: Sequelize.STRING,
},
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
}
})
await queryInterface.createTable('faculty_programmes',
{
faculty_code: {
primaryKey: true,
type: Sequelize.STRING,
},
programme_code: {
primaryKey: true,
type: Sequelize.STRING,
references: {
model: 'element_details',
key: 'code'
}
},
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
}
})
},
down: async () => {
}
}
@@ -0,0 +1,35 @@
const valtiotieteellisen_ohjelmat = [
'KH40_001', // Filosofian kandiohjelma
'KH70_001', // Politiikan ja viestinnän kandiohjelma
'KH70_002', // Yhteiskunnallisen muutoksen kandiohjelma
'KH70_003', // Sosiaalitieteiden kandiohjelma
'KH70_004', // Taloustieteen kandiohjelma
'MH50_001', // Matematiikan ja tilastotieteen maisteriohjelma
'MH50_013', // Kaupunkitutkimuksen ja suunnittelun maisteriohjelma
'MH57_005', // Ympäristömuutoksen ja globaalin kestävyyden maisteriohjelma
'MH70_001', // Filosofian maisteriohjelma
'MH70_002', // Politiikan ja viestinnän maisteriohjelma
'MH70_003', // Globaalin politiikan ja kommunikaation maisteriohjelma
'MH70_004', // Yhteiskunnallisen muutoksen maisteriohjelma
'MH70_005', // Nyky-yhteiskunnan tutkimuksen maisteriohjelma
'MH70_006', // Euroopan ja pohjoismaiden tutkimuksen maisteriohjelma (European and Nordic Studies)
'MH70_007', // Yhteiskuntatieteiden maisteriohjelma
'MH70_008', // Sosiaalitieteiden maisteriohjelma
'MH70_009', // Taloustieteen maisteriohjelma
'MH70_010' // International Masters in Economy, State & Society
]
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.bulkInsert(
'faculty_programmes',
valtiotieteellisen_ohjelmat.map(code => ({
faculty_code: 'H70',
programme_code: code,
createdAt: new Date(),
updatedAt: new Date()
}))
)
},
down: async () => {}
}
@@ -37,6 +37,49 @@ const AccessGroup = sequelize.define('access_group',
}
})
const UserFaculties = sequelize.define('user_faculties',
{
userId: {
primaryKey: true,
type: Sequelize.BIGINT,
references: {
model: 'users',
key: 'id'
}
},
faculty_code: {
primaryKey: true,
type: Sequelize.STRING,
},
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
}
})
const FacultyProgrammes = sequelize.define('faculty_programmes',
{
faculty_code: {
primaryKey: true,
type: Sequelize.STRING,
},
programme_code: {
primaryKey: true,
type: Sequelize.STRING,
references: {
model: 'element_details',
key: 'code'
}
},
createdAt: {
type: Sequelize.DATE
},
updatedAt: {
type: Sequelize.DATE
}
})
const User = sequelize.define('users',
{
@@ -109,7 +152,7 @@ const Migration = sequelize.define('migrations', {
})
User.belongsToMany(ElementDetails, { through: 'user_elementdetails', as: 'elementdetails' })
User.belongsToMany(ElementDetails, { through: 'user_elementdetails', as: 'programme' })
ElementDetails.belongsToMany(User, { through: 'user_elementdetails' })
User.belongsToMany(AccessGroup, { through: 'user_accessgroup', as: 'accessgroup' })
@@ -121,11 +164,18 @@ HyGroup.belongsToMany(User, { through: 'user_hy_group' })
User.belongsToMany(Affiliation, { through: 'user_affiliation', as: 'affiliation' })
Affiliation.belongsToMany(User, { through: 'user_affiliation' })
User.hasMany(UserFaculties, { as: 'faculty', foreignKey: 'userId' })
UserFaculties.hasMany(FacultyProgrammes, { as: 'programme', foreignKey: 'faculty_code', sourceKey: 'faculty_code' })
FacultyProgrammes.hasOne(ElementDetails, { foreignKey: 'code', sourceKey: 'programme_code' })
module.exports = {
User,
ElementDetails,
Migration,
AccessGroup,
HyGroup,
Affiliation
Affiliation,
UserFaculties,
FacultyProgrammes,
sequelize
}
@@ -1,7 +1,8 @@
const Sequelize = require('sequelize')
const jwt = require('jsonwebtoken')
const moment = require('moment')
const { User, ElementDetails, AccessGroup, HyGroup, Affiliation } = require('../models')
const { flatMap } = require('lodash')
const { User, ElementDetails, AccessGroup, HyGroup, Affiliation, FacultyProgrammes, UserFaculties, sequelize } = require('../models')
const ElementService = require('./studyelements')
const AccessService = require('./accessgroups')
const AffiliationService = require('./affiliations')
@@ -11,15 +12,14 @@ const Op = Sequelize.Op
const TOKEN_VERSION = 1 // When token structure changes, increment in userservice, backend and frontend
const generateToken = async (uid, mockedBy = null) => {
let user = await byUsername(uid)
const elementdetails = await getUserElementDetails(user.username)
const elements = elementdetails.map(element => element.code)
const enabled = requiredGroup === null || user.hy_group.some(e => e.code === requiredGroup)
const user = await byUsername(uid)
const userData = getUserData(user)
const elements = userData.elementdetails.map(element => element.code)
const payload = {
id: user.id,
userId: uid, // username
name: user.full_name,
enabled,
enabled: userData.is_enabled,
language: user.language,
mockedBy,
rights: elements,
@@ -114,28 +114,57 @@ const superlogin = async (uid, asUser) => {
}
return undefined
}
const userIncludes = [
{
model: ElementDetails,
as: 'programme'
},
{
model: AccessGroup,
as: 'accessgroup',
attributes: ['id', 'group_code', 'group_info']
},
{
model: HyGroup,
as: 'hy_group'
},
{
model: UserFaculties,
as: 'faculty',
include: {
model: FacultyProgrammes,
as: 'programme',
include: {
model: ElementDetails,
}
}
},
{
model: Affiliation,
as: 'affiliation'
}
]
const getUserData = user => {
if (user == null) return null
const newuser = user.get()
newuser.elementdetails = getUserElementDetails(newuser)
newuser.is_enabled = requiredGroup === null || newuser.hy_group.some(e => e.code === requiredGroup)
newuser.hy_group = null
return newuser
}
const byUsername = async (username) => {
return User.findOne({
const user = await User.findOne({
where: {
username: {
[Op.eq]: username
}
},
include: [{
model: ElementDetails,
as: 'elementdetails'
}, {
model: AccessGroup,
as: 'accessgroup',
attributes: ['id', 'group_code', 'group_info']
}, {
model: HyGroup,
as: 'hy_group'
},{
model: Affiliation,
as: 'affiliation'
}]
include: userIncludes
})
return user
}
const createUser = async (username, fullname, email) => {
@@ -152,32 +181,26 @@ const updateUser = async (userObject, values) => {
const byId = async (id) => {
return User.findOne({
const user = await User.findOne({
where: {
id: {
[Op.eq]: id
}
},
include: [{
model: ElementDetails,
as: 'elementdetails'
}, {
model: AccessGroup,
as: 'accessgroup',
attributes: ['id', 'group_code', 'group_info']
}]
include: userIncludes
})
return user
}
const getUnitsFromElementDetails = async username => {
const user = await byUsername(username)
const elementDetails = await user.getElementdetails()
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
const userData = getUserData(user)
return userData.elementdetails.map(element => UnitService.parseUnitFromElement(element))
}
const getUserElementDetails = async username => {
const user = await byUsername(username)
return await user.getElementdetails()
const getUserElementDetails = user => {
const elementdetails = [...user.programme, ...flatMap(user.faculty, f => f.programme.map(p => p.element_detail))]
return elementdetails
}
const getUserAccessGroups = async username => {
const user = await byUsername(username)
@@ -185,32 +208,31 @@ const getUserAccessGroups = async username => {
}
const findAll = async () => {
return User.findAll({
include: [{
model: ElementDetails,
as: 'elementdetails'
},
{
model: AccessGroup,
as: 'accessgroup'
},
{
model: HyGroup,
as: 'hy_group'
}]
const users = await User.findAll({
include: userIncludes,
})
return users
}
const enableElementDetails = async (uid, codes) => {
const user = await byId(uid)
const elements = await ElementService.byCodes(codes)
await user.addElementdetails(elements)
await user.addProgramme(elements)
}
const removeElementDetails = async (uid, codes) => {
const user = await byId(uid)
const elements = await ElementService.byCodes(codes)
await user.removeElementdetails(elements)
await user.removeProgramme(elements)
}
const setFaculties = async (uid, faculties) => {
await sequelize.transaction(async transaction => {
await UserFaculties.destroy({ where: { userId: uid }, transaction })
for (const faculty of faculties) {
await UserFaculties.create({ userId: uid, faculty_code: faculty }, { transaction })
}
})
}
const modifyRights = async (uid, rights) => {
@@ -254,6 +276,7 @@ module.exports = {
superlogin,
modifyRights,
getUserAccessGroups,
getRoles
getRoles,
getUserData,
setFaculties
}
@@ -100,7 +100,7 @@ beforeAll(async () => {
await admin_user.addHy_group(hygroup)
const normal_user = await User.findOne({ where: { id: 665 }})
const CS_ED = await ElementDetails.findOne({ where: { code: 'ELEMENT_CS' }})
await normal_user.addElementdetail(CS_ED)
await normal_user.addProgramme(CS_ED)
await normal_user.addHy_group(hygroup)
})
afterAll(async () => {
@@ -168,19 +168,23 @@ describe('user tests', async () => {
describe('user access right tests', async () => {
test('adding and getting access rights works', async () => {
const rights1 = await userService.getUserElementDetails('sasumaki')
const user1 = await userService.byUsername('sasumaki')
const rights1 = userService.getUserElementDetails(user1)
expect(rights1.length).toBe(0)
await userService.enableElementDetails(69, ['Element_MATH'])
const rights2 = await userService.getUserElementDetails('sasumaki')
const user2 = await userService.byUsername('sasumaki')
const rights2 = userService.getUserElementDetails(user2)
expect(rights2[0].type).toBe(20)
})
test('removing and getting access rights works', async () => {
const rights1 = await userService.getUserElementDetails('freeman')
const user1 = await userService.byUsername('freeman')
const rights1 = userService.getUserElementDetails(user1)
expect(rights1.length).toBe(1)
await userService.removeElementDetails(665, ['ELEMENT_CS'])
const rights2 = await userService.getUserElementDetails('freeman')
const user2 = await userService.byUsername('freeman')
const rights2 = userService.getUserElementDetails(user2)
console.log(rights2)
expect(rights2.length).toBe(0)
})
+62 -32
View File
@@ -23,19 +23,34 @@ const updateTask = async (task, status, type) => {
}
stan.on('connect', async () => {
cron.schedule('0 0 1 * *', async () => {
// Update ALL students and meta every month
scheduleAllStudentsAndMeta()
}, { timezone })
// cron.schedule('0 0 1 * *', async () => {
// // Update ALL students and meta every month
// scheduleAllStudentsAndMeta()
// }, { timezone })
cron.schedule('20 4 1 1,3,8,10 *', async () => {
// At 04:20 on day-of-month 1 in January, March, August, and October.”
updateStudentNumberList()
})
cron.schedule('0 23 * * *', async () => {
// Update ACTIVE students every night
scheduleActiveStudents()
}, { timezone })
cron.schedule('0 * * * *'), async () => {
const allStudentTasks = await Schedule.find({ type: 'student' })
if (allStudentTasks && allStudentTasks.every(task => task.status === 'DONE')) {
stan.publish('DumpDatabase', null, (err, guid) => {
if (err) {
console.log('publish failed', 'DumpDatabase')
} else {
console.log('published', 'DumpDatabase')
}
})
}
}
// cron.schedule('0 23 * * *', async () => {
// // Update ACTIVE students every night
// scheduleActiveStudents()
// }, { timezone })
cron.schedule('*/5 * * * *', async () => {
const oldestTasks = await getOldestTasks()
const status = await getCurrentStatus()
@@ -43,6 +58,7 @@ stan.on('connect', async () => {
logger.info('updaterStatus', status)
}, { timezone })
cron.schedule('0 0-9 * * *', async () => {
// Just log some statistics about updater during nights
logger.info(`${updatedCount} TASKS DONE IN LAST HOUR\n ${scheduledCount} TASKS SCHEDULED IN LAST HOUR\n ${fetchedCount} TASKS FETCHED FROM API IN LAST HOUR`)
@@ -50,31 +66,44 @@ stan.on('connect', async () => {
fetchedCount = 0
scheduledCount = 0
}, { timezone })
cron.schedule('0 7 * * *', async () => {
stan.publish('RefreshOverview', null, (err, guid) => {
if (err) {
console.log('publish failed', 'RefreshOverview')
} else {
console.log('published', 'RefreshOverview')
}
})
stan.publish('RefreshStudyrightAssociations', null, (err, guid) => {
if (err) {
console.log('publish failed', 'RefreshStudyrightAssociations')
} else {
console.log('published', 'RefreshStudyrightAssociations')
}
})
stan.publish('updateAttainmentDates', null, (err, guid) => {
if (err) {
console.log('publish failed', 'UpdateAttainmentDates')
} else {
console.log('published', 'UpdateAttainmentDates')
}
})
}, { timezone })
const statusSub = stan.subscribe('status')
// cron.schedule('0 7 * * *', async () => {
// stan.publish('RefreshOverview', null, (err, guid) => {
// if (err) {
// console.log('publish failed', 'RefreshOverview')
// } else {
// console.log('published', 'RefreshOverview')
// }
// })
// stan.publish('RefreshStudyrightAssociations', null, (err, guid) => {
// if (err) {
// console.log('publish failed', 'RefreshStudyrightAssociations')
// } else {
// console.log('published', 'RefreshStudyrightAssociations')
// }
// })
// stan.publish('updateAttainmentDates', null, (err, guid) => {
// if (err) {
// console.log('publish failed', 'UpdateAttainmentDates')
// } else {
// console.log('published', 'UpdateAttainmentDates')
// }
// })
// }, { timezone })
const scheduleSub = stan.subscribe('ScheduleAll')
scheduleSub.on('message', async (_) => {
scheduleAllStudentsAndMeta()
})
const opts = stan.subscriptionOptions();
opts.setManualAckMode(true);
opts.setAckWait(30 * 60 * 1000); // 1min
opts.setDeliverAllAvailable()
opts.setDurableName('durable')
opts.setMaxInFlight(20)
const statusSub = stan.subscribe('status', opts)
statusSub.on('message', async (msg) => {
const message = msg.getData().split(':')
@@ -111,6 +140,7 @@ stan.on('connect', async () => {
const isStudent = !!isValidStudentId(task)
logger.info(`Status changed for ${task} to ${status}`, { task: task, status: status, student: isStudent })
await updateTask(task, status, isStudent ? 'student' : 'other')
msg.ack()
})
})
@@ -22,14 +22,21 @@ const publish = async (tasks) => {
await sleep(15 * rampup)
}
}
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array
}
const scheduleActiveStudents = async () => {
const tasks = [...(await Schedule.find({ type: 'student', active: true }))]
const tasks = [...shuffleArray(await Schedule.find({ type: 'student', active: true }))]
console.log(tasks.length, 'tasks to schedule')
publish(tasks)
}
const scheduleAllStudentsAndMeta = async () => {
const tasks = ['meta', ...(await Schedule.find({ type: 'student' }))]
const tasks = [{ task:'meta', type: 'other', active: 'false' }, ...shuffleArray(await Schedule.find({ type: 'student' }))]
console.log(tasks.length, 'tasks to schedule')
publish(tasks)
}