Compare commits

...
160 Commits
Author SHA1 Message Date
Otto Ahoniemi 8c092c8d9f Remove more redux boilerplate 2021-11-03 12:47:52 +02:00
Otto Ahoniemi e0789186ba Change combinereducer import location 2021-11-03 12:26:56 +02:00
Otto Ahoniemi c798a4747f Remove unnecessary crudreducer abstraction 2021-11-03 12:20:18 +02:00
Otto Ahoniemi 8f396a9913 Cleanup studyguidancegroup reducer 2021-11-03 12:11:30 +02:00
Otto Ahoniemi 148a5acc25 Use one source of truth for redux types 2021-11-03 09:49:09 +02:00
Otto Ahoniemi 9cf1b87b3f Remove deprecated context dev tool pkg 2021-11-03 09:34:58 +02:00
Otto Ahoniemi 82ed55e1f9 Simplify store config 2021-11-03 09:30:41 +02:00
Otto Ahoniemi 82041e673d Swap individual redux&reselect to official redux toolkit package 2021-11-03 09:25:45 +02:00
Otto Ahoniemi 1e4405aac9 Change frontpage to work with hooks 2021-11-02 22:06:42 +02:00
Otto Ahoniemi dcefdd12cb Fix error when year tag not set 2021-11-02 20:29:11 +02:00
Otto AhoniemiandGitHub fd775252b8 Merge pull request #3282 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/ansi-regex-5.0.1
Bump ansi-regex from 5.0.0 to 5.0.1
2021-11-02 20:19:09 +02:00
Otto Ahoniemi acd7dc65f6 Add prop for creditacc starting year, cleanup proptypes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi a82d272a27 moar cleanups 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 1bb5ddba96 Finalize form on frontend 2021-11-02 20:14:32 +02:00
Otto Ahoniemi ae14a55885 Cleanups 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 9308016cf3 Show previosly selected value as placeholder when editing 2021-11-02 20:14:32 +02:00
Otto Ahoniemi b5252a01f8 Add dropdown to search studyprogrammes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 4b22902fa4 Add support for studyguidancegroups in backend and add edits to frontend 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 267ebce457 Cleaning up again some proptypes 2021-11-02 20:14:32 +02:00
Otto Ahoniemi ee8013cba2 Add initial support for adding study guidance group programme & year in front 2021-11-02 20:14:32 +02:00
Otto Ahoniemi 4ce07eaf31 Refactor 2021-11-02 20:14:32 +02:00
Mitja Karhusaari e0d4e4072d Improve date selection behavior when no date is selected or it is the current date 2021-11-02 16:31:38 +02:00
Mitja Karhusaari d4aeece78e Minor UI behaviour fix in a tooltip popup 2021-11-02 16:19:37 +02:00
dependabot[bot]andGitHub 02adcbc4f8 Bump ansi-regex from 5.0.0 to 5.0.1
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 13:58:40 +00:00
Mitja Karhusaari a9d44b3d76 Fix tests for population statistics
The tests were dependent of the current month being October of 2021.
2021-11-02 15:57:31 +02:00
Mitja Karhusaari 3d310ee184 Update tooltips 2021-11-01 19:11:38 +02:00
Mitja Karhusaari 0adf1eae76 Misc UI asjustments in trends status page 2021-11-01 18:48:33 +02:00
Mitja Karhusaari e10d3dd3c7 Wrap Status and Graduated tabs' contents in a pane 2021-11-01 16:29:39 +02:00
Mitja Karhusaari fce0e482d3 Move settings pane next to the usage guide and behind a popup 2021-11-01 16:29:39 +02:00
Mitja Karhusaari 4d57d00ddb Move trends status usage info behind a popup window 2021-11-01 16:29:39 +02:00
Mitja Karhusaari 7cc90a4378 Implement drilling in trends so that the browser navigation buttons work 2021-11-01 16:29:39 +02:00
Mitja Karhusaari ff0f1a545f Display accumulated / total values for course student counts 2021-11-01 16:29:39 +02:00
Saara Satokangas 6eb69a036a Add tests for Credits Gained -tab 2021-10-28 18:34:50 +03:00
Saara Satokangas 3c8a47fc76 Add hover to make the toggle more noticeable 2021-10-28 16:28:26 +03:00
Saara Satokangas 735ec7efa0 Move "NEW!" label to specifically admission types 2021-10-28 16:11:46 +03:00
Saara Satokangas c33f842c9a Do not show dividers if no admission type data available 2021-10-28 16:03:17 +03:00
Saara Satokangas 2340e39fb4 Don't show admission type credit table if only zeros 2021-10-28 11:51:29 +03:00
Saara Satokangas 0221d1f70e Add divider to credit statistics 2021-10-28 11:48:14 +03:00
Saara Satokangas c6fe105b46 Add credit gains by admission type 2021-10-28 11:47:33 +03:00
Saara Satokangas 6ec1bc118d Move label "NEW!" from age stats to credit stats 2021-10-28 09:53:30 +03:00
Saara Satokangas 098dd5425e Remove proptypes from CreditGainStats-tables 2021-10-28 09:49:03 +03:00
Saara Satokangas 9eeed5094f Remove proptypes from CreditGainStats 2021-10-28 09:47:57 +03:00
Otto Ahoniemi ada4f66105 Add navbar styles back 2021-10-28 08:22:42 +03:00
Otto Ahoniemi b25c90e338 remove jstat 2021-10-28 08:22:42 +03:00
Otto Ahoniemi 47995056cb Report errors in case normal logging process fails 2021-10-28 08:22:42 +03:00
Otto Ahoniemi 46ee5b7764 Hardcode importer url instead of getting it from env 2021-10-28 08:22:42 +03:00
Petri Palmu c8f0e421fe little correction to the avoin trends 2021-10-28 08:21:38 +03:00
Petri Palmu 3db6a4443e Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-10-28 08:04:58 +03:00
Petri Palmu 77d20ad189 open uni added to the trends 2021-10-28 08:01:46 +03:00
Otto Ahoniemi bd6b2adef0 Change test to target cypress attr instead of classname 2021-10-27 19:50:14 +03:00
Otto Ahoniemi 7457ada25a Move select prompt reset to main cli.sh 2021-10-27 19:46:23 +03:00
Otto Ahoniemi ab94a0b27c Add option to reset single database interactively from cli 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 41e86af663 Remove old sis button configs from navbar, allow it to overflow before stacking 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 19e752b7ca Add formik 2021-10-27 19:34:54 +03:00
Otto Ahoniemi 3d4f71dbad Merge logging logic in different modules, fixing #3243 2021-10-27 19:34:53 +03:00
Saara Satokangas ff4d858efc Remove the quartiles also from tests 2021-10-27 11:20:57 +03:00
Saara Satokangas 59271c9e38 Remove mention of Oodi-Sis -credit mismatch from frontend 2021-10-27 10:30:47 +03:00
Saara Satokangas d87fb6d0fa Remove the mention of oodi-sisu-credit-mismatch 2021-10-27 10:28:38 +03:00
Saara Satokangas 646d87ab9e Add comments about the BH modules and programmes 2021-10-27 10:21:24 +03:00
Saara Satokangas 9e687f27a3 Update infotexts for population credit statistics 2021-10-27 10:11:40 +03:00
Saara Satokangas b699322e6f Make credit stats adjust to chosen admission type filter better 2021-10-27 10:11:19 +03:00
Saara Satokangas 3c01e3b7b1 Add admission types to credit statistics 2021-10-27 10:11:05 +03:00
Petri Palmu cb256976d7 kielikeskus added to the trends 2021-10-26 11:13:13 +03:00
Petri Palmu 1424461e26 open uni sql added to the trends and lot of pull merges 2021-10-25 09:55:07 +03:00
Otto Ahoniemi 86bd472c41 re-introduce the fix I just somehow managed to revert 2021-10-22 14:00:40 +03:00
Otto Ahoniemi 8984ea14f1 Add better error reporting to userservice 2021-10-22 13:58:54 +03:00
Otto Ahoniemi 1af42bf012 Fix userservice failing because of wrong property check 2021-10-22 13:38:17 +03:00
Mitja Karhusaari f606a7e8b7 Display course, programme and faculty codes on hover 2021-10-22 11:33:38 +03:00
Mitja Karhusaari 6d938a34af Add "type": "course" properties to the generated data 2021-10-22 11:28:30 +03:00
Mitja Karhusaari 77c5721dff Show course student numbers in trends 2021-10-22 11:28:30 +03:00
Mitja Karhusaari c5561ab6dc Bump trends redis key version number 2021-10-22 11:27:54 +03:00
Mitja Karhusaari 0b647e1b32 Revert "Calculate student counts in trends based on attainments instead of study rights"
This reverts commit 9e282ae634.
2021-10-22 11:27:54 +03:00
Mitja Karhusaari 1437bb9d0e Display zero instead of infinity if both the nominator and the
denominator are zero
2021-10-22 11:27:54 +03:00
Mitja Karhusaari 66249aabd8 Display yearly stats up to the current year, even if nothing to show 2021-10-22 11:27:54 +03:00
Otto AhoniemiandGitHub 02c63d1d9f Stop logging to file in updater 2021-10-22 10:12:30 +03:00
Mitja Karhusaari 00b7dc933e Add back accumulated statistics to the trends view
Resolves #3269
2021-10-21 12:58:56 +03:00
Mitja Karhusaari 9f089fb6db Fix year labels wrapping in the trends view on Chrome
Resolves #3270
2021-10-21 12:58:56 +03:00
Mitja Karhusaari 542267042e Add explanation of the relative statistics option in trends 2021-10-21 12:58:56 +03:00
Mitja Karhusaari 9e282ae634 Calculate student counts in trends based on attainments instead of study rights 2021-10-21 12:58:56 +03:00
Otto Ahoniemi 6a625b1da7 Remove faculty programmes from userservice 2021-10-20 16:45:25 +03:00
Otto Ahoniemi 4982bd8f3c Wrap everything needed to enricher 2021-10-20 16:35:18 +03:00
Otto Ahoniemi 908e33ccd3 Moar userservice cleanups 2021-10-19 15:12:39 +03:00
Otto Ahoniemi d429480f80 Remove faculties from rights returned by userservice 2021-10-19 14:21:09 +03:00
Otto Ahoniemi dada995398 Remove facultyprogramme route 2021-10-19 14:08:26 +03:00
Otto Ahoniemi 69c9b05657 Moar enriching from faculties 2021-10-19 14:06:06 +03:00
Petri Palmu db66880e25 kielikeskus added to the trends 2021-10-19 13:35:06 +03:00
Otto Ahoniemi 0b5ac0b61b Set modifyaccess to work on username, not internal id 2021-10-19 11:20:39 +03:00
Otto Ahoniemi a902961942 Cleanup users elementdetails usage 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 7c2fa532d4 Merge some user data fetching routes 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 36267f9fca Enrich all users list with facultyprogrammes on backend side 2021-10-19 11:08:06 +03:00
Otto Ahoniemi c2d99d6944 remove unused userservice ping 2021-10-19 11:08:06 +03:00
Otto Ahoniemi 0b1be20780 remove facultyprogramme tests 2021-10-19 11:08:06 +03:00
Otto Ahoniemi cc9f5f6906 Set trends to use static facultyarray instead of fetching from userservice 2021-10-19 11:08:06 +03:00
Otto Ahoniemi d5bca4dc57 proptypes :trash: 2021-10-19 11:08:06 +03:00
Mitja Karhusaari 5f8c468ceb Hotfix part 3 2021-10-18 14:48:17 +03:00
Otto AhoniemiandGitHub 434d45987d fix staging sourcemap prefix 2021-10-18 14:15:14 +03:00
Mitja Karhusaari 0cb8acc46a Hotfix part 2 2021-10-18 13:20:38 +03:00
Mitja Karhusaari 6e996fbc4c Hotfix: Trends data refreshing did not work in staging/prod 2021-10-18 13:11:46 +03:00
Saara Satokangas 86fbaaf1ca Improvements and cleaning up to Bachelor Honours 2021-10-18 07:33:35 +03:00
Saara Satokangas 517c216a5d Fix explorer not working if course not found 2021-10-18 07:33:16 +03:00
Mitja Karhusaari a04fb68aaa Allow displaying trends proportional to the number of students
Changes the yearly stats UI a bit, in order to make the currently
displayed unit more apparent.

Resolves #2665
2021-10-15 15:07:33 +03:00
Mitja Karhusaari 72c01fa345 Rework credit and student counting in trends 2021-10-15 15:07:33 +03:00
Petri Palmu 62f1cc5459 show by programme button color changed 2021-10-14 12:50:56 +03:00
Petri Palmu c9eceaebe4 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-10-13 12:10:21 +03:00
Otto Ahoniemi 92e8709ef6 Fix uudet ohjelmat to koulutusohjelmat 2021-10-13 11:33:30 +03:00
Otto Ahoniemi 9c5c135d0f Connect eläinlääkis lisensiaatti to faculty 2021-10-13 09:59:17 +03:00
Otto Ahoniemi c4f65a1f29 Explicitly set logging filename 2021-10-13 09:30:52 +03:00
Otto Ahoniemi 96f1c9d9cf Drop organisations from teacher stats 2021-10-13 09:05:09 +03:00
Otto Ahoniemi a8cfcb3371 Explicitly specify logging levels 2021-10-13 08:42:45 +03:00
Petri Palmu 63cbe523b1 correction to the ay codeless updater object spread 2021-10-12 12:49:36 +03:00
Mitja Karhusaari 7092fbbda0 UX fix: display filter's options always if it is in use 2021-10-11 15:27:42 +03:00
Mitja Karhusaari 82573c9d17 Allow filtering course statistics by students' study programmes
Resolves #2435
2021-10-11 15:27:42 +03:00
Mitja Karhusaari 1b98b0a847 Allow filtering by courses in course population view 2021-10-11 15:27:42 +03:00
Otto Ahoniemi b73174f8a5 Set backend to temporarily push logs to file as well 2021-10-11 15:21:43 +03:00
Otto Ahoniemi b1167eebf3 Update deprecated jStat to jstat package 2021-10-11 11:40:22 +03:00
Otto Ahoniemi 35391f9b36 cleanup some proptypes 2021-10-11 11:29:13 +03:00
Otto Ahoniemi 4fde6c319a Fix study guidance groups layout 2021-10-11 08:27:40 +03:00
Otto Ahoniemi ebd1a98ead Refactor users overview to same folder with single user page 2021-10-11 08:18:12 +03:00
Otto Ahoniemi b3ef64fc2e Remove unused json for oodis 2021-10-07 16:54:27 +03:00
Otto Ahoniemi 7bbc3c0cb0 sos revert what was i thinking 2021-10-07 16:39:16 +03:00
Otto Ahoniemi 90abe7eb22 Don't forward all 404's to index.html 2021-10-07 16:21:28 +03:00
Otto Ahoniemi 08dd4019af Fix sourcemaps prefix 2021-10-07 16:21:28 +03:00
Otto Ahoniemi 2841fd69a3 Refactor mocking under hook 2021-10-07 11:58:08 +03:00
Otto Ahoniemi 827d45c481 Add show as user to user list 2021-10-07 11:58:08 +03:00
Otto Ahoniemi 52ba8d582b Refactor userpage to use hooks instead of HOCs 2021-10-07 11:58:08 +03:00
Otto Ahoniemi d151521eae Refactor userpage to func component 2021-10-07 11:58:08 +03:00
Petri Palmu 6188fe6fe4 Merge branch 'filter' 2021-10-07 10:19:35 +03:00
Otto Ahoniemi 1f0eb93174 remove sis notification 2021-10-06 14:14:54 +03:00
Petri Palmu f50e632cdb possibility to select most attained courses or programme structure in study programmes 2021-10-06 13:12:01 +03:00
Mitja Karhusaari bbd83b501b Pre-define importer API URL in .env template file 2021-10-06 12:35:00 +03:00
Mitja Karhusaari 48048e5ecf Fix credit accumulation graph not showing on the first render.
Resolves #3242
2021-10-06 12:25:34 +03:00
Petri Palmu 5be0a74a60 proptype correction 2021-10-05 10:03:59 +03:00
Petri Palmu e8e10bcb9a Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-10-04 09:19:41 +03:00
Petri Palmu 3e3a95a113 possible correction to ay codeless course_units 2021-10-04 09:19:19 +03:00
Otto Ahoniemi 9a190b301d Setup updater + oodikone with cli 1 2021-10-01 09:53:29 +03:00
Otto AhoniemiandGitHub f976eca1a5 Merge pull request #3233 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/i-0.3.7
Bump i from 0.3.6 to 0.3.7 in /updater/sis-updater-scheduler
2021-10-01 09:21:50 +03:00
Petri Palmu a32b4695a9 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-10-01 08:46:52 +03:00
Petri Palmu d55e27f1d9 console logs removed 2021-10-01 08:46:39 +03:00
Otto Ahoniemi b749ad4033 Fix refreshers not awaited 2021-10-01 00:04:39 +03:00
Petri Palmu 6e15b8c355 ay codeless attainments mapped to ay courses 2021-09-30 13:08:13 +03:00
Petri Palmu f1196b49ae Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-09-30 13:03:41 +03:00
Petri Palmu e5b89dc844 codeless ay code attainments to ay courses 2021-09-30 13:01:07 +03:00
dependabot[bot]andGitHub 5d84d2f9a3 Bump i from 0.3.6 to 0.3.7 in /updater/sis-updater-scheduler
Bumps [i](https://github.com/pksunkara/inflect) from 0.3.6 to 0.3.7.
- [Release notes](https://github.com/pksunkara/inflect/releases)
- [Commits](https://github.com/pksunkara/inflect/compare/v0.3.6...v0.3.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-29 18:10:13 +00:00
Petri Palmu e06f12017d oodis to oodikone works 2021-09-29 14:22:07 +03:00
Petri Palmu db0e924f36 correction to ay codeless courseprovider 2021-09-28 12:01:07 +03:00
Petri Palmu 10d13b751b Merge branch 'master' into feature/avoin 2021-09-28 11:58:39 +03:00
Petri Palmu 87ef487fe8 creating courseprovider for non ay course if not exist 2021-09-28 11:27:50 +03:00
Petri Palmu 50968b4b66 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-09-28 10:49:08 +03:00
Petri Palmu 791e295eb7 adding codeless open uni works 2021-09-28 10:27:47 +03:00
Petri Palmu 4bef9c8daa possible way to add non ay courses 2021-09-27 13:59:07 +03:00
Petri Palmu 8050a56847 added oodis to oodikone 2021-09-27 09:07:09 +03:00
Petri Palmu e48247d80b added explanation to student stats if course is upgraded 2021-09-23 13:07:36 +03:00
Petri Palmu afdab20014 Merge branch 'master' into feature/avoin 2021-09-21 12:34:18 +03:00
Petri Palmu f477b3ef50 correction to the ay regex 2021-09-21 12:23:26 +03:00
Petri Palmu ab19b429ca new ay codeless courses works 2021-09-21 11:52:20 +03:00
Petri Palmu 2e6fdb4970 calculations for new ayless courses 2021-09-21 09:33:07 +03:00
Petri Palmu c918f862e3 Merge branch 'master' into feature/avoin 2021-09-16 13:29:24 +03:00
Petri Palmu 59bfbfcca3 adding ay code to the credits work 2021-09-09 13:59:50 +03:00
Petri Palmu 22e11948f5 avoin check from studyright_id implemented 2021-09-08 11:28:46 +03:00
Petri Palmu 5b5884b25d get avoi, studyright mapping works 2021-09-08 09:47:56 +03:00
128 changed files with 3626 additions and 43045 deletions
-1
View File
@@ -1,4 +1,3 @@
# Populate and change this to .env
IMPORTER_DB_API_TOKEN=
IMPORTER_DB_API_URL=
PATE_TOKEN=
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
environment: production
set_commits: 'skip'
sourcemaps: './build/static/js'
url_prefix: '~/oodikone/static/js'
url_prefix: '~/static/js'
version: ${{ steps.extract_version.outputs.VERSION }}
build_and_release_userservice:
+1 -1
View File
@@ -545,7 +545,7 @@ jobs:
environment: staging
set_commits: 'skip'
sourcemaps: './build/static/js'
url_prefix: '~/oodikone/static/js'
url_prefix: '~/static/js'
version: ${{ github.sha }}
- name: 'Push to docker hub'
run: |
+15 -11
View File
@@ -44,20 +44,22 @@ will take care of setting up Oodikone for you. See README for more details."
msg ""
}
# Define custom shell prompt for the interactive select loop
PS3="Please enter your choice: "
set_custom_select_prompt() {
PS3="Please enter your choice: "
options=(
"Set up oodikone from scratch."
"Reset all real data."
"Reset sis-importer data."
"Quit."
)
options=(
"Set up oodikone from scratch."
"Reset all real data."
"Reset single database."
"Quit."
)
}
# Run scripts before showing interactive prompt
# Run scripts and set the prompt
show_welcome
init_dirs
get_username
set_custom_select_prompt
while true; do
select opt in "${options[@]}"; do
@@ -66,8 +68,10 @@ while true; do
set_up_oodikone_from_scratch;;
"Reset all real data.")
reset_all_real_data;;
"Reset sis-importer data.")
reset_sis_importer_data;;
"Reset single database.")
reset_single_database
set_custom_select_prompt # Set prompt back to initial values
;;
"Quit.")
break 2;;
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
+50 -3
View File
@@ -1,5 +1,8 @@
/// <reference types="Cypress" />
const moment = require('moment')
const _ = require('lodash')
const setPopStatsUntil = (until, includeSettings = []) => {
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
includeSettings.forEach(setting => {
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
describe('Population Statistics tests', () => {
const pathToCSBach2017 =
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
const pathToCSMaster2019 =
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
describe('when using basic user', () => {
beforeEach(() => {
cy.init('/populations')
@@ -123,6 +128,51 @@ describe('Population Statistics tests', () => {
cy.contains('Credit accumulation (for 170 students)')
})
it('Credit Statistics, Credits Gained tab works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
cy.get('.credits-gained-table').should('contain', '(n=149)')
for (const [start, end] of ranges) {
let value = 'credits'
if (start !== null) {
value = `${start} ≤ ${value}`
}
if (end !== null) {
value = `${value} < ${end}`
}
cy.get('.credits-gained-table').should('contain', value)
}
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
expect(sum).to.equal(149)
})
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
})
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
cy.visit(pathToCSMaster2019)
cy.contains('Credit statistics').click()
cy.get('.credits-gained-divider').click()
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
})
it('Credit Statistics, Statistics pane works', () => {
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Credit statistics').click()
@@ -132,9 +182,6 @@ describe('Population Statistics tests', () => {
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
cy.get("[data-cy='credit-stats-q1']").should('contain', '70')
cy.get("[data-cy='credit-stats-q2']").should('contain', '129')
cy.get("[data-cy='credit-stats-q3']").should('contain', '168')
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
})
})
+15 -15
View File
@@ -4,16 +4,16 @@ describe('Users tests', () => {
describe('Using user with just grp-oodikone-user, no other rights', () => {
it('shows only frontpage, trends feedback', () => {
cy.init('', 'norights') // login with norights user
cy.get('.navBar').contains('Trends').should('exist')
cy.get('.navBar').contains('Give feedback').should('exist')
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
cy.get('.navBar').contains('Study programme').should('not.exist')
cy.get('.navBar').contains('Student statistics').should('not.exist')
cy.get('.navBar').contains('Course statistics').should('not.exist')
cy.get('.navBar').contains('Teachers').should('not.exist')
cy.get('.navBar').contains('Users').should('not.exist')
cy.get('.navBar').contains('Faculty').should('not.exist')
cy.get('.navBar').contains('Updater').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Study programme').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Teachers').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Updater').should('not.exist')
})
})
@@ -29,12 +29,12 @@ describe('Users tests', () => {
})
it('should see more stuff than others', () => {
cy.get('.navBar').contains('Study programme').should('exist')
cy.get('.navBar').contains('Student statistics').should('exist')
cy.get('.navBar').contains('Course statistics').should('exist')
// TODO: admin should probably see this as well, since can access this page: cy.get('.navBar').contains('Teachers').should('exist')
cy.get('.navBar').contains('Users').should('exist')
cy.get('.navBar').contains('Trends').should('exist')
cy.get('[data-cy="navBar"]').contains('Study programme').should('exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('exist')
// TODO: admin should probably see this as well, since can access this page: cy.get('[data-cy="navBar"]').contains('Teachers').should('exist')
cy.get('[data-cy="navBar"]').contains('Users').should('exist')
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
})
it("mocking normal user shows only the mocked user's programmes", () => {
+34 -6593
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -49,5 +49,9 @@
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
"stylelint-prettier": "^1.2.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.29.1"
}
}
+1 -2
View File
@@ -64,8 +64,7 @@ parse_profiles() {
return 0
}
# Set which docker-compose files to use based on version. In ci, profiles are also
# emptied, since they're passed from github actions.
# Set which docker-compose files to use based on version.
parse_env() {
env=""
if [[ "$version" == "real" ]]; then
+50 -8
View File
@@ -110,12 +110,54 @@ reset_all_real_data() {
reset_databases "${DATABASES[@]}"
}
reset_sis_importer_data() {
infomsg "Downloading sis-importer-db dump"
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
download_real_dump $database $url
reset_databases $database
reset_single_database() {
# Define custom shell prompt for the nested interactive select loop
PS3="Please enter which database to reset: "
options=(
"kone-db"
"sis-db"
"sis-importer-db"
"user-db"
"Return to main menu."
)
local database=""
local url=""
while true; do
select opt in "${options[@]}"; do
case $opt in
"kone-db")
local database=$KONE_DB_NAME
local url=$KONE_DB_REAL_DUMP_URL
break 2;;
"sis-db")
local database=$SIS_DB_NAME
local url=$SIS_DB_REAL_DUMP_URL
break 2;;
"sis-importer-db")
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
break 2;;
"user-db")
local database=$USER_DB_NAME
local url=$USER_DB_REAL_DUMP_URL
break 2;;
"Return to main menu.")
break 2;;
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
";;
esac
break
done
done
if [ -n "$database" ]; then
infomsg "Downloading $database dump"
download_real_dump $database $url
reset_databases $database
fi
}
set_up_oodikone_from_scratch() {
@@ -152,10 +194,10 @@ linting and formatting"
npm run prepare
infomsg "Pulling images"
"$PROJECT_ROOT"/run.sh oodikone anon pull
"$PROJECT_ROOT"/run.sh both anon pull
infomsg "Building images"
"$PROJECT_ROOT"/run.sh oodikone anon build
"$PROJECT_ROOT"/run.sh both anon build
successmsg "Setup ready, oodikone can be started! See README for more info."
}
-2
View File
@@ -18,7 +18,6 @@ const pateToken = process.env.PATE_API_TOKEN || ''
// Importer client
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
// Networking: Urls & ports
const baseUrl = isDev ? '/api' : ''
@@ -89,5 +88,4 @@ module.exports = {
frontUrl,
backendPort,
importerToken,
importerUrl,
}
+1 -1
View File
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
const migrations = await migrator.up()
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
} catch (e) {
logger.error('Kone Migration error')
logger.error({ message: 'Kone Migration error: ', meta: e })
throw e
}
}
@@ -0,0 +1,34 @@
const { BIGINT, STRING, DATE } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.createTable(
'study_guidance_group_tags',
{
id: {
primaryKey: true,
type: BIGINT,
autoIncrement: true,
},
study_guidance_group_id: {
type: STRING,
unique: true,
},
study_programme: {
type: STRING,
},
year: {
type: STRING,
},
created_at: {
type: DATE,
},
updated_at: {
type: DATE,
},
},
{ underscored: true, timestamps: true }
)
},
down: async () => {},
}
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
},
})
const StudyGuidanceGroupTag = sequelizeKone.define(
'study_guidance_group_tags',
{
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true,
},
studyGuidanceGroupId: {
type: Sequelize.STRING,
unique: true,
},
studyProgramme: {
type: Sequelize.STRING,
},
year: {
type: Sequelize.STRING,
},
},
{ underscored: true, timestamps: true }
)
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
foreignKey: 'label',
sourceKey: 'id',
@@ -157,4 +179,5 @@ module.exports = {
CustomPopulationSearch,
sequelizeKone,
ExcludedCourse,
StudyGuidanceGroupTag,
}
+3 -2
View File
@@ -1,5 +1,5 @@
const router = require('express').Router()
const userService = require('../services/userService')
const { getUserDataFor } = require('../services/userService')
const { sendFeedbackToToska } = require('../services/mailservice')
const logger = require('../util/logger')
@@ -8,7 +8,8 @@ router.post('/email', async (req, res) => {
const {
decodedToken: { userId },
} = req
const { email, full_name } = await userService.byUsername(userId)
const { email, full_name } = await getUserDataFor(userId)
const result = await sendFeedbackToToska({
feedbackContent: content,
+1 -12
View File
@@ -2,7 +2,6 @@ const router = require('express').Router()
const Student = require('../services/students')
const userService = require('../services/userService')
const Unit = require('../services/units')
const { mismatchedStudents } = require('../services/student_credit_total_mismatches')
const filterStudentTags = (student, userId) => {
return {
@@ -11,13 +10,6 @@ const filterStudentTags = (student, userId) => {
}
}
const creditTotalMismatch = student => {
return {
...student,
mismatch: mismatchedStudents.has(student.studentNumber),
}
}
router.get('/students', async (req, res) => {
const {
roles,
@@ -58,10 +50,7 @@ router.get('/students/:id', async (req, res) => {
const results = await Student.withId(studentId)
return results.error
? res.status(400).json({ error: 'error finding student' }).end()
: res
.status(200)
.json(creditTotalMismatch(filterStudentTags(results, decodedToken.id)))
.end()
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
}
const uid = req.decodedToken.userId
@@ -1,6 +1,6 @@
const router = require('express').Router()
const { logger } = require('../util/logger')
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
router.get('/', async (req, res) => {
const {
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
return res.json(await getAllGroupsAndStudents(sisPersonId))
})
router.put('/:id/tags', async (req, res) => {
const {
decodedToken: { uid, sisPersonId },
body: tags,
params: { id: groupId },
} = req
if (!sisPersonId) {
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
return res.status(400).json({ error: 'Not possible to change groups without personId' })
}
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
if (!groupsUserHasAccessTo.includes(groupId)) {
logger.error(
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
)
return res.status(400).json({ error: 'Access denied' })
}
return res.status(200).json(await changeGroupTags({ groupId, tags }))
})
module.exports = router
+2 -2
View File
@@ -53,12 +53,12 @@ router.get('/refresh_redis_cache', async (req, res) => {
})
router.post('/refresh_statistic_v2', async (req, res) => {
refreshStatistics()
await refreshStatistics()
res.status(200).json('Refreshing sis statistics')
})
router.post('/refresh_trends', async (req, res) => {
refreshTrends()
await refreshTrends()
res.status(200).json('Refreshing trends')
})
+4 -2
View File
@@ -25,9 +25,11 @@ router.get('/access_groups', async (req, res) => {
router.post('/modifyaccess', async (req, res) => {
try {
const { uid } = req.body
const { username } = req.body
const result = await userService.modifyAccess(req.body)
const user = await userService.byId(uid)
const user = {
username,
}
await addUserToBlacklist(user, req.decodedToken)
res.status(200).json(result)
} catch (e) {
+1 -1
View File
@@ -172,7 +172,7 @@ const allCodeAltenatives = async code => {
const temp = courses
.map(c => c.code)
.map(c => {
if (c.match(/^A/)) return [c, 4] // open university codes come last
if (c.match(/^AY?/)) return [c, 4] // open university codes come last
if (c.match(/^\d/)) return [c, 2] // old numeric codes come second
if (c.match(/^[A-Za-z]/)) return [c, 1] // new letter based codes come first
return [c, 3] // unknown, comes before open uni?
@@ -3,6 +3,154 @@ const { Organization, ProgrammeModule } = require('../models')
const Op = Sequelize.Op
const { dbConnections } = require('../database/connection')
// temp refactoring faculty <-> userservice mappings away from userservice
const facultiesAndProgrammesInUserService = [
['H10', 'MH10_001'],
['H10', 'MH40_011'],
['H10', 'KH10_001'],
['H20', 'KH20_001'],
['H20', 'MH20_001'],
['H20', 'MH20_002'],
['H30', 'KH30_001'],
['H30', 'KH30_002'],
['H30', 'MH30_002'],
['H30', 'MH30_003'],
['H30', 'MH30_004'],
['H30', 'MH30_005'],
['H30', 'MH30_001'],
['H40', 'KH40_001'],
['H40', 'KH40_002'],
['H40', 'KH40_003'],
['H40', 'KH40_004'],
['H40', 'KH40_005'],
['H40', 'KH40_006'],
['H40', 'MH40_001'],
['H40', 'MH40_002'],
['H40', 'MH40_003'],
['H40', 'MH40_004'],
['H40', 'MH40_005'],
['H40', 'MH40_006'],
['H40', 'MH40_007'],
['H40', 'MH40_008'],
['H40', 'MH40_009'],
['H40', 'MH40_010'],
['H40', 'MH40_011'],
['H40', 'MH40_012'],
['H40', 'MH40_013'],
['H40', 'MH40_014'],
['H40', 'MH40_015'],
['H40', 'MH70_001'],
['H40', 'MH70_006'],
['H50', 'KH50_001'],
['H50', 'KH50_002'],
['H50', 'KH50_003'],
['H50', 'KH50_004'],
['H50', 'KH50_005'],
['H50', 'KH50_006'],
['H50', 'KH50_007'],
['H50', 'MH50_001'],
['H50', 'MH50_002'],
['H50', 'MH50_003'],
['H50', 'MH50_004'],
['H50', 'MH50_005'],
['H50', 'MH50_006'],
['H50', 'MH50_007'],
['H50', 'MH50_008'],
['H50', 'MH50_009'],
['H50', 'MH50_010'],
['H50', 'MH50_011'],
['H50', 'MH50_012'],
['H50', 'MH50_013'],
['H55', 'KH55_001'],
['H55', 'MH55_001'],
['H57', 'KH57_001'],
['H57', 'KH57_002'],
['H57', 'KH57_003'],
['H57', 'MH50_002'],
['H57', 'MH50_013'],
['H57', 'MH57_001'],
['H57', 'MH57_002'],
['H57', 'MH57_003'],
['H57', 'MH57_004'],
['H57', 'MH57_005'],
['H57', 'MH80_007'],
['H60', 'KH60_001'],
['H60', 'MH60_001'],
['H70', 'KH40_001'],
['H70', 'KH50_001'],
['H70', 'KH70_001'],
['H70', 'KH70_002'],
['H70', 'KH70_003'],
['H70', 'KH70_004'],
['H70', 'MH50_001'],
['H70', 'MH57_005'],
['H70', 'MH70_001'],
['H70', 'MH70_002'],
['H70', 'MH70_003'],
['H70', 'MH70_004'],
['H70', 'MH70_005'],
['H70', 'MH70_006'],
['H70', 'MH70_007'],
['H70', 'MH70_008'],
['H70', 'MH70_009'],
['H70', 'MH70_010'],
['H74', 'KH74_001'],
['H80', 'KH57_002'],
['H80', 'KH80_001'],
['H80', 'KH80_002'],
['H80', 'KH80_003'],
['H80', 'KH80_004'],
['H80', 'MH57_002'],
['H80', 'MH57_003'],
['H80', 'MH57_005'],
['H80', 'MH80_001'],
['H80', 'MH80_002'],
['H80', 'MH80_003'],
['H80', 'MH80_004'],
['H80', 'MH80_005'],
['H80', 'MH80_006'],
['H80', 'MH80_007'],
['H90', 'KH90_001'],
['H30', '447000'],
['H30', '447001'],
['H30', '447002'],
['H60', 'EDUK730'],
['H60', 'K-MUUT-ERIL'],
['H60', 'ED400'],
['H60', 'ED200'],
['H60', 'ED700'],
['H60', 'ED100'],
['H60', 'ED300'],
['H60', 'ED600'],
['H60', '8009'],
['H60', '8118'],
['H60', '8600'],
['H60', 'DUK-PED'],
['H60', '65'],
['H60', '1102'],
['H60', '20018'],
['H60', '8601'],
['H60', '9290'],
['H60', '8172'],
['H60', '83600'],
['H60', 'DUM-PED'],
['H60', 'ED500'],
['H60', '83601'],
['H60', '0130'],
['H60', '20005-e'],
['H60', '1040'],
['H60', '8703'],
['H60', '0005'],
['H60', '7900'],
['H60', '0152 '],
['H90', 'MH90_001'],
]
const facultiesAndProgrammesForTrends = facultiesAndProgrammesInUserService.map(f => ({
faculty_code: f[0],
programme_code: f[1],
}))
// Have facultyfetching to work like it worked during oodi-db time
const facultiesInOodi = [
'H10',
@@ -66,4 +214,5 @@ module.exports = {
degreeProgrammeCodesOfFaculty,
isFaculty,
providersOfFaculty,
facultiesAndProgrammesForTrends,
}
@@ -6,7 +6,6 @@ const logger = require('../util/logger')
const { dbConnections } = require('../database/connection')
const recursivelyGetModuleAndChildren = async (code, type) => {
// TODO use only sis connection once sis is stable
const connection = dbConnections.sequelize
try {
const [result] = await connection.query(
File diff suppressed because it is too large Load Diff
@@ -181,6 +181,7 @@ const formatStudent = ({
passed: Credit.passed({ credittypecode }) || Credit.improved({ credittypecode }),
grade,
credits,
credittypecode,
isStudyModuleCredit: isStudyModule,
}
}
@@ -1,5 +1,6 @@
const { getImporterClient } = require('../util/importerClient')
const importerClient = getImporterClient()
const { StudyGuidanceGroupTag } = require('../models/models_kone')
const getAllStudentsUserHasInGroups = async sisPersonId => {
if (!importerClient) {
@@ -19,10 +20,33 @@ const getAllGroupsAndStudents = async sisPersonId => {
return []
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return Object.values(data)
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
const { studyProgramme, year } = curr
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
}, {})
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
}
const changeGroupTags = async ({ groupId, tags }) => {
const { studyProgramme, year } = tags
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
const [result] = await StudyGuidanceGroupTag.upsert(
{
studyGuidanceGroupId: groupId,
...tagToUpdate,
},
{
where: {
studyGuidanceGroupId: groupId,
},
}
)
return result
}
module.exports = {
getAllStudentsUserHasInGroups,
getAllGroupsAndStudents,
changeGroupTags,
}
@@ -2,7 +2,6 @@ const {
dbConnections: { sequelize },
} = require('../database/connection')
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
const { getUserElementDetails } = require('./userService')
const moment = require('moment')
const { redisClient } = require('../services/redis')
const _ = require('lodash')
@@ -140,15 +139,6 @@ const getAllStudyrightElementsAndAssociations = async () => {
return JSON.parse(studyright_elements)
}
const getStudyrightElementsAndAssociationsForUser = async username => {
const studyrightelementcodes = await getUserElementDetails(username)
if (studyrightelementcodes.length === 0) {
return []
}
const associations = await getAssociatedStudyrights(studyrightelementcodes)
return formatStudyrightElements(studyrightelementcodes, associations)
}
const getAllProgrammes = async () => {
const elementDetails = ElementDetail.findAll({
where: {
@@ -324,21 +314,13 @@ const getFilteredAssociations = async codes => {
return associations
}
const getUserAssociations = async userid => {
const codes = await getUserElementDetails(userid)
const associations = await getFilteredAssociations(codes)
return associations
}
module.exports = {
byStudent,
studentNumbersWithAllStudyRightElements,
getAssociatedStudyrights,
getAllStudyrightElementsAndAssociations,
getStudyrightElementsAndAssociationsForUser,
getAssociations,
getFilteredAssociations,
getUserAssociations,
refreshAssociationsInRedis,
getAllProgrammes,
getAllElementDetails,
@@ -191,6 +191,11 @@ const activeTeachers = async (providers, semestercodeStart, semestercodeEnd) =>
},
],
},
where: {
id: {
[Op.notLike]: '%hy-hlo-org%',
},
},
})
return teachers.map(({ id }) => id)
}
@@ -1,15 +1,8 @@
const axios = require('axios')
const { redisClient } = require('../../services/redis')
const { USERSERVICE_URL } = require('../../conf-backend')
const {
dbConnections: { sequelize },
} = require('../../database/connection')
const userServiceClient = axios.create({
baseURL: USERSERVICE_URL,
headers: { secret: process.env.USERSERVICE_SECRET },
})
const getRedisCDS = async REDIS_KEY => {
const raw = await redisClient.getAsync(REDIS_KEY)
return raw && JSON.parse(raw)
@@ -177,6 +170,5 @@ const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNon
module.exports = {
getRedisCDS,
saveToRedis,
userServiceClient,
getTargetStudentCounts,
}
@@ -4,9 +4,10 @@ const {
} = require('../../database/connection')
const { mapToProviders } = require('../../util/utils')
const { ElementDetail, Organization } = require('../../models')
const { getRedisCDS, saveToRedis, userServiceClient } = require('./shared')
const { getRedisCDS, saveToRedis } = require('./shared')
const { facultiesAndProgrammesForTrends } = require('../../services/organisations')
const REDIS_KEY_STATUS = 'STATUS_DATA_V2'
const REDIS_KEY_STATUS = 'STATUS_DATA_V5'
const getCurrentStudyYearStartDate = _.memoize(
async unixMillis =>
@@ -29,6 +30,140 @@ const getCurrentYearStartDate = () => {
return new Date(new Date().getFullYear(), 0, 1)
}
const getOrganizationStudents = async (start, end) => {
const rows = await sequelize.query(
`
SELECT
sre.code AS code,
ARRAY_AGG(DISTINCT(sr.student_studentnumber)) AS students
FROM studyright sr
INNER JOIN studyright_elements sre ON sre.studyrightid = sr.studyrightid
INNER JOIN element_details ed ON ed.code = sre.code
WHERE ed.type = 20 AND (:start, :end) OVERLAPS (sr.startdate, sr.enddate)
GROUP BY sre.code
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { start, end },
}
)
const codes = mapToProviders(rows.map(({ code }) => code))
const orgIdRows = await sequelize.query(
`
SELECT o.code, o.id FROM organization o WHERE o.code IN (:codes)
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { codes },
}
)
const orgIds = Object.fromEntries(orgIdRows.map(({ code, id }) => [code, id]))
return rows.reduce((acc, { code, students }) => {
const [orgCode] = mapToProviders([code])
const orgId = orgIds[orgCode]
acc[orgId] = students
return acc
}, {})
}
const getOrganizationCredits = async (start, end) => {
const rows = await sequelize.query(
`
SELECT
cp.organizationcode AS code,
-- ARRAY_AGG(DISTINCT(cr.student_studentnumber)) AS students,
SUM(cr.credits) AS credits
FROM credit cr
INNER JOIN course co ON co.code = cr.course_code
INNER JOIN course_providers cp ON cp.coursecode = co.id
WHERE
cr.attainment_date BETWEEN :start AND :end AND
cr.credittypecode IN (4, 9) AND
(cr."isStudyModule" = FALSE OR cr."isStudyModule" IS NULL)
GROUP BY cp.organizationcode
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { start, end },
}
)
return rows.reduce((acc, { code, credits }) => {
acc[code] = credits
return acc
}, {})
}
const getOrganizationStatistics = async (organizations, start, end) => {
const orgCredits = await getOrganizationCredits(start, end)
const orgStudents = await getOrganizationStudents(start, end)
const organizationStats = {}
let stack_pointer = 0
const stack = ['hy-university-root-id']
while (stack_pointer < stack.length) {
const orgId = stack[stack_pointer]
stack_pointer += 1
organizations.filter(({ parent_id }) => parent_id === orgId).forEach(({ id }) => stack.push(id))
}
const updateOrgStats = (orgId, stats, isDirect) => {
if (organizationStats[orgId] === undefined) {
organizationStats[orgId] = {
direct: { credits: 0, students: new Set() },
aggregated: { credits: 0, students: new Set() },
}
}
const orgStats = organizationStats[orgId]
const statsRefs = isDirect ? [orgStats.direct, orgStats.aggregated] : [orgStats.aggregated]
statsRefs.forEach(statsRef => {
statsRef.credits += stats.credits
stats.students.forEach(s => statsRef.students.add(s))
})
}
while (stack.length > 0) {
const orgId = stack.pop()
const credits = orgCredits[orgId] ?? 0
const students = orgStudents[orgId] ?? []
const org = organizations.find(({ id }) => id === orgId)
const parentOrgId = org.parent_id
updateOrgStats(orgId, { credits, students }, true)
if (parentOrgId) {
updateOrgStats(parentOrgId, organizationStats[orgId].aggregated, false)
}
}
return Object.entries(organizationStats).reduce((acc, [id, { direct, aggregated }]) => {
acc[id] = {
id,
direct: {
students: direct.students.size,
credits: direct.credits,
},
aggregated: {
students: aggregated.students.size,
credits: aggregated.credits,
},
}
return acc
}, {})
}
const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
return sequelize.query(
`
@@ -43,7 +178,7 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
WHERE
cr.attainment_date BETWEEN :a AND :b
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
AND cr.credittypecode IN (4, 9)
AND cr.credittypecode IN (4, 9)
GROUP BY co.id, o.code
-- HAVING SUM(cr.credits) > 0
`,
@@ -54,25 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
)
}
const sumMerge = (a, b) => {
if (!a) return b
if (!b) return a
return a + b
const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
return sequelize.query(
`
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
o.code AS organizationcode, co.code, co.id
FROM credit cr
INNER JOIN course co ON cr.course_code = co.code
INNER JOIN course_providers cp ON cp.coursecode = co.id
INNER JOIN organization o ON o.id = cp.organizationcode
AND cr.attainment_date BETWEEN :a AND :b
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
AND cr.credittypecode IN (4, 9) AND cp.organizationcode = 'hy-org-48645785'
AND cr.student_studentnumber NOT IN
(SELECT student_studentnumber FROM studyright
WHERE studyright.student_studentnumber = cr.student_studentnumber
AND cr.attainment_date BETWEEN studyright.startdate AND studyright.enddate)
GROUP BY co.id, o.code -- HAVING SUM(cr.credits) > 0
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { a, b },
}
)
}
const mergele = (a, b) => {
if (!a) return _.clone(b)
if (!b) return _.clone(a)
return _.mergeWith(a, b, sumMerge)
}
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2', avoin) => {
return years.map(
year =>
new Promise(async res => {
const diff = currentYear - year
const { from, to } = getRange(diff)
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
const creditsByCourse = avoin
? await getTotalOpenUniCreditsOfCoursesBetween(from, to, alias, alias2)
: await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
res(
creditsByCourse.map(c => {
c['year'] = year
@@ -83,6 +233,18 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
)
}
const getOrgStats = (stats, orgId) =>
stats[orgId] ?? {
accumulated: {
aggregated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
},
total: {
aggregated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
},
}
const calculateStatusStatistics = async (unixMillis, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
/* Memoize parses booleans into strings... */
@@ -90,40 +252,120 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
const startYear = startDate.getFullYear()
const startTime = startDate.getTime()
const yearRange = _.range(2017, startYear + 1)
const getRangeAcc = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to:
showByYear === 'true'
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
})
const getRangeTotal = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
})
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange,
diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to:
showByYear === 'true'
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
}),
getRangeAcc,
'acc',
'students'
'students',
false
)
const yearlyOpenUniAccCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange,
getRangeAcc,
'acc',
'students',
true
)
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange.slice(0, -1),
diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
}),
getRangeTotal,
'total',
'students'
'students',
false
)
const yearlyOpenUniTotalCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange.slice(0, -1),
getRangeTotal,
'total',
'students',
true
)
/* Gather all required data */
const [yearlyAccCredits, yearlyTotalCredits, elementDetails, faculties, { data: facultyProgrammes }] =
await Promise.all([
Promise.all(yearlyAccCreditsPromises),
Promise.all(yearlyTotalCreditsPromises),
ElementDetail.findAll(),
Organization.findAll(),
userServiceClient.get('/faculty_programmes'),
])
const [
yearlyAccCredits,
yearlyTotalCredits,
yearlyOpenUniAccCredits,
yearlyOpenUniTotalCredits,
elementDetails,
faculties,
] = await Promise.all([
Promise.all(yearlyAccCreditsPromises),
Promise.all(yearlyTotalCreditsPromises),
Promise.all(yearlyOpenUniAccCreditsPromises),
Promise.all(yearlyOpenUniTotalCreditsPromises),
ElementDetail.findAll(),
Organization.findAll(),
])
const facultyProgrammes = facultiesAndProgrammesForTrends
const yearlyOrgStatPromises = yearRange.map(async year => {
const accumulatedStats = await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
showByYear === 'true'
? new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - (startYear - year) * YEAR_TO_MILLISECONDS)
)
const totalStats =
year !== startYear &&
(await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
))
return [
year,
_.merge(
_.mapValues(totalStats, total => ({ total })),
_.mapValues(accumulatedStats, accumulated => ({ accumulated }))
),
]
})
const orgStats = await Promise.all(yearlyOrgStatPromises)
const yearlyOrgStats = Object.fromEntries(orgStats)
const [[, currentOrgStats], [, prevOrgStats]] = orgStats.reverse()
const defaultYearStats = _.chain(_.range(2017, startYear + 1))
.map(year => [year, { acc: 0, accStudents: 0 }])
.fromPairs()
.value()
const getOrgYearlyStats = orgId =>
_.chain(yearlyOrgStats)
.mapValues(stats => {
const orgStats = getOrgStats(stats, orgId)
return {
acc: orgStats.accumulated.aggregated.credits,
accStudents: orgStats.accumulated.aggregated.students,
total: orgStats.total?.aggregated?.credits,
totalStudents: orgStats.total?.aggregated?.students,
}
})
.defaults(defaultYearStats)
.value()
/* Construct some helper maps */
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
@@ -137,6 +379,9 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return res
}, {})
programmeToFaculties['H906'] = ['H906']
programmeToFaculties['H930'] = ['H930']
const providerToProgramme = elementDetails.reduce((res, curr) => {
const [p] = mapToProviders([curr.code])
res[p] = {
@@ -146,21 +391,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return res
}, {})
providerToProgramme['H906'] = {
code: 'H906',
name: { en: 'Language Centre', fi: 'Kielikeskus', sv: 'Språkcentrum' },
}
providerToProgramme['H930'] = {
code: 'H930',
name: { en: 'Open University', fi: 'Avoin yliopisto', sv: 'Öppna universitetet' },
}
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
acc[org.code] = org
return acc
}, {})
const sumMerge = (a, b) => {
if (!a) return b
if (!b) return a
return a + b
}
const mergele = (a, b) => {
if (!a) return _.clone(b)
if (!b) return _.clone(a)
return _.mergeWith(a, b, sumMerge)
}
/* Calculate course level stats and group by providers */
const coursesGroupedByProvider = Object.entries(
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
_.groupBy(
[
..._.flatten(yearlyAccCredits),
..._.flatten(yearlyTotalCredits),
..._.flatten(yearlyOpenUniAccCredits),
..._.flatten(yearlyOpenUniTotalCredits),
],
'organizationcode'
)
).reduce((acc, [organizationcode, courseCredits]) => {
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
(acc, [courseCode, yearlyInstances]) => {
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
const array = [2020, 2019, 2018, 2017]
acc[courseCode] = {
yearly: {},
name: yearlyInstances[0].name,
type: 'course',
code: courseCode,
}
const array = _.range(2017, startYear + 1)
array.forEach(year => {
acc[courseCode]['yearly'][year] = {}
acc[courseCode]['yearly'][year]['acc'] = 0
acc[courseCode]['yearly'][year]['accStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = 0
acc[courseCode]['yearly'][year]['totalStudents'] = 0
acc[courseCode]['yearly'][year]['total'] = undefined
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
})
yearlyInstances.forEach(instance => {
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
@@ -172,6 +460,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
}
})
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
@@ -186,17 +475,47 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
/* Map providers into proper programmes and calculate programme level stats */
const groupedByProgramme = Object.entries(coursesGroupedByProvider).reduce((acc, [organizationcode, courses]) => {
const programme = providerToProgramme[organizationcode]
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
if (programme && programme.code) {
if (programme.code === 'H906' || programme.code === 'H930') {
const courseValues = Object.values(courses)
const yearlyValues = courseValues.map(c => c.yearly)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current') | undefined,
previous: _.sumBy(courseValues, 'previous') | undefined,
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
}
return acc
}
const orgId = organizationCodeToOrganization[organizationcode]?.id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated.aggregated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated.aggregated
const yearly = getOrgYearlyStats(orgId)
acc[programme.code] = {
type: 'programme',
code: programme.code,
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current'),
previous: _.sumBy(courseValues, 'previous'),
yearly,
current,
previous,
currentStudents: currentStudents,
previousStudents: previousStudents,
}
}
return acc
}, {})
@@ -206,19 +525,46 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
if (!facultyCodes) return acc
facultyCodes.forEach(facultyCode => {
if (!facultyCode) return
if (!acc[facultyCode]) {
acc[facultyCode] = {
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: {},
current: 0,
previous: 0,
if (facultyCode === 'H906' || facultyCode === 'H930') {
if (!acc[facultyCode]) {
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: programmeStats.yearly,
current: programmeStats.current,
previous: programmeStats.previous,
currentStudents: programmeStats.currentStudents,
previousStudents: programmeStats.previousStudents,
}
}
}
if (!acc[facultyCode]) {
const orgId = organizationCodeToOrganization[facultyCode].id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
.aggregated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
.aggregated
const yearly = getOrgYearlyStats(orgId)
acc[facultyCode] = {
type: 'faculty',
code: facultyCode,
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly,
current,
previous,
currentStudents,
previousStudents,
}
}
acc[facultyCode]['drill'][programmeCode] = programmeStats
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
acc[facultyCode]['current'] += programmeStats.current
acc[facultyCode]['previous'] += programmeStats.previous
})
return acc
}, {})
+96 -108
View File
@@ -1,24 +1,42 @@
const axios = require('axios')
const _ = require('lodash')
const { USERSERVICE_URL } = require('../conf-backend')
const UnitService = require('./units')
const elementDetailService = require('./elementdetails')
const { filterStudentnumbersByAccessrights } = require('./students')
const { userDataCache } = require('./cache')
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
const { getImporterClient } = require('../util/importerClient')
const logger = require('../util/logger')
const { facultiesAndProgrammesForTrends } = require('../services/organisations')
const ping = async () => {
const url = '/ping'
const response = await axios.get(url)
return response.data
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
// Private helpers
const enrichProgrammesFromFaculties = faculties =>
facultiesAndProgrammesForTrends.filter(f => faculties.includes(f.faculty_code)).map(f => f.programme_code)
const enrichWithProgrammes = toEnrich => {
if (Array.isArray(toEnrich)) {
return toEnrich.map(obj => enrichWithProgrammes(obj))
}
if (toEnrich.rights) {
return {
...toEnrich,
rights: _.union(toEnrich.rights, enrichProgrammesFromFaculties(toEnrich.faculties)),
}
}
if (toEnrich.elementdetails) {
return {
...toEnrich,
elementdetails: _.union(
toEnrich.elementdetails,
enrichProgrammesFromFaculties(toEnrich.faculty.map(f => f.faculty_code))
),
}
}
return toEnrich
}
const findAll = async () => {
const response = await client.get('/findall')
return response.data
}
const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
if (!hyPersonSisuId) {
logger.error('Not possible to get groups without personId header')
@@ -30,101 +48,20 @@ const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
return data && Object.values(data).length > 0
}
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
const response = await client.post('/login', {
uid,
full_name,
hyGroups,
affiliations,
email,
hyPersonSisuId,
hasStudyGuidanceGroupAccess,
})
return response.data
}
const superlogin = async (uid, asUser) => {
const response = await client.post('/superlogin', {
uid,
asUser,
})
return response.data
}
const byUsername = async uid => {
const url = `/user/${uid}`
const byUsernameData = async username => {
// OK, enriched
const url = `/user/${username}/user_data`
const response = await client.get(url)
return response.data
return enrichWithProgrammes(response.data)
}
const byUsernameData = async uid => {
const url = `/user/${uid}/user_data`
const response = await client.get(url)
return response.data
}
const getUserElementDetails = async username => {
const url = `/user/elementdetails/${username}`
const response = await client.get(url)
const elementdetailcodes = response.data
return elementDetailService.byCodes(elementdetailcodes)
}
const byId = async id => {
const url = `/user/id/${id}`
const response = await client.get(url)
return response.data
}
const updateUser = async (uid, fields) => {
userDataCache.del(uid)
const url = `/user/${uid}`
const response = await client.put(url, fields)
return response.data
}
const getRolesFor = async user => {
const url = `/get_roles/${user}`
const response = await client.get(url)
return response.data
}
const enableElementDetails = async (uid, codes) => {
const response = await client.post('/add_rights', { uid, codes })
return response.data.user
}
const removeElementDetails = async (uid, codes) => {
const response = await client.post('/remove_rights', { 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 elementDetails = await getUserElementDetails(username)
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
}
const modifyAccess = async body => {
const response = await client.post('/modifyaccess', body)
return response.data
}
const getAccessGroups = async () => {
const response = await client.get('/access_groups')
return response.data
}
const getUserDataFor = async uid => {
let userData = userDataCache.get(uid)
// Exported helpers
const getUserDataFor = async username => {
// no need to enrich
let userData = userDataCache.get(username)
if (!userData) {
userData = await byUsernameData(uid)
userDataCache.set(uid, userData)
userData = await byUsernameData(username)
userDataCache.set(username, userData)
}
return {
@@ -133,9 +70,21 @@ const getUserDataFor = async uid => {
}
}
const getUnitsFromElementDetails = async username => {
// No need to enrich
let userData = userDataCache.get(username)
if (!userData) {
userData = await byUsernameData(username)
userDataCache.set(username, userData)
}
const elementDetails = await elementDetailService.byCodes(userData.rights)
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
}
const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
// No need to enrich
let studentsUserCanAccess
if (roles && roles.includes('admin')) {
if (roles?.includes('admin')) {
studentsUserCanAccess = new Set(studentnumbers)
} else {
const unitsUserCanAccess = await getUnitsFromElementDetails(userId)
@@ -145,12 +94,52 @@ const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
return studentsUserCanAccess
}
// FOR ROUTES 1-to-1 mapping
const modifyAccess = async body => enrichWithProgrammes((await client.post('/modifyaccess', body)).data)
const getAccessGroups = async () => enrichWithProgrammes((await client.get('/access_groups')).data)
const enableElementDetails = async (uid, codes) =>
enrichWithProgrammes((await client.post('/add_rights', { uid, codes })).data.user)
const findAll = async () => enrichWithProgrammes((await client.get('/findall')).data)
const setFaculties = async (uid, faculties) =>
enrichWithProgrammes((await client.post('/set_faculties', { uid, faculties })).data.user)
const removeElementDetails = async (uid, codes) =>
enrichWithProgrammes((await client.post('/remove_rights', { uid, codes })).data.user)
const updateUser = async (username, fields) => {
userDataCache.del(username)
return enrichWithProgrammes((await client.put(`/user/${username}`, fields)).data)
}
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
return (
await client.post('/login', {
uid,
full_name,
hyGroups,
affiliations,
email,
hyPersonSisuId,
hasStudyGuidanceGroupAccess,
})
).data
}
const superlogin = async (uid, asUser) =>
(
await client.post('/superlogin', {
uid,
asUser,
})
).data
module.exports = {
ping,
byUsername,
updateUser,
byId,
getUserElementDetails,
login,
superlogin,
enableElementDetails,
@@ -159,7 +148,6 @@ module.exports = {
modifyAccess,
getAccessGroups,
getUnitsFromElementDetails,
getRolesFor,
setFaculties,
getUserDataFor,
getStudentsUserCanAccess,
+4 -4
View File
@@ -1,17 +1,17 @@
const axios = require('axios')
const { importerToken, importerUrl } = require('../conf-backend')
const { importerToken } = require('../conf-backend')
const logger = require('./logger')
const importerClient = axios.create({
headers: {
token: importerToken,
},
baseURL: importerUrl,
baseURL: 'https://importer.cs.helsinki.fi',
})
const getImporterClient = () => {
if (!importerToken || !importerUrl) {
logger.error("Importer token or importer url not set, can't return client!")
if (!importerToken) {
logger.error("Importer token not set, can't return client!")
return null
}
return importerClient
+6 -9
View File
@@ -1,13 +1,10 @@
const winston = require('winston')
const { isProduction } = require('../conf-backend')
const { isDev } = require('../conf-backend')
const { combine, timestamp, printf, splat } = winston.format
const transports = []
let transports = []
transports.push(new winston.transports.File({ filename: 'debug.log' }))
if (!isProduction) {
if (isDev) {
const devFormat = printf(
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
)
@@ -18,9 +15,7 @@ if (!isProduction) {
format: combine(splat(), timestamp(), devFormat),
})
)
}
if (isProduction) {
} else {
const levels = {
error: 0,
warn: 1,
@@ -43,4 +38,6 @@ if (isProduction) {
const logger = winston.createLogger({ transports })
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
module.exports = logger
+80 -38
View File
@@ -1912,6 +1912,24 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
},
"@reduxjs/toolkit": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.6.2.tgz",
"integrity": "sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA==",
"requires": {
"immer": "^9.0.6",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
"dependencies": {
"immer": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz",
"integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ=="
}
}
},
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
@@ -2403,9 +2421,9 @@
"integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ=="
},
"@types/react": {
"version": "17.0.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.14.tgz",
"integrity": "sha512-0WwKHUbWuQWOce61UexYuWTGuGY/8JvtUe/dtQ6lR4sZ3UiylHotJeWpf3ArP9+DSGUoLY3wbU59VyMrJps5VQ==",
"version": "17.0.34",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz",
"integrity": "sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==",
"requires": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -2413,9 +2431,9 @@
}
},
"@types/react-redux": {
"version": "7.1.18",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.18.tgz",
"integrity": "sha512-9iwAsPyJ9DLTRH+OFeIrm9cAbIj1i2ANL3sKQFATqnPWRbg+jEFXyZOKHiQK/N86pNRXbb4HRxAxo0SIX1XwzQ==",
"version": "7.1.20",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz",
"integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==",
"requires": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
@@ -4979,9 +4997,9 @@
}
},
"csstype": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
},
"cyclist": {
"version": "1.0.1",
@@ -6959,6 +6977,37 @@
"mime-types": "^2.1.12"
}
},
"formik": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
"integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
"requires": {
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^1.10.0"
},
"dependencies": {
"deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
},
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}
}
},
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -8228,11 +8277,6 @@
"istanbul-lib-report": "^3.0.0"
}
},
"jStat": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/jStat/-/jStat-1.8.6.tgz",
"integrity": "sha512-Oh/ePZVSoFigme69pHTQudcGh64cpNH9Lz3hBZcRJWLrDqpw7JfuYU9F3dj9py3tBYmHz7og7ZT8hXTNbYq9Rw=="
},
"jest": {
"version": "26.6.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-26.6.0.tgz",
@@ -12520,11 +12564,6 @@
"whatwg-fetch": "^3.4.1"
}
},
"react-context-devtool": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/react-context-devtool/-/react-context-devtool-2.0.3.tgz",
"integrity": "sha512-GVpDaQqRsVoofcbViVWelWTUHNjk4CAA81ZU+TVjEwZUTjhwOgKDM4t6yGfLBoEEG8PFYKbp9QqzyWw2BASe5Q=="
},
"react-datetime": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-3.0.4.tgz",
@@ -12729,22 +12768,25 @@
}
},
"react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
"integrity": "sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==",
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",
"integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==",
"requires": {
"@babel/runtime": "^7.12.1",
"@types/react-redux": "^7.1.16",
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
"hoist-non-react-statics": "^3.3.2",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^16.13.1"
"react-is": "^17.0.2"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"@babel/runtime": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz",
"integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
},
@@ -13031,17 +13073,17 @@
}
},
"redux": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.0.tgz",
"integrity": "sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz",
"integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==",
"requires": {
"@babel/runtime": "^7.9.2"
}
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz",
"integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA=="
},
"regenerate": {
"version": "1.4.2",
@@ -13214,9 +13256,9 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.1.tgz",
"integrity": "sha512-Jjt8Us6hAWJpjucyladHvUGR+q1mHHgWtGDXlhvvKyNyIeQ3bjuWLDX0bsTLhbm/gd4iXEACBlODUHBlLWiNnA=="
},
"resolve": {
"version": "1.18.1",
+3 -6
View File
@@ -6,13 +6,14 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
},
"dependencies": {
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.1",
"formik": "^2.2.9",
"highcharts": "^9.1.2",
"highcharts-custom-events": "^3.0.10",
"history": "^4.10.1",
"jStat": "^1.8.6",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
@@ -22,18 +23,14 @@
"qs": "^6.10.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-context-devtool": "^2.0.3",
"react-datetime": "^3.0.4",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.2",
"react-redux": "^7.2.4",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3",
"unfuck-spa-shibboleth-session": "^3.1.3",
-5
View File
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
</LanguageProvider>
)
CommonContext.propTypes = {
children: PropTypes.node.isRequired,
}
export default CommonContext
+23 -16
View File
@@ -2,6 +2,7 @@ import axios from 'axios'
import * as Sentry from '@sentry/browser'
import { getMocked, setMocking } from '../common'
import { apiBasePath, isDev } from '../conf'
import { loginPrefix, logoutPrefix } from '../redux/auth'
const getHeaders = () => {
// Set up dev user for development environment, mimicking production admin user
@@ -19,16 +20,16 @@ const getHeaders = () => {
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
const types = {
attempt: prefix => `${prefix}ATTEMPT`,
failure: prefix => `${prefix}FAILURE`,
success: prefix => `${prefix}SUCCESS`,
export const actionSuffixes = {
attempt: `ATTEMPT`,
failure: `FAILURE`,
success: `SUCCESS`,
}
export const actionTypes = prefix => ({
attempt: types.attempt(prefix),
failure: types.failure(prefix),
success: types.success(prefix),
attempt: `${prefix}${actionSuffixes.attempt}`,
failure: `${prefix}${actionSuffixes.failure}`,
success: `${prefix}${actionSuffixes.success}`,
})
export const logout = async () => {
@@ -69,7 +70,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
params,
onProgress,
}
return { type: `${prefix}ATTEMPT`, requestSettings }
const { attempt } = actionTypes(prefix)
return { type: attempt, requestSettings }
}
const handleError = (err, actionHistory = []) => {
@@ -90,20 +92,24 @@ export const handleRequest = store => next => async action => {
const { requestSettings } = action
if (requestSettings) {
const { route, method, data, prefix, query, params, onProgress } = requestSettings
const { success, failure } = actionTypes(prefix)
try {
const res = await callApi(route, method, data, params, 0, onProgress)
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
store.dispatch({ type: success, response: res.data, query })
} catch (e) {
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
store.dispatch({ type: failure, response: e, query })
handleError(e, store.getState().actionHistory)
}
}
}
export const handleAuth = store => next => async action => {
const loginTypes = actionTypes(loginPrefix)
const logoutTypes = actionTypes(logoutPrefix)
next(action)
const { type } = action
if (type === 'LOGIN_ATTEMPT') {
if (type === loginTypes.attempt) {
const { success, failure } = loginTypes
try {
let token
const mocked = getMocked()
@@ -118,17 +124,18 @@ export const handleAuth = store => next => async action => {
...api.defaults.headers.common,
'x-access-token': token,
}
store.dispatch({ type: 'LOGIN_SUCCESS', token })
store.dispatch({ type: success, token })
} catch (err) {
store.dispatch({ type: 'LOGIN_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
} else if (type === 'LOGOUT_ATTEMPT') {
} else if (type === loginTypes.attempt) {
const { success, failure } = logoutTypes
try {
await logout()
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
store.dispatch({ type: success })
} catch (err) {
store.dispatch({ type: 'LOGOUT_FAILURE' })
store.dispatch({ type: failure })
handleError(err, store.getState().actionHistory)
}
}
@@ -57,17 +57,7 @@ export default {
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
eikä lukuvuoden alusta.
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
`,
statusGraduated: `
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
@@ -19,16 +19,28 @@ export default {
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
CreditStatistics: `**Opintopistekertymä**
Taulukon rivit skaalautuvat valitun tarkastelujakson mukaan.
Jos tarkastelujaksoksi valitsee 12 kuukautta, on ylin kategoria 55 opintopistettä tai yli, eli opiskelija etenee tavoiteajassa.
Taulukosta näkee silmäyksellä tavoiteajassa etenevät (ylin kategoria) ja esim. nollasuorittajat.
**Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.**
CreditStatistics: `
**Credits gained**
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
**Credit statistics**
Taulukko kertoo kaikkien tarkasteluun valittujen opiskelijoiden opintopistemääristä tähän päivään mennessä. Statistiikka on suodatettu käytettyjen filttereiden mukaan.
Esimerkiksi, jos mukaan on valittu vain naisopiskelijat, statistiikat koskevat vain heitä.
+ **Total** on kokonaisopintopistemäärä kyseiselle populaatiolle.
+ **Mean** kuvaa opintopistemäärän opiskelijakohtaista keskiarvoa.
+ **Keskihajonta** kuvaa vaihtelua opintopistemäärissä. Karkeasti, mitä suurempi keskihajonta, sitä enemmän opintopistemäärissä on vaihtelua opiskelijoiden kesken.
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
`,
CoursesOfPopulation: `**Courses of population**
+23
View File
@@ -2,6 +2,8 @@ import React, { useCallback, useState, useEffect, useRef } from 'react'
import { chunk, isEqual } from 'lodash'
import qs from 'query-string'
import { useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'
import { setMocking } from './index'
import { SEARCH_HISTORY_VERSION } from '../constants'
import TSA from './tsa'
@@ -276,3 +278,24 @@ export const useIsAdmin = () => {
if (!roles) return false
return roles.some(role => role.group_code === 'admin')
}
// From: https://www.joshwcomeau.com/snippets/react-hooks/use-toggle/
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
export const useShowAsUser = () => {
const history = useHistory()
const showAsUser = uid => {
setMocking(uid)
history.push('/')
window.location.reload()
}
return showAsUser
}
+38 -5
View File
@@ -12,11 +12,6 @@ export const setMocking = userid =>
userid ? localStorage.setItem(MOCK_USERID, userid) : localStorage.removeItem(MOCK_USERID)
export const getMocked = () => localStorage.getItem(MOCK_USERID)
export const SIS_WARNING_FLAG = 'SIS_WARNING_FLAG'
export const setHideSisWarningFlag = booleanFlag =>
booleanFlag ? localStorage.setItem(SIS_WARNING_FLAG, booleanFlag) : localStorage.removeItem(SIS_WARNING_FLAG)
export const getHideSisWarningFlag = () => localStorage.getItem(SIS_WARNING_FLAG)
export const textAndDescriptionSearch = (dropDownOptions, param) =>
filter(dropDownOptions, option =>
option.text
@@ -228,3 +223,41 @@ export const validateInputLength = (input, minLength) => input && input.trim().l
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
export const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
// These are the new Bachelor's programmes in Matlu, that have BH possibility
export const bachelorHonoursProgrammes = [
'KH50_001',
'KH50_002',
'KH50_003',
'KH50_004',
'KH50_005',
'KH50_006',
'KH50_007',
'KH50_008',
]
// Basic modules from Sisu programme structures. All modules that can be found for the past years' structures
// taken into account
export const bachelorHonoursBasicModules = {
KH50_001: ['MAT110', 'MAT120', 'MAT130', 'MAT120', 'TKT1'],
KH50_002: ['FYS1100', 'FYS1200'],
KH50_003: ['KEK100'],
KH50_004: ['MAT110', 'FYS1100', 'FYS1200', 'KEK100'],
KH50_005: ['TKT1'],
KH50_006: ['GEOK_100'],
KH50_007: ['MAA-100'],
KH50_008: ['BSCH1000', 'BSCS1000', 'BSMA1000', 'BSMS1000', 'BSPH1000'],
}
// Similarly intermediate modules from Sisu programme structures. All modules that can be found for the past years' structures
// taken into account
export const bachelorHonoursIntermediateModules = {
KH50_001: ['mat217', 'MAT210', 'MAT220', 'MAT240', 'MAT213', 'MAT230', 'MAT218', 'MAT011'],
KH50_002: ['FYS2100', 'FYS2200', 'FYS2300', 'FYS2400', 'FYS2500', 'FYS2600'],
KH50_003: ['KEK200'],
KH50_004: ['MFK-M200', 'MFK-F200', 'MFK-K200'],
KH50_005: ['TKT2'],
KH50_006: ['GEOK_200'],
KH50_007: ['MAA-200'],
KH50_008: ['BSCH2000', 'BSCS2000', 'BSMA2000', 'BSMS2000', 'BSPH2000'],
}
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment } from 'semantic-ui-react'
import { Switch, Route, Link, useRouteMatch } from 'react-router-dom'
@@ -32,20 +31,10 @@ const NavTile = ({ to, title, imageSrc }) => {
)
}
NavTile.propTypes = {
to: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
}
const NavTiles = ({ children }) => {
return <div className="cool-ds-nav-tiles">{children}</div>
}
NavTiles.propTypes = {
children: PropTypes.node.isRequired,
}
const baseURI = '/cool-data-science'
const CoolDataScience = () => {
@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'
import PropTypes, { shape, bool, func } from 'prop-types'
import { connect } from 'react-redux'
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Divider, Segment, Button, Popup, Loader, Icon, Checkbox, Form, Breadcrumb } from 'semantic-ui-react'
import _ from 'lodash'
import moment from 'moment'
import ReactMarkdown from 'react-markdown'
import Datetime from 'react-datetime'
import { useHistory, useLocation } from 'react-router-dom'
import TSA from '../../common/tsa'
import { getTextIn } from '../../common'
import { useLocalStorage } from '../../common/hooks'
@@ -28,31 +28,104 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
}
const StatusContainer = ({
title,
current,
previous,
clickable,
handleClick,
min1,
max1,
showYearlyValues,
yearlyValues,
showByYear,
}) => {
const diff = Math.round(current - previous)
const actionTooltips = {
showByYear: {
label: 'Näytä kalenterivuosittain',
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
long: `
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
`,
},
showYearlyValues: {
label: 'Näytä edelliset vuodet',
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
long: `
Näyttää tilastot vuodesta 2017 eteenpäin.
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
`,
},
showRelativeValues: {
label: 'Näytä suhteutettuna opiskelijoiden määrään',
short:
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
long: `
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
`,
},
showCountingFrom: {
label: 'Näytä päivänä',
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
long: `
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
`,
},
}
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
const title = getTextIn(stats.name)
const clickable = !!stats.drill
let current
let previous
if (!stats.drill) {
current = stats.currentStudents
previous = stats.previousStudents
} else {
current = stats.current
previous = stats.previous
if (showRelativeValues) {
current /= stats.currentStudents
previous /= stats.previousStudents
}
}
const diff = current - previous
const p = getP(current, previous)
const change = Math.round((p - 1) * 1000) / 10
const change = p - 1
const getColor = v => {
if (v > 2.5) return '#6ab04c'
if (v < -2.5) return '#ff7979'
if (v > 0.025) return '#6ab04c'
if (v < -0.025) return '#ff7979'
return '#7B9FCF'
}
const plussify = x => {
if (x > 0) return `+${x.toLocaleString('fi')}`
return x.toLocaleString('fi')
const plussify = (x, decimals = 0) =>
x.toLocaleString('fi', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: 'always',
})
const getDisplayValue = (value, denominator) => {
let displayValue
if (denominator === 0) {
displayValue = value === 0 ? 0 : Infinity
} else if (showRelativeValues) {
displayValue = value / denominator
} else {
displayValue = value
}
return displayValue.toLocaleString('fi', {
minimumFractionDigits: showRelativeValues ? 2 : 0,
maximumFractionDigits: showRelativeValues ? 2 : 0,
})
}
return (
@@ -80,225 +153,86 @@ const StatusContainer = ({
overflowWrap: 'break-word',
}}
>
<span className="status-title">{title}</span>
<Popup position="bottom center" size="tiny" trigger={<span className="status-title">{title}</span>}>
{stats.code}
</Popup>
<div style={{ margin: '10px 0' }}>
<Icon
style={{
color: getColor(change),
transform: `rotateZ(${-mapValueToRange(change, min1, max1, -45, 45)}deg)`,
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
transitionProperty: 'transform',
transitionDuration: '200ms',
}}
size="huge"
name="arrow right"
/>
</div>
<div>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>{plussify(diff)}</span>
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
{plussify(diff, showRelativeValues ? 2 : 0)}
</span>
<br />
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
<br />
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change)}%)</span>
</div>
</div>
{showYearlyValues && (
<div style={{ marginTop: '10px', textAlign: 'start' }}>
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(
([year, { acc, total, accStudents, totalStudents }]) => {
return (
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
<span>
<b>
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>{' '}
{/* render num of students instead of credits if no credits are given from the course */}
{accStudents <= acc ? Math.round(acc).toLocaleString('fi') : accStudents}
{/* if no total students add students after accStudents */}
{acc <= accStudents && totalStudents < 1 && ' students'}
{/* render total if there are credits or students for either */}
{(total > 0 || totalStudents > 0) &&
` / ${
// same logic as before to check if render num of students or credits
totalStudents <= total && total > 0
? Math.round(total).toLocaleString('fi')
: `${totalStudents} students`
}`}
</span>
</div>
)
}
)}
<div className={`years item-type-${stats.type}`}>
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
return (
<span className="year-row">
<b className="year-label">
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
{!!yearStats.totalStudents && (
<>
<span className="separator">{yearStats.totalStudents !== undefined && '/'}</span>
<span className="year-value">{yearStats.totalStudents}</span>
</>
)}
<span className="unit">students</span>
</>
) : (
<>
<span className="year-value accumulated">
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
</span>
<span className="separator">{yearStats.total !== undefined && '/'}</span>
<span className="year-value total">
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
</span>
</>
)}
</span>
)
})}
</div>
)}
</Segment>
)
}
StatusContainer.propTypes = {
title: PropTypes.string.isRequired,
current: PropTypes.number.isRequired,
previous: PropTypes.number.isRequired,
clickable: PropTypes.bool.isRequired,
handleClick: PropTypes.func.isRequired,
min1: PropTypes.number.isRequired,
max1: PropTypes.number.isRequired,
showYearlyValues: PropTypes.bool.isRequired,
yearlyValues: PropTypes.shape({}).isRequired,
showByYear: PropTypes.bool.isRequired,
}
const StatusContent = ({ data, settings, onDrill }) => {
const { showByYear, showRelativeValues, showYearlyValues } = settings
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
const Status = ({ getStatusDispatch, data, loading }) => {
const DATE_FORMAT = 'DD.MM.YYYY'
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
const [drillStack, setDrillStack] = useState([])
const [showSettings, setShowSettings] = useState(true)
const [selectedDate, setSelectedDate] = useState(moment())
const [codes, setCodes] = useState([])
const { CoolDataScience } = InfoToolTips
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
useEffect(() => {
if (selectedDate && isValidDate(selectedDate)) {
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
}
}, [selectedDate, showByYear])
useEffect(() => {
if (codes.length > 0) {
const updatedDrillStack = codes.reduce((acc, code) => {
const drilled = data[code]
// check if the code is on first level of drilldown
if (drilled) acc.push(drilled.drill)
// if not on first level then use the previous object in array
// this might be source of bugs but its the best I could come up with
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
return acc
}, [])
setDrillStack(updatedDrillStack)
}
}, [data])
const handleShowYearlyValuesToggled = () => {
const yearlyValues = showYearlyValues
setShowYearlyValues(!showYearlyValues)
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
}
const handleShowByYearToggled = () => {
const byYear = showByYear
setShowByYear(!showByYear)
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
}
const pushToDrillStack = (values, code) => {
const updatedCodes = [...codes].concat(code)
const updatedDrillStack = [...drillStack].concat(values)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('S Drilldown clicked', 'Status')
}
const popFromDrillStack = () => {
const updatedDrillStack = _.dropRight([...drillStack], 1)
const updatedCodes = _.dropRight([...codes], 1)
setCodes(updatedCodes)
setDrillStack(updatedDrillStack)
sendAnalytics('S Drillup clicked', 'Status')
}
const renderSettings = () => {
return (
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
<Icon name="setting" />
<span>Asetukset</span>
</span>
</Accordion.Title>
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
<Segment style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{
width: '10px',
height: '10px',
background: 'red',
transform: 'rotateY(0deg) rotate(45deg)',
position: 'absolute',
top: '-6px',
left: '35px',
border: '1px solid #dededf',
borderRight: 'none',
borderBottom: 'none',
backgroundColor: 'white',
}}
/>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä edelliset vuodet"
onChange={handleShowYearlyValuesToggled}
checked={showYearlyValues}
/>
<VerticalLine />
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä kalenterivuosittain"
onChange={handleShowByYearToggled}
checked={showByYear}
/>
<VerticalLine />
<Form>
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
<Datetime
className="status-date-time-input"
dateFormat={DATE_FORMAT}
timeFormat={false}
closeOnSelect
value={selectedDate}
locale="fi"
isValidDate={isValidDate}
onChange={setSelectedDate}
/>
</Form.Field>
</Form>
</Segment>
</Accordion.Content>
</Accordion>
)
}
const DrilldownMessage = () => (
<Message
color="blue"
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
/>
)
if (!data || loading)
return (
<Segment style={{ padding: '40px' }} textAlign="center">
<Dimmer inverted active />
<Loader active={loading} />
</Segment>
)
const orderedAbsDiffs = _.orderBy(
Object.values(_.last(drillStack) || data).map(({ current, previous }) => {
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
const orderedAbsDiffs = _.chain(data)
.map(({ current, currentStudents, previous, previousStudents }) => {
const currentValue = showRelativeValues ? current / currentStudents : current
const previousValue = showRelativeValues ? previous / previousStudents : previous
return Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
})
)
.orderBy()
.value()
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return (
<>
<h2>Uusien ohjelmien tuottamat opintopisteet</h2>
<DrilldownMessage />
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
{drillStack.length > 0 && (
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
)}
</div>
<div
style={{
display: 'grid',
@@ -309,54 +243,376 @@ const Status = ({ getStatusDispatch, data, loading }) => {
}}
>
{_.orderBy(
Object.entries(_.last(drillStack) || data),
([, { current, previous, currentStudents, previousStudents }]) =>
data,
({ current, previous, currentStudents, previousStudents }) =>
getP(current || currentStudents, previous || previousStudents), // oh god<r
['desc']
).map(([code, stats]) => {
const handleClick = () => pushToDrillStack(stats.drill, code)
// check if the course has credits or not (if credits is zero but there are students who have completed it)
const current =
!stats.drill && stats.current === 0 && stats.currentStudents > 1 ? stats.currentStudents : stats.current
const previous =
!stats.drill && stats.previous === 0 && stats.previousStudents > 1 ? stats.previousStudents : stats.previous
).map(stats => {
const handleClick = () => onDrill(stats.code)
return (
<StatusContainer
key={code}
clickable={!!stats.drill}
key={stats.code}
stats={stats}
handleClick={handleClick}
title={getTextIn(stats.name)}
current={current}
previous={previous}
showRelativeValues={showRelativeValues}
showYearlyValues={showYearlyValues}
min1={-medianDiff * 2}
max1={medianDiff * 2}
yearlyValues={stats.yearly}
showByYear={showByYear}
/>
)
})}
</div>
{renderSettings()}
<Message>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</Message>
</>
)
}
Status.propTypes = {
data: shape({}).isRequired,
loading: bool.isRequired,
getStatusDispatch: func.isRequired,
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
const popupContext = useRef()
const [popupOpen, setPopupOpen] = useState(false)
const trigger = (
<div>
{children}
<div ref={popupContext} style={{ display: 'inline-block', paddingTop: '0.2em', cursor: 'help' }}>
<Icon
onClick={() => setPopupOpen(!popupOpen)}
ref={popupContext}
style={{ marginLeft: '0.3em', color: '#888' }}
name="question circle outline"
/>
</div>
</div>
)
const popupProps = _.defaults(rest, {
position: 'right center',
})
return (
<>
<Popup
hoverable
size="tiny"
open={popupOpen}
onOpen={() => setPopupOpen(true)}
onClose={() => setPopupOpen(false)}
trigger={trigger}
context={popupContext}
{...popupProps}
on="hover"
mouseEnterDelay={1000}
>
<div>{tooltip}</div>
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
Lue lisää...
</span>
</Popup>
</>
)
}
const mapStateToProps = ({ coolDataScience }) => ({
data: coolDataScience.data.status,
loading: coolDataScience.pending.status,
const isValidDate = d => moment().diff(moment(d)) > 0
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
const { showYearlyValues, showByYear, showRelativeValues, selectedDate } = settings
const DATE_FORMAT = 'DD.MM.YYYY'
const changeSetting = (property, value) => {
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
onSettingsChange({
...settings,
[property]: value,
})
}
const itemStyles = {
margin: '0.5rem 1rem',
display: 'flex',
alignItems: 'center',
}
return (
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showYearlyValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showYearlyValues.label}
checked={showYearlyValues}
onChange={() => changeSetting('showYearlyValues', !showYearlyValues)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showByYear.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showByYear.label}
checked={showByYear}
onChange={() => changeSetting('showByYear', !showByYear)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<WithHelpTooltip tooltip={actionTooltips.showRelativeValues.short} onOpenDetails={onOpenDetails}>
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label={actionTooltips.showRelativeValues.label}
checked={showRelativeValues}
onChange={() => changeSetting('showRelativeValues', !showRelativeValues)}
/>
</WithHelpTooltip>
</div>
<div style={itemStyles}>
<Form>
<Form.Field
error={selectedDate !== null && !isValidDate(selectedDate)}
style={{ display: 'flex', alignItems: 'center' }}
>
<WithHelpTooltip tooltip={actionTooltips.showCountingFrom.short} onOpenDetails={onOpenDetails}>
<span style={{ fontSize: '0.9em' }}>{actionTooltips.showCountingFrom.label}</span>
</WithHelpTooltip>
<Datetime
className="status-date-time-input"
dateFormat={DATE_FORMAT}
timeFormat={false}
closeOnSelect
value={moment(selectedDate)}
locale="fi"
isValidDate={isValidDate}
inputProps={{ placeholder: 'Valise päivämäärä' }}
onChange={value => {
changeSetting('selectedDate', value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
}}
/>
</Form.Field>
</Form>
</div>
</div>
)
}
const getDefaultSettings = () => ({
showYearlyValues: true,
showByYear: false,
showRelativeValues: false,
showSettings: true,
selectedDate: null,
})
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
const Status = () => {
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
const moreDetailsRef = useRef(null)
const history = useHistory()
const location = useLocation()
const drillStack = location.state?.drillStack ?? []
const data = useSelector(state => state.coolDataScience.data.status)
const loading = useSelector(state => state.coolDataScience.pending.status)
const dispatch = useDispatch()
const settings = useMemo(() => _.defaults(explicitSettings, getDefaultSettings()), [explicitSettings])
const { CoolDataScience } = InfoToolTips
useEffect(() => {
const { selectedDate, showByYear } = settings
let date
if (!selectedDate) {
date = moment().valueOf()
} else if (isValidDate(selectedDate)) {
date = moment(selectedDate).valueOf()
} else {
return
}
dispatch(
getStatus({
date,
showByYear,
})
)
}, [settings.selectedDate, settings.showByYear])
const pushToDrillStack = code => {
history.push('/trends/status', {
drillStack: [...drillStack, code],
})
sendAnalytics('S Drilldown clicked', 'Status')
}
const breadcrumb = useMemo(() => {
const stack = _.chain(drillStack)
.reduce(
([stack, data], code) => {
let content = code
let next = null
if (data) {
const entry = data[code]
if (entry) {
content = getTextIn(entry.name)
next = entry.drill
}
}
const item = {
key: code,
content,
link: !!next,
active: !next,
onClick: () => {
history.push('/trends/status', {
drillStack: [...stack.map(({ key }) => key).splice(1), code],
})
},
}
return [[...stack, item], next]
},
[[], data]
)
.value()[0]
const hy = {
key: '',
content: getTextIn({
fi: 'Helsingin Yliopisto',
en: 'University of Helsinki',
sv: 'Helsingfors universitet',
}),
link: true,
onClick: () =>
history.push('/trends/status', {
drillStack: [],
}),
}
return [hy, ...stack]
}, [data, drillStack])
const drilledData = useMemo(
() =>
_.chain(drillStack)
.reduce((data, code) => data && _.values(data).find(item => item.code === code)?.drill, data)
.defaultTo({})
.values()
.value(),
[data, drillStack]
)
const popFromDrillStack = () => {
const newDrillStack = [...drillStack]
newDrillStack.pop()
history.push('/trends/status', {
drillStack: newDrillStack,
})
sendAnalytics('S Drillup clicked', 'Status')
}
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }} ref={moreDetailsRef}>
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
<Popup
trigger={
<Button>
<Icon name="settings" /> Asetukset
</Button>
}
position="bottom right"
on="click"
wide="very"
open={settingsOpen}
onOpen={() => setSettingsOpen(true)}
onClose={() => {
// Close the popup only after the event has had a chance to propagate.
setTimeout(() => setSettingsOpen(false), 0)
}}
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
>
<StatusSettings
settings={settings}
onSettingsChange={setSettings}
onOpenDetails={() => {
if (moreDetailsRef.current) {
moreDetailsRef.current.scrollIntoView({
block: 'start',
inline: 'end',
behavior: 'smooth',
})
}
setUsageDetailsOpen(true)
}}
/>
</Popup>
<Popup
trigger={
<Button>
<Icon name="question circle" /> Käyttöohje
</Button>
}
position="bottom right"
on="click"
wide="very"
open={usageDetailsOpen}
onOpen={() => setUsageDetailsOpen(true)}
onClose={() => setUsageDetailsOpen(false)}
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
>
<div style={{ padding: '1em' }}>
{
// eslint-disable-next-line react/no-children-prop
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
}
</div>
{_.toPairs(actionTooltips).map(([key, { label, long }]) => (
<div key={key}>
<Divider />
<div style={{ padding: '0 1em' }}>
<b>
Valinta "<i>{label}</i>"
</b>
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
<ReactMarkdown escapeHtml={false}>{long.replace(/(^|\n)[ \t]+/g, '\n')}</ReactMarkdown>
</div>
</div>
</div>
))}
</Popup>
</div>
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
<Breadcrumb icon="right angle" sections={breadcrumb} size="large" />
{drillStack.length > 0 && (
<Icon
onClick={popFromDrillStack}
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
name="arrow left"
/>
)}
</div>
{data && !loading ? (
<StatusContent settings={settings} onDrill={pushToDrillStack} data={drilledData} />
) : (
<Loader active={loading} inline="centered" />
)}
</>
)
}
export default Status
@@ -309,7 +309,7 @@ const Status = ({ getStatusGraduatedDispatch, data, loading }) => {
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return (
<>
<h2>Uusista ohjelmista valmistuneet</h2>
<h2>Koulutusohjelmista valmistuneet</h2>
<DrilldownMessage />
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
{drillStack.length > 0 && (
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
const StatusPanel = () => {
const panes = [
{ menuItem: 'Attainments', render: () => <Status /> },
{ menuItem: 'Graduations', render: () => <Graduated /> },
{
menuItem: 'Attainments',
render: () => (
<Tab.Pane>
<Status />
</Tab.Pane>
),
},
{
menuItem: 'Graduations',
render: () => (
<Tab.Pane>
<Graduated />
</Tab.Pane>
),
},
]
return <Tab panes={panes} />
}
@@ -10,6 +10,47 @@
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.years {
margin-top: 10px;
text-align: start;
font-variant-numeric: tabular-nums;
display: grid;
grid-template-columns: repeat(4, fit-content(0));
}
.subtitle {
color: #888;
}
.unit {
margin-left: 0.3em;
}
.year-label {
white-space: nowrap;
padding-right: 0.5em;
}
.year-value {
text-align: right;
white-space: nowrap;
}
.year-row {
position: relative;
padding-left: 0.5em;
display: contents;
}
.year-row .unit {
grid-column: 5;
}
.year-row .separator {
color: #aaa;
margin: 0 0.3em;
}
.status-date-time-input {
padding-right: 0;
margin-left: 5px;
@@ -21,6 +21,7 @@ import infotooltips from '../../common/InfoToolTips'
import InfoBox from '../InfoBox'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
import { CoursePopulationFilters } from '../FilterTray/FilterSets'
import useLanguage from '../LanguagePicker/useLanguage'
import { queryParamsFromUrl } from '../../common/query'
@@ -39,6 +40,7 @@ const CoursePopulation = ({
}) => {
const { language } = useLanguage()
const { setAllStudents, filteredStudents } = useFilters()
const { setCourseCodes } = useProgrammeFilter()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const [codes, setCodes] = useState([])
@@ -106,6 +108,7 @@ const CoursePopulation = ({
separate,
})
setCodes(parsedCourseCodes)
setCourseCodes(parsedCourseCodes)
if (years) {
setYears(years)
} else {
@@ -2,7 +2,6 @@ import React, { Component } from 'react'
import { renderToString } from 'react-dom/server'
import { connect } from 'react-redux'
import moment from 'moment'
import { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
import { withRouter } from 'react-router-dom'
import Highcharts from 'highcharts/highstock'
import { Button } from 'semantic-ui-react'
@@ -27,36 +26,29 @@ class CreditAccumulationGraphHighCharts extends Component {
constructor(props) {
super(props)
this.state = {
studentCreditLines: [],
options: [],
updateGraph: false,
}
}
componentDidMount() {
const { students } = this.props
this.getMoreCreditLines(students)
const { students, selectedStudents, currentGraphSize, render } = this.props
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
}
componentWillReceiveProps(nextProps) {
if (nextProps.students) {
const nextStudents = nextProps.students.map(student => student.studentNumber)
const oldStudents = this.props.students.map(student => student.studentNumber)
const nextMinDate = nextProps.students.minDate
const oldMinDate = this.props.students.minDate
const clear = nextMinDate !== oldMinDate
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
const changed =
nextStudents.some(student => !oldStudents.includes(student)) ||
oldStudents.some(student => !nextStudents.includes(student)) ||
clear
oldStudents.some(student => !nextStudents.includes(student))
if (changed) {
const { students } = nextProps
this.getMoreCreditLines(students, clear)
} else if (changedStudentAmount || changedGraphSize) {
if (changed || changedStudentAmount || changedGraphSize) {
this.createGraphOptions(
nextProps.students,
nextProps.selectedStudents,
@@ -88,19 +80,6 @@ class CreditAccumulationGraphHighCharts extends Component {
}
}
getMoreCreditLines = (students, clear) => {
if (clear) {
// eslint-disable-next-line react/no-access-state-in-setstate
const removed = this.state.studentCreditLines.splice(0, 0)
const studentCreditLines = removed.concat(this.createStudentCreditLines(students))
this.setState({ studentCreditLines, updateGraph: true })
} else {
// eslint-disable-next-line react/no-access-state-in-setstate
const studentCreditLines = this.state.studentCreditLines.concat(this.createStudentCreditLines(students))
this.setState({ studentCreditLines, updateGraph: true })
}
}
getXAxisMonth = (date, startDate) =>
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
@@ -134,7 +113,10 @@ class CreditAccumulationGraphHighCharts extends Component {
}
createGraphOptions = (students, selectedStudents, graphSize, render) => {
const dataOfSelected = this.state.studentCreditLines.filter(line => selectedStudents.includes(line.name))
const studentCreditLines = this.createStudentCreditLines(students)
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
let lastCredits = null
if (this.isSingleStudentGraph() && render) {
const started = moment(students.minDate)
@@ -237,7 +219,7 @@ class CreditAccumulationGraphHighCharts extends Component {
},
xAxis: {
max: students.maxDate,
min: students.minDateWithCredits || students.minDate,
min: students.minDateWithCredits || students.minDate || this.props.startYear,
ordinal: false,
},
series: dataOfSelected,
@@ -325,25 +307,6 @@ class CreditAccumulationGraphHighCharts extends Component {
}
}
CreditAccumulationGraphHighCharts.defaultProps = {
absences: [],
singleStudent: false,
render: true,
trayOpen: false,
}
CreditAccumulationGraphHighCharts.propTypes = {
singleStudent: bool,
students: arrayOf(object).isRequired,
selectedStudents: arrayOf(string).isRequired,
setChartHeight: func.isRequired,
currentGraphSize: number.isRequired,
language: string.isRequired,
absences: arrayOf(shape({})),
render: bool,
trayOpen: bool,
}
const mapStateToProps = state => ({
spinner: state.graphSpinner,
currentGraphSize: state.settings.chartHeight,
@@ -5,6 +5,8 @@ import { arrayOf, string, shape } from 'prop-types'
import SearchResultTable from '../../SearchResultTable'
import { getNewestProgramme, getTextIn } from '../../../common'
import useLanguage from '../../LanguagePicker/useLanguage'
import useProgrammeFilter from '../../FilterTray/filters/Programmes/useProgrammeFilter'
import FilterToggleIcon from '../../FilterToggleIcon'
const CustomPopulationProgrammeDist = ({
samples,
@@ -13,7 +15,9 @@ const CustomPopulationProgrammeDist = ({
populationStatistics,
}) => {
const { language } = useLanguage()
const { selectedProgrammes, toggleFilterProgramme } = useProgrammeFilter()
const [tableRows, setRows] = useState([])
useEffect(() => {
if (Object.keys(populationStatistics).length > 0) {
const allProgrammes = {}
@@ -41,7 +45,8 @@ const CustomPopulationProgrammeDist = ({
}
})
const rows = Object.entries(allProgrammes).map(([code, { programme, students }]) => [
`${getTextIn(programme.name, language)}, ${code}`,
getTextIn(programme.name, language),
code,
students.length,
<Progress
style={{ margin: '0px' }}
@@ -49,14 +54,32 @@ const CustomPopulationProgrammeDist = ({
progress
/>,
])
const sortedRows = rows.sort((a, b) => b[1] - a[1])
const sortedRows = rows.sort((a, b) => b[2] - a[2])
setRows(sortedRows)
}
}, [selectedStudents])
const headers = ['Programmes', `Students (all=${selectedStudents.length})`, 'Percentage of population']
const headers = ['Programme', 'Code', `Students (all=${selectedStudents.length})`, 'Percentage of population']
return <SearchResultTable headers={headers} rows={tableRows} selectable noResultText="placeholder" />
const handleFilterToggle = code => {
toggleFilterProgramme(code)
}
const isProgrammeFilterActive = code => selectedProgrammes.find(p => p.code === code) !== undefined
return (
<SearchResultTable
headers={headers}
rows={tableRows}
selectable
noResultText="placeholder"
actionTrigger={row => (
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
<FilterToggleIcon onClick={() => handleFilterToggle(row[1])} isActive={isProgrammeFilterActive(row[1])} />
</span>
)}
/>
)
}
CustomPopulationProgrammeDist.defaultProps = {
@@ -1,148 +0,0 @@
import React, { Component } from 'react'
import { withRouter } from 'react-router-dom'
import { Radio, Icon, Header, Segment, Loader, Popup } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { func, shape, string, bool, arrayOf, number } from 'prop-types'
import { getUsers } from '../../redux/users'
import { getUnits } from '../../redux/units'
import { getElementDetails } from '../../redux/elementdetails'
import { makeSortUsers } from '../../selectors/users'
import { copyToClipboard } from '../../common'
import UserPageNew from '../UserPage'
import UserSearchList from './UserSearchList'
class EnableUsers extends Component {
state = {
enabledOnly: true,
}
componentDidMount() {
if (this.props.elementdetails.length === 0) this.props.getElementDetails()
if (this.props.units.length === 0) this.props.getUnits()
if (this.props.users.length === 0) this.props.getUsers()
document.title = 'Users - Oodikone'
}
componentWillUnmount = () => {
clearTimeout(this.popupTimeout)
}
toggleEnabledOnly() {
if (this.props.enabledOnly) this.props.getUsers()
const { enabledOnly } = this.state
this.setState({ enabledOnly: !enabledOnly })
}
openUsersPage = () => {
const { history } = this.props
history.push('/users')
}
copyEmailsToClippoard = () => {
const clipboardString = this.props.users
.filter(u => u.is_enabled && u.email)
.map(u => u.email)
.join('; ')
copyToClipboard(clipboardString)
}
renderUserPage = userid => {
const { users } = this.props
const user = users.find(u => u.id === userid)
return !user ? <Loader active /> : <UserPageNew userid={userid} user={user} goBack={this.openUsersPage} />
}
handlePopupOpen = () => {
this.setState({ popupOpen: true })
this.popupTimeout = setTimeout(() => {
this.setState({ popupOpen: false })
}, 1500)
}
handlePopupClose = () => {
this.popupTimeout = null
}
render() {
const { match, pending, users, error, elementdetails } = this.props
const { enabledOnly } = this.state
const { userid } = match.params
return (
<div style={{ marginBottom: '10px' }} className="segmentContainer">
<Header className="segmentTitle" size="large">
Oodikone users
</Header>
<Radio
label={`Showing ${enabledOnly ? 'only enabled' : 'all'} users`}
toggle
onClick={() => this.toggleEnabledOnly()}
/>
<Segment loading={pending} className="contentSegment">
{!userid ? (
<UserSearchList enabledOnly={enabledOnly} users={users} error={error} elementdetails={elementdetails} />
) : (
this.renderUserPage(userid)
)}
</Segment>
<Popup
trigger={<Icon link name="envelope" onClick={this.copyEmailsToClippoard} style={{ float: 'right' }} />}
content="Copied email(s)!"
on="click"
onOpen={this.handlePopupOpen}
onClose={this.handlePopupClose}
open={this.state.popupOpen}
/>
</div>
)
}
}
EnableUsers.propTypes = {
match: shape({
params: shape({
studentNumber: string,
}),
}).isRequired,
getUsers: func.isRequired,
pending: bool.isRequired,
getUnits: func.isRequired,
enabledOnly: bool.isRequired,
users: arrayOf(
shape({
id: string,
full_name: string,
is_enabled: bool,
username: string,
units: arrayOf(
shape({
id: string,
name: shape({}).isRequired,
})
),
})
).isRequired,
error: bool.isRequired,
units: arrayOf(shape({})).isRequired,
elementdetails: arrayOf(shape({ code: string, type: number, name: shape({}) })).isRequired,
getElementDetails: func.isRequired,
history: shape({}).isRequired,
}
const sortUsers = makeSortUsers()
const mapStateToProps = ({ users, units, elementdetails }) => ({
units: units.data,
elementdetails: elementdetails.data,
enabledOnly: users.enabledOnly,
users: sortUsers(users),
pending: typeof users.pending === 'boolean' ? users.pending : true,
error: users.error || false,
})
export default withRouter(
connect(mapStateToProps, {
getUsers,
getUnits,
getElementDetails,
})(EnableUsers)
)
@@ -1,5 +1,5 @@
.flexIcon {
display: flex;
display: inline-flex;
align-items: flex-end;
}
@@ -4,25 +4,24 @@ import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
import { FilterTrayProvider } from './useFilterTray'
import { ProgrammeFilterProvider } from './filters/Programmes/useProgrammeFilter'
import { FilterProvider } from './useFilters'
import { FilterAnalyticsProvider } from './useAnalytics'
import { GradeFilterProvider } from './filters/Grade/useGradeFilter'
const FilterContextProvider = ({ children }) => (
<FilterAnalyticsProvider>
<FilterTrayProvider>
<FilterProvider>
<CourseFilterProvider>
<CreditFilterProvider>
<AgeFilterProvider>
<GradeFilterProvider>{children}</GradeFilterProvider>
</AgeFilterProvider>
</CreditFilterProvider>
</CourseFilterProvider>
</FilterProvider>
</FilterTrayProvider>
</FilterAnalyticsProvider>
)
const filterProviders = [
FilterAnalyticsProvider,
FilterTrayProvider,
FilterProvider,
CourseFilterProvider,
CreditFilterProvider,
AgeFilterProvider,
GradeFilterProvider,
ProgrammeFilterProvider,
]
const FilterContextProvider = ({ children }) =>
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
FilterContextProvider.propTypes = {
children: PropTypes.node.isRequired,
@@ -6,6 +6,7 @@ import CreditsEarned from './filters/CreditsEarned'
import Gender from './filters/Gender'
import StartYearAtUni from './filters/StartYearAtUni'
import Courses from './filters/Courses'
import Programmes from './filters/Programmes'
import useAnalytics from './useAnalytics'
import Grade from './filters/Grade'
import Date from './filters/Date'
@@ -48,6 +49,8 @@ export const CoursePopulationFilters = () => {
<Age />
<Gender />
<StartYearAtUni />
<Courses />
<Programmes />
</>
)
}
@@ -0,0 +1,60 @@
import React, { useEffect, useMemo } from 'react'
import { Dropdown } from 'semantic-ui-react'
import FilterCard from '../common/FilterCard'
import { getTextIn } from '../../../../common'
import useLanguage from '../../../LanguagePicker/useLanguage'
import useProgrammeFilter from './useProgrammeFilter'
import useFilters from '../../useFilters'
const Programme = () => {
const { programmes, selectedProgrammes, studentToProgrammeMap, selectFilterProgramme, removeFilterProgramme } =
useProgrammeFilter()
const { removeFilter, addFilter } = useFilters()
const { language } = useLanguage()
const name = 'programmeFilter'
const options = useMemo(
() =>
programmes
.map(program => ({
key: `programme-filter-value-${program.code}`,
text: getTextIn(program.name, language),
value: program.code,
}))
.sort((a, b) => a.text.localeCompare(b.text)),
[programmes, language]
)
const handleChange = (_, { value }) => {
value.filter(code => selectedProgrammes.findIndex(p => p.code === code) === -1).forEach(selectFilterProgramme)
selectedProgrammes.filter(({ code }) => value.indexOf(code) === -1).forEach(removeFilterProgramme)
}
useEffect(() => {
if (selectedProgrammes.length === 0) {
removeFilter(name)
} else {
addFilter(name, ({ studentNumber }) => {
return selectedProgrammes.findIndex(p => p.code === studentToProgrammeMap[studentNumber]) !== -1
})
}
}, [selectedProgrammes, studentToProgrammeMap])
return (
<FilterCard title="Programme" active={selectedProgrammes.length > 0} className="programmes-filter" name={name}>
<Dropdown
options={options}
placeholder="Select Programme"
onChange={handleChange}
button
value={selectedProgrammes.map(p => p.code)}
name={name}
closeOnChange
multiple
/>
</FilterCard>
)
}
export default Programme
@@ -0,0 +1,119 @@
import React, { useState, createContext, useContext, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { getStudentToTargetCourseDateMap, getNewestProgramme } from '../../../../common'
import useFilters from '../../useFilters'
const ProgrammeFilterContext = createContext([[], () => {}])
ProgrammeFilterContext.displayName = 'Programme Filter'
const defaultState = {
selectedProgrammes: [],
}
export const ProgrammeFilterProvider = ({ children }) => {
const [state, setState] = useState(defaultState)
const [courseCodes, setCourseCodes] = useState([])
const { selectedProgrammes } = state
const setSelectedProgrammes = programmes => {
setState({
selectedProgrammes: programmes,
})
}
const context = {
selectedProgrammes,
setSelectedProgrammes,
courseCodes,
setCourseCodes,
}
return <ProgrammeFilterContext.Provider value={context}>{children}</ProgrammeFilterContext.Provider>
}
export default () => {
const { selectedProgrammes, courseCodes, setCourseCodes, setSelectedProgrammes } = useContext(ProgrammeFilterContext)
const { allStudents } = useFilters()
const studentToTargetCourseDateMap = useMemo(
() => getStudentToTargetCourseDateMap(allStudents, courseCodes),
[allStudents]
)
const elementDetails = useSelector(state => state?.populations?.data?.elementdetails?.data)
const { programmes, studentToProgrammeMap } = useMemo(() => {
if (!elementDetails) {
return {
programmes: [],
studentToProgrammeMap: {},
}
}
const programmeMap = {}
const studentToProgrammeMap = {}
allStudents
.map(student => [
student,
getNewestProgramme(student.studyrights, student.studentNumber, studentToTargetCourseDateMap, elementDetails),
])
.forEach(([{ studentNumber }, programme]) => {
programmeMap[programme.code] = programme
studentToProgrammeMap[studentNumber] = programme.code
})
return {
programmes: Object.values(programmeMap),
studentToProgrammeMap,
}
}, [allStudents, elementDetails])
const selectFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
if (index !== -1) {
return
}
const programme = programmes.find(p => p.code === code)
if (programme === undefined) {
return
}
setSelectedProgrammes([...selectedProgrammes, programme])
}
const removeFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
const newSelection = [...selectedProgrammes]
newSelection.splice(index, 1)
setSelectedProgrammes(newSelection)
}
const toggleFilterProgramme = code => {
const index = selectedProgrammes.findIndex(p => p.code === code)
if (index === -1) {
const programme = programmes.find(p => p.code === code)
setSelectedProgrammes([...selectedProgrammes, programme])
} else {
const newSelection = [...selectedProgrammes]
newSelection.splice(index, 1)
setSelectedProgrammes(newSelection)
}
}
return {
programmes,
selectedProgrammes,
toggleFilterProgramme,
selectFilterProgramme,
removeFilterProgramme,
setCourseCodes,
studentToProgrammeMap,
}
}
@@ -18,7 +18,7 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
{title}
</Header>
</div>
{open && children}
{(active || open) && children}
</div>
)
}
+49 -29
View File
@@ -1,16 +1,55 @@
/* eslint-disable no-console */
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
import moment from 'moment'
import { connect } from 'react-redux'
import propTypes from 'prop-types'
import { isEqual } from 'lodash'
import { useSelector } from 'react-redux'
import { images, getUserRoles, checkUserAccess } from '../common'
import { useTitle } from '../common/hooks'
import { builtAt } from '../conf'
import oodiTXT from '../static/oodi.txt'
const FrontPage = props => {
const { userRoles, rights } = props
const OodiToOodikone = () => {
const [oodis, setOodis] = useState([])
useEffect(() => {
const getOodis = async () => {
try {
const data = await fetch(oodiTXT)
setOodis(
(await data.text())
.split('**')
.map(o => o.trim())
.filter(oodi => !!oodi)
)
} catch (error) {
setOodis([])
}
}
getOodis()
}, [])
if (!oodis || oodis.length === 0) {
return null
}
const oodi = oodis[Math.floor(Math.random() * oodis.length)].split('\n')
return (
<div style={{ margin: 'auto', width: '50%' }}>
{oodi.map((l, index) => (
// eslint-disable-next-line react/no-array-index-key
<p key={index} style={{ textAlign: 'center', fontStyle: 'italic', margin: 0 }}>
{index === 0 && '\u201C'}
{l}
{index === oodi.length - 1 && '\u201D'}
</p>
))}
<p style={{ marginTop: '1.3em', textAlign: 'center' }}>
- <a href="http://www.helsinki.fi/discovery">R. U. Nokone</a>
</p>
</div>
)
}
const FrontPage = () => {
const { roles, rights } = useSelector(state => state.auth.token)
const userRoles = getUserRoles(roles)
useTitle()
const showItems = {
@@ -32,6 +71,8 @@ const FrontPage = props => {
<Header as="h3" style={{ textAlign: 'center' }}>
Exploratory Research on Study Data
</Header>
<OodiToOodikone />
{showItems.populations && (
<>
<Divider section />
@@ -80,25 +121,4 @@ const FrontPage = props => {
)
}
FrontPage.propTypes = {
userRoles: propTypes.arrayOf(propTypes.string),
rights: propTypes.arrayOf(propTypes.string),
}
FrontPage.defaultProps = {
userRoles: [],
rights: [],
}
const mapStateToProps = ({
auth: {
token: { roles, rights },
},
}) => ({
userRoles: getUserRoles(roles),
rights,
})
export default connect(mapStateToProps, null, null, {
areStatePropsEqual: isEqual,
})(FrontPage)
export default FrontPage
@@ -1,5 +1,4 @@
import React from 'react'
import { shape, string } from 'prop-types'
import { withRouter } from 'react-router-dom'
import NavigationBar from '../NavigationBar'
import './header.css'
@@ -10,10 +9,4 @@ const Header = props => (
</header>
)
Header.propTypes = {
location: shape({
pathname: string.isRequired,
}).isRequired,
}
export default withRouter(Header)
@@ -1,72 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState } from 'react'
import { Message } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { isEqual } from 'lodash'
import { getHideSisWarningFlag, setHideSisWarningFlag, getUserRoles } from '../../common'
const SisChangeMessage = props => {
const [open, setOpen] = useState(true)
const { userRoles, rights } = props
const handleDismiss = () => {
setOpen(false)
setHideSisWarningFlag(true)
}
if (!open || getHideSisWarningFlag()) return null
const reasons = [
'Osalla opiskelijoista voi olla opintopisteiden kokonaismäärässä eroja. Mahdollinen eroavaisuus on merkitty kyseisen opiskelijan sivulla. Tämä koskee erityisesti tiedekuntia, joissa on runsaasti osasuorituksia.',
'Populaatioiden osalta vaihto-opiskelijoiden, tutkintoon johtamattomien opinto-oikeuksien ja ohjelmaan siirrettyjen opiskelijoiden näyttäminen ei kaikissa tapauksissa toimi oikein.',
'Trendien osalta ainoastaan "See current status" -sivu toimii, muut eivät näytä vuositahteja oikein.',
]
// Kasvatustiedehuomiot
if (
rights.includes('KH60_001') ||
rights.includes('MH60_001') ||
rights.includes('MH60_002') ||
userRoles.includes('admin')
) {
reasons.push('Osalla Varhaiskasvatuksen opettaja -tutkintosuunnan opiskelijoista on virheellisiä opinto-oikeuksia.')
}
// Oikeustiedehuomiot
if (rights.includes('KH20_001') || rights.includes('MH20_001') || userRoles.includes('admin')) {
reasons.push(
'Oikeustieteellisen tiedekunnan tuottamien opintopisteiden kokonaismäärä on Trends-osiossa liian alhainen.'
)
}
return (
<Message onDismiss={handleDismiss} info size="huge">
<Message.Header>Oodikone käyttää nyt Sisua</Message.Header>
<p>
Opiskelijoiden tiedot saattavat siirtymän myötä poiketa joiltain osin aiemmasta. Erityisesti siirtymän tiedetään
vaikuttaneen seuraaviin asioihin:
</p>
<Message.List items={reasons} />
<p>
Mikäli huomaat tiedoissa virheitä, laitathan palautetta sähköpostitse{' '}
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a> tai{' '}
<Link to="/feedback">palautelomakkeella</Link>.
</p>
</Message>
)
}
const mapStateToProps = ({
auth: {
token: { roles, rights },
},
}) => ({
userRoles: getUserRoles(roles),
rights,
})
export default connect(mapStateToProps, null, null, {
areStatePropsEqual: isEqual,
})(SisChangeMessage)
@@ -6,7 +6,6 @@ import TSA from '../../common/tsa'
import Header from '../Header'
import ErrorContainer from '../ErrorContainer'
import Routes from '../Routes'
import SisChangeMessage from './SisChangeMessage'
import './main.css'
import { isDev, basePath } from '../../conf'
@@ -24,7 +23,6 @@ export default () => {
<Router basename={basePath} history={history}>
<main className="routeViewContainer">
<Header />
<SisChangeMessage />
<ErrorContainer />
<Routes />
</main>
@@ -1,13 +1,14 @@
import React from 'react'
import './navigationBar.css'
import { Menu, Dropdown, Button, Label } from 'semantic-ui-react'
import { NavLink, Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { isEqual } from 'lodash'
import { getUserRoles, setMocking, checkUserAccess } from '../../common'
import { getUserRoles, checkUserAccess } from '../../common'
import { logout as logoutAction } from '../../redux/auth'
import './navigationBar.css'
import LanguagePicker from '../LanguagePicker'
import { isDev, adminerUrls } from '../../conf'
import { useShowAsUser } from '../../common/hooks'
const allNavigationItems = {
populations: {
@@ -35,6 +36,7 @@ const allNavigationItems = {
const NavigationBar = props => {
const { logout, userRoles, rights, mockedBy, userId } = props
const showAsUser = useShowAsUser()
const refreshNavigationRoutes = () => {
const visibleNavigationItems = {}
@@ -56,11 +58,6 @@ const NavigationBar = props => {
const visibleNavigationItems = refreshNavigationRoutes()
const returnToSelf = () => {
setMocking(null)
window.location.reload()
}
const renderHome = () => (
<Menu.Item as={Link} to="/" tabIndex="-1">
<span className="logo">
@@ -132,12 +129,12 @@ const NavigationBar = props => {
const renderStopMockingButton = () => (
<Menu.Item>
<Button onClick={returnToSelf}>Stop mocking as {userId}</Button>
<Button onClick={() => showAsUser(null)}>Stop mocking as {userId}</Button>
</Menu.Item>
)
return (
<Menu stackable fluid className="navBar">
<Menu data-cy="navBar" stackable fluid className="navBar">
{renderHome()}
{renderNavigationRoutes()}
{renderUserMenu()}
@@ -1,5 +1,3 @@
/* stylelint-disable */
.navBar {
display: grid !important;
grid-auto-flow: column !important;
@@ -25,56 +23,3 @@
.logoText {
color: var(--black) ;;
}
.sis-destruction-siren {
width: 75px;
height: 75px;
animation: 0.5s infinite running sis-siren;
}
@keyframes sis-siren {
from {
transform: rotateZ(0);
}
to {
transform: rotateZ(360deg);
}
}
.sis-danger-zone-button {
background-color: #d01919;
animation: 2s ease-in-out infinite running sis-danger-zone-button-heartbeat;
}
@keyframes sis-danger-zone-button-heartbeat {
from {
background-color: #d01919;
transform: scale(1.05);
}
to {
background-color: rgba(230, 107, 107, 0.76);
transform: scale(1);
}
}
.sis-danger-zone-button::after {
position: absolute;
content: '';
width: 100%;
height: 100%;
left: 0;
top: 0;
border-radius: 0.28571429rem;
box-shadow: 0 0 25px 5px rgba(224, 114, 40, 0.75);
opacity: 1;
animation: 2s ease-in-out infinite running sis-danger-zone-opacity;
}
@keyframes sis-danger-zone-opacity {
from {
opacity: 1;
}
to {
opacity: 0.2;
}
}
@@ -1,10 +1,11 @@
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { shape, arrayOf, string, bool } from 'prop-types'
import { Segment } from 'semantic-ui-react'
import { Segment, Button } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import SegmentDimmer from '../SegmentDimmer'
import PopulationCourseStats from '../PopulationCourseStats'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import InfoBox from '../InfoBox'
import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
@@ -18,6 +19,7 @@ const PopulationCourses = ({
filteredStudents,
}) => {
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
const [showByStudytrack, setShowByStudytrack] = useState(true)
const selectedPopulationCourses = populationSelectedStudentCourses.data
? populationSelectedStudentCourses
@@ -70,18 +72,30 @@ const PopulationCourses = ({
return resetCourses
}, [])
const changeStructure = () => {
setShowByStudytrack(!showByStudytrack)
}
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
<Button primary onClick={changeStructure} style={{ marginLeft: '1em' }}>
{showByStudytrack === true ? 'Show the most attained courses' : 'Show by programme structure'}
</Button>
{query.studyRights.programme && <FilterDegreeCoursesModal studyProgramme={query.studyRights.programme} />}
<SegmentDimmer isLoading={pending} />
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
{showByStudytrack ? (
<PopulationCourseStats
key={selectedPopulationCourses.query.uuid}
courses={selectedPopulationCourses.data}
query={selectedPopulationCourses.query}
pending={pending}
selectedStudents={selectedStudents}
/>
) : (
<CustomPopulationCourses selectedStudents={selectedStudents} showFilter={false} />
)}
</Segment>
)
}
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { Table, Progress, Icon } from 'semantic-ui-react'
import { number, func } from 'prop-types'
import ExternalCreditFilterToggle from './ExternalCreditFilterToggle'
const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }) => {
@@ -76,17 +76,4 @@ const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }
)
}
CollapsibleCreditRow.propTypes = {
min: number,
max: number,
studentCount: func.isRequired,
filteredLength: number.isRequired,
months: number.isRequired,
}
CollapsibleCreditRow.defaultProps = {
min: undefined,
max: undefined,
}
export default CollapsibleCreditRow
@@ -1,62 +1,59 @@
import React, { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useLocation } from 'react-router-dom'
import { Table } from 'semantic-ui-react'
import { getMonths } from '../../../common/query'
import { getStudentTotalCredits } from '../../../common'
import CollapsibleCreditRow from './CollapsibleCreditRow'
import React, { useState } from 'react'
import { Divider, Icon, Grid, Label } from 'semantic-ui-react'
const CreditsGainedTab = ({ filteredStudents }) => {
const months = getMonths(useLocation())
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
import CreditsGainedTable from './CreditsGainedTable'
const studentCount = (min, max = Infinity) =>
max === 0
? creditList.filter(credits => credits === 0).length
: creditList.filter(credits => credits < max && credits >= min).length
const admissionTypes = [
'Todistusvalinta',
'Koepisteet',
'Yhteispisteet',
'Avoin väylä',
'Kilpailumenestys',
'Muu',
null,
]
const limits = [
[Math.ceil(months * (60 / 12))],
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
[1, Math.ceil(months * (15 / 12))],
[null, 0],
]
const CreditsGainedTab = ({ allStudents, query }) => {
const [show, setShow] = useState(false)
if (!allStudents || !allStudents.length || !query) return null
const { studyRights } = query
const filterFunction = (student, type) =>
student.studyrights.some(
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
)
const getCreditsGainedTable = type => {
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
return <CreditsGainedTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
}
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
return (
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing />
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
<Table.HeaderCell>
Number of Students
<br />
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
</Table.HeaderCell>
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{limits.map(([min, max]) => (
<CollapsibleCreditRow
key={`table-row-${min}-${max}`}
min={min}
max={max}
studentCount={studentCount}
filteredLength={filteredStudents.length}
months={Number(months)}
/>
))}
</Table.Body>
</Table>
<Grid>
<Grid.Row>
<Grid.Column width={16} data-cy="credits-gained-main-table">
<CreditsGainedTable type="All students of the population" filteredStudents={allStudents} />
</Grid.Column>
</Grid.Row>
{admissionTypesAvailable && (
<Divider
className="credits-gained-divider"
horizontal
style={{ cursor: 'pointer' }}
onClick={() => setShow(!show)}
>
By admission type <Icon name={`angle ${show ? 'down' : 'right'}`} />
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</Divider>
)}
{show && <Grid.Row>{admissionTypes.map(type => getCreditsGainedTable(type))}</Grid.Row>}
</Grid>
)
}
CreditsGainedTab.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default CreditsGainedTab
@@ -0,0 +1,62 @@
import React, { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Table } from 'semantic-ui-react'
import { getMonths } from '../../../common/query'
import { getStudentTotalCredits } from '../../../common'
import CollapsibleCreditRow from './CollapsibleCreditRow'
const CreditsGainedTable = ({ filteredStudents, type }) => {
const months = getMonths(useLocation())
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
if (!filteredStudents || !filteredStudents.length || !type) return null
const studentCount = (min, max = Infinity) =>
max === 0
? creditList.filter(credits => credits === 0).length
: creditList.filter(credits => credits < max && credits >= min).length
const limits = [
[Math.ceil(months * (60 / 12))],
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
[1, Math.ceil(months * (15 / 12))],
[null, 0],
]
return (
<div className="credits-gained-table" data-cy={`credits-gained-table-${type}`}>
<h3>{type}</h3>
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing />
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
<Table.HeaderCell>
Number of Students
<br />
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
</Table.HeaderCell>
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body data-cy="credits-gained-table-body">
{limits.map(([min, max]) => (
<CollapsibleCreditRow
key={`table-row-${min}-${max}`}
min={min}
max={max}
studentCount={studentCount}
filteredLength={filteredStudents.length}
months={Number(months)}
/>
))}
</Table.Body>
</Table>
</div>
)
}
export default CreditsGainedTable
@@ -1,61 +1,53 @@
import React from 'react'
import { jStat } from 'jStat'
import PropTypes from 'prop-types'
import { Table } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../common'
import { Divider, Grid, Label } from 'semantic-ui-react'
const StatisticsTab = ({ filteredStudents }) => {
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
const quartiles = jStat.quartiles(credits)
import StatisticsTable from './StatisticsTable'
const admissionTypes = [
'Todistusvalinta',
'Koepisteet',
'Yhteispisteet',
'Avoin väylä',
'Kilpailumenestys',
'Muu',
null,
]
const StatisticsTab = ({ allStudents, query }) => {
if (!allStudents || !allStudents.length || !query) return null
const { studyRights } = query
const filterFunction = (student, type) =>
student.studyrights.some(
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
)
const getStatisticsTable = type => {
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
}
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
return (
<Table celled collapsing className="statistics-table">
<Table.Header>
<Table.Row>
<Table.HeaderCell data-cy="credit-stats-table-name-header">
{`Statistic for n = ${credits.length} Students`}
</Table.HeaderCell>
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Average</Table.Cell>
<Table.Cell data-cy="credit-stats-mean">{formatNumber(jStat.mean(credits), 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Standard Deviation</Table.Cell>
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(jStat.stdev(credits), 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Min</Table.Cell>
<Table.Cell data-cy="credit-stats-min">{formatNumber(jStat.min(credits), 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>1st Quartile (25 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q1">{formatNumber(quartiles[0], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>2nd Quartile (50 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q2">{formatNumber(quartiles[1], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>3rd Quartile (75 %)</Table.Cell>
<Table.Cell data-cy="credit-stats-q3">{formatNumber(quartiles[2], 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Max</Table.Cell>
<Table.Cell data-cy="credit-stats-max">{formatNumber(jStat.max(credits), 0)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<Grid padded centered>
<Grid.Row>
<StatisticsTable type="All students of the population" filteredStudents={allStudents} />
</Grid.Row>
{admissionTypesAvailable && (
<>
<Divider horizontal>
By admission type
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</Divider>
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
</>
)}
</Grid>
)
}
StatisticsTab.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default StatisticsTab
@@ -0,0 +1,53 @@
import React from 'react'
import * as _ from 'lodash'
import { Table } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../common'
const StatisticsTable = ({ filteredStudents, type }) => {
if (!filteredStudents || !filteredStudents.length) return null
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
const mean = _.mean(credits)
const stdev = Math.sqrt(_.sum(_.map(credits, c => (c - mean) * (c - mean))) / credits.length)
return (
<div className="statistics-table">
<h3>{type}</h3>
<Table celled collapsing>
<Table.Header>
<Table.Row>
<Table.HeaderCell data-cy="credit-stats-table-name-header">
{`Statistic for n = ${credits.length} Students`}
</Table.HeaderCell>
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Total</Table.Cell>
<Table.Cell data-cy="credit-stats-total">{formatNumber(_.sum(credits))}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Average</Table.Cell>
<Table.Cell data-cy="credit-stats-mean">{formatNumber(mean, 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Standard Deviation</Table.Cell>
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(stdev, 2)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Min</Table.Cell>
<Table.Cell data-cy="credit-stats-min">{formatNumber(_.min(credits), 0)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Max</Table.Cell>
<Table.Cell data-cy="credit-stats-max">{formatNumber(_.max(credits), 0)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</div>
)
}
export default StatisticsTable
@@ -1,5 +1,14 @@
#credit-gain-stats .statistics-table {
margin: 0 auto;
margin: 10px auto;
}
#credit-gain-stats .credits-gained-table {
margin: 10px auto;
}
#credit-gain-stats .credits-gained-divider:hover {
color: gray;
cursor: pointer;
}
#credit-gain-stats .collapsing + th,
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import { Tab } from 'semantic-ui-react'
import { useTabChangeAnalytics } from '../../../common/hooks'
import InfoBox from '../../InfoBox'
@@ -8,11 +7,11 @@ import CreditsGainedTab from './CreditsGainedTab'
import infotooltips from '../../../common/InfoToolTips'
import './creditGainStats.css'
const CreditGainStats = ({ filteredStudents }) => {
const CreditGainStats = ({ filteredStudents, query }) => {
const renderCreditsGainTab = useCallback(() => {
return (
<Tab.Pane attached={false}>
<CreditsGainedTab filteredStudents={filteredStudents} />
<CreditsGainedTab allStudents={filteredStudents} query={query} />
</Tab.Pane>
)
}, [filteredStudents])
@@ -20,7 +19,7 @@ const CreditGainStats = ({ filteredStudents }) => {
const renderQuartersTab = useCallback(() => {
return (
<Tab.Pane attached={false}>
<StatisticsTab filteredStudents={filteredStudents} />
<StatisticsTab allStudents={filteredStudents} query={query} />
</Tab.Pane>
)
}, [filteredStudents])
@@ -51,8 +50,4 @@ const CreditGainStats = ({ filteredStudents }) => {
)
}
CreditGainStats.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default CreditGainStats
@@ -1,7 +1,7 @@
import React, { useRef } from 'react'
import { connect } from 'react-redux'
import { object, arrayOf, bool, shape, node } from 'prop-types'
import { Message, Accordion, Label } from 'semantic-ui-react'
import { Message, Accordion } from 'semantic-ui-react'
import { useLocalStorage } from '../../common/hooks'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
@@ -111,7 +111,7 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
content: {
content: !query.years ? (
<div ref={creditGainRef}>
<CreditGainStats filteredStudents={filteredStudents} />
<CreditGainStats query={query} filteredStudents={filteredStudents} />
</div>
) : (
<div>This table is omitted when searching population of multiple years</div>
@@ -124,9 +124,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Age distribution
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
NEW!
</Label>
</span>
),
},
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import qs from 'query-string'
import { func, arrayOf, shape, bool, string, object, oneOfType } from 'prop-types'
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import Datetime from 'react-datetime'
@@ -21,7 +20,6 @@ import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, ca
import { useSearchHistory } from '../../common/hooks'
import { setLoading } from '../../redux/graphSpinner'
import './populationSearch.css'
import { dropdownType } from '../../constants/types'
import SearchHistory from '../SearchHistory'
const YEAR_DATE_FORMAT = 'YYYY'
@@ -568,28 +566,6 @@ const PopulationSearchForm = props => {
)
}
PopulationSearchForm.propTypes = {
language: string.isRequired,
getProgrammes: func.isRequired,
getPopulationStatistics: func.isRequired,
getPopulationCourses: func.isRequired,
getMandatoryCourses: func.isRequired,
queries: shape({}).isRequired,
studyProgrammes: shape({}), //eslint-disable-line
studyTracks: arrayOf(dropdownType), //eslint-disable-line
setLoading: func.isRequired,
pending: bool, //eslint-disable-line
getSemesters: func.isRequired,
semesters: shape({}).isRequired,
history: shape({}).isRequired,
location: shape({}).isRequired,
getTagsByStudytrackAction: func.isRequired,
tags: oneOfType([arrayOf(shape({ tag_id: string, tagname: string })), object]).isRequired,
onProgress: func.isRequired,
clearSelected: func.isRequired,
clearPopulations: func.isRequired,
}
const mapStateToProps = ({ semesters, settings, populations, populationProgrammes, tags }) => {
const { language } = settings
const { pending } = populationProgrammes
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment, Header, Divider, Form, Button, Icon } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
@@ -41,19 +40,6 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
)
}
PopulationSearch.propTypes = {
history: PropTypes.shape({
location: PropTypes.shape({
search: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
location: PropTypes.shape({
search: PropTypes.string.isRequired,
}).isRequired,
populationFound: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
}
const mapStateToProps = ({ populations }) => ({
populationFound: populations.data.students !== undefined,
loading: !!populations.pending,
@@ -250,7 +250,7 @@ const GeneralTab = ({
})
}
if (coursecode) {
if (coursecode.length > 0) {
columns.push({
key: 'grade for single course',
title: 'Grade',
@@ -422,7 +422,7 @@ PopulationStudents.defaultProps = {
customPopulation: false,
coursePopulation: false,
dataExport: null,
coursecode: '',
coursecode: [],
}
PopulationStudents.propTypes = {
@@ -446,7 +446,7 @@ PopulationStudents.propTypes = {
customPopulation: bool,
filteredStudents: arrayOf(shape({})).isRequired,
dataExport: node,
coursecode: string,
coursecode: arrayOf(string),
}
const mapStateToProps = state => {
@@ -30,7 +30,7 @@ const FrontPage = React.lazy(() => retry(() => import('../FrontPage')))
const Populations = React.lazy(() => retry(() => import('../PopulationStatistics')))
const StudentStatistics = React.lazy(() => retry(() => import('../StudentStatistics')))
const CourseStatistics = React.lazy(() => retry(() => import('../CourseStatistics')))
const EnableUsers = React.lazy(() => retry(() => import('../EnableUsers')))
const Users = React.lazy(() => retry(() => import('../Users')))
const StudyProgramme = React.lazy(() => retry(() => import('../StudyProgramme')))
const Teachers = React.lazy(() => retry(() => import('../Teachers')))
const Feedback = React.lazy(() => retry(() => import('../Feedback')))
@@ -74,7 +74,7 @@ const Routes = () => (
path={routes.courseStatistics}
component={CourseStatistics}
/>
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={EnableUsers} />
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={Users} />
<ProtectedRoute requiredRoles={['teachers']} exact path={routes.teachers} component={Teachers} />
<ProtectedRoute requireUserHasRights exact path={routes.coursepopulation} component={CoursePopulation} />
<ProtectedRoute requireUserHasRights exact path={routes.custompopulation} component={CustomPopulation} />
@@ -1,6 +1,5 @@
import React, { useState } from 'react'
import { Table } from 'semantic-ui-react'
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
import { sortBy } from 'lodash'
import { useChunk } from '../../common/hooks'
@@ -166,39 +165,4 @@ const SortableTable = ({
)
}
SortableTable.propTypes = {
tableProps: shape({}),
getRowKey: func.isRequired,
getRowProps: func,
columns: arrayOf(
shape({
key: string.isRequired,
title: oneOfType([element, string]),
headerProps: shape({}),
getRowVal: func,
getRowContent: func,
getCellProps: func,
cellProps: shape({}),
group: bool,
children: arrayOf(),
})
).isRequired,
data: arrayOf(shape({})).isRequired,
defaultdescending: bool,
defaultsortkey: string,
collapsingHeaders: bool,
showNames: bool,
chunkifyBy: string,
}
SortableTable.defaultProps = {
tableProps: undefined,
getRowProps: undefined,
defaultdescending: false,
defaultsortkey: null,
collapsingHeaders: false,
showNames: undefined,
chunkifyBy: undefined,
}
export default SortableTable
@@ -1,68 +1,63 @@
import React, { useEffect, useState } from 'react'
import moment from 'moment'
import { shape, arrayOf, func } from 'prop-types'
import { connect } from 'react-redux'
import { useSelector } from 'react-redux'
import { Divider, Table, Label, Header } from 'semantic-ui-react'
import { getNewestProgramme, reformatDate } from '../../../common'
import { getMandatoryCourseModules } from '../../../redux/populationMandatoryCourses'
import {
bachelorHonoursProgrammes as bachelorCodes,
bachelorHonoursBasicModules as basicHonoursModules,
bachelorHonoursIntermediateModules as intermediateHonoursModules,
reformatDate,
} from '../../../common'
const bachelorCodes = [
'KH50_001',
'KH50_002',
'KH50_003',
'KH50_004',
'KH50_005',
'KH50_006',
'KH50_007',
'KH50_008',
'00345',
]
const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatch, mandatoryModules, absentYears }) => {
const BachelorHonours = ({ student, absentYears, programmeCode }) => {
const mandatoryModules = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
const [studentsModules, setModules] = useState([])
const [otherModules, setOther] = useState([])
const [studyStartDate, setStartDate] = useState('')
const [honors, setHonors] = useState(false)
const [render, setRender] = useState(false)
const [graduated, setGraduated] = useState(false)
const [inspection, setInspection] = useState(false)
const [reason, setReason] = useState(null)
useEffect(() => {
if (programmes) {
if (programmeCode) {
const bachelorStudyrights = student.studyrights.filter(sr => sr.extentcode === 1)
const newestBachelorProgramme = getNewestProgramme(bachelorStudyrights, student.studentNumber, null, programmes)
const studyrightWithNewestProgramme = bachelorStudyrights.find(sr =>
sr.studyright_elements.map(srE => srE.code).includes(newestBachelorProgramme.code)
sr.studyright_elements.map(srE => srE.code).includes(programmeCode)
)
if (studyrightWithNewestProgramme) {
setStartDate(moment(studyrightWithNewestProgramme.startdate))
setGraduated(!!studyrightWithNewestProgramme.graduated)
}
// currently only for matlu
const shouldRender = bachelorCodes.includes(newestBachelorProgramme.code)
if (shouldRender) getMandatoryCourseModulesDispatch(newestBachelorProgramme.code)
setRender(shouldRender)
}
}, [programmes])
}, [programmeCode, student])
useEffect(() => {
// very naive perus- and aineopinto filtering
const basicAndIntermediateStudies = mandatoryModules
.filter(mod => mod.name.fi.includes('perusopinnot') || mod.name.fi.includes('aineopinnot'))
.map(mod => mod.code)
const attainedModules = student.courses.filter(
course => basicAndIntermediateStudies.includes(course.course_code) || bachelorCodes.includes(course.course_code)
const mandatoryModuleCodes = mandatoryModules.map(mod => mod.code)
const degreeModule = student.courses.find(mod => bachelorCodes.includes(mod.course_code))
const basicModules = student.courses.filter(mod => basicHonoursModules[programmeCode].includes(mod.course_code))
const intermediateModules = student.courses.filter(mod =>
intermediateHonoursModules[programmeCode].includes(mod.course_code)
)
let honoursModules = []
if (degreeModule) honoursModules = [...honoursModules, degreeModule]
if (basicModules.length) honoursModules = [...honoursModules, ...basicModules]
if (intermediateModules.length) honoursModules = [...honoursModules, ...intermediateModules]
const honoursModulesCodes = honoursModules?.map(mod => mod.course_code)
setModules(honoursModules)
setOther(
student.courses.filter(
c => !honoursModulesCodes.includes(c.course_code) && mandatoryModuleCodes.includes(c.course_code)
)
)
let inTime = false
const degree = attainedModules.find(mod => bachelorCodes.includes(mod.course_code))
if (degree) {
const graduationDate = moment(degree.date)
if (degreeModule) {
const graduationDate = moment(degreeModule.date)
const yearsForGraduation = moment.duration(graduationDate.diff(studyStartDate)).asYears()
// calculate time student has been absent during bachelors degree
@@ -80,53 +75,21 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
inTime = yearsForGraduation <= 3 + Math.round(timeAbsent * 10) / 10
}
if (attainedModules.length > 3) {
// student needs only one basic and one intermediate studies package
// so if there are more than two attained modules + bachelors
// filter out the module that does not match
const pairedModules = []
attainedModules.forEach(mod => {
const filtered = attainedModules.filter(
mod2 => mod2.course_code.slice(4, mod.course_code.length) === mod.course_code.slice(4, mod.course_code.length)
)
if (filtered.length > 1) pairedModules.push(mod)
if (bachelorCodes.includes(mod.course_code)) pairedModules.push(mod)
})
const allBasicUnderFour = basicModules.every(mod => Number(mod.grade) < 4)
const basicAboveFour = basicModules.find(mod => Number(mod.grade) > 3)
const allIntermediateUnderFour = intermediateModules.every(mod => Number(mod.grade) < 4)
const intermediateAboveFour = intermediateModules.find(mod => Number(mod.grade) > 3)
const filterGrades = pairedModules.filter(
mod => !bachelorCodes.includes(mod.course_code) && Number(mod.grade) > 3
)
setHonors(basicAboveFour && intermediateAboveFour && inTime)
const leftOutModules = attainedModules.filter(mod => !pairedModules.includes(mod))
setHonors(filterGrades.length > 1 && inTime)
if (!inTime) {
setReason(degree ? 'Did not graduate in time' : 'Has not graduated')
} else if (filterGrades.length <= 1) {
setReason('Module grades too low')
}
setModules(pairedModules)
setOther(leftOutModules)
setInspection(leftOutModules.length > 0 || (graduated && pairedModules.length < 3))
} else {
const filterGrades = attainedModules.filter(
mod => !bachelorCodes.includes(mod.course_code) && Number(mod.grade) > 3
)
setHonors(filterGrades.length > 1 && inTime)
if (!inTime) {
setReason(degree ? 'Did not graduate in time' : 'Has not graduated')
} else if (filterGrades.length <= 1) {
setReason('Module grades too low')
}
setModules(attainedModules)
setInspection(inTime && graduated && attainedModules.length < 3)
if (!inTime) {
setReason(degreeModule ? 'Did not graduate in time' : 'Has not graduated')
} else if (inTime && graduated && honoursModules.length < 3 && honoursModules.length > 4) {
setInspection(true)
} else if (graduated && (allBasicUnderFour || allIntermediateUnderFour)) {
setReason('Module grades too low')
}
}, [mandatoryModules])
}, [mandatoryModules, student])
const dataRows = modules =>
modules.map(mod => (
@@ -152,7 +115,7 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
</Table>
)
if (!render) return null
if (!programmeCode || !student || !student.courses || !student.studyrights || !mandatoryModules?.length) return null
return (
<>
@@ -183,20 +146,4 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
)
}
BachelorHonours.propTypes = {
student: shape({}).isRequired,
programmes: shape({}).isRequired,
mandatoryModules: arrayOf(shape({})).isRequired,
absentYears: arrayOf(shape({})).isRequired,
getMandatoryCourseModulesDispatch: func.isRequired,
}
const mapStateToProps = ({ populationMandatoryCourses }) => {
return {
mandatoryModules: populationMandatoryCourses.data || [],
}
}
export default connect(mapStateToProps, { getMandatoryCourseModulesDispatch: getMandatoryCourseModules })(
BachelorHonours
)
export default BachelorHonours
@@ -14,8 +14,9 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
const courseRowsByAcademicYear = {}
student.courses.sort(byDateDesc).forEach(c => {
const { date, grade, credits, course, isStudyModuleCredit, passed } = c
const { date, grade, credits, course, isStudyModuleCredit, passed, credittypecode } = c
let icon = null
if (isStudyModuleCredit) {
icon = <Icon name="certificate" color="purple" />
} else if (passed) {
@@ -36,7 +37,7 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
reformatDate(date, 'DD.MM.YYYY'),
`${
isStudyModuleCredit ? `${getTextIn(course.name, language)} [Study Module]` : getTextIn(course.name, language)
} (${course.code})`,
} ${credittypecode === 7 ? `, ${course.code} (korotettu)` : `(${course.code})`}`,
<div>
{icon}
{grade}
@@ -54,7 +55,7 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
reformatDate(date, 'DD.MM.YYYY'),
`${
isStudyModuleCredit ? `${getTextIn(course.name, language)} [Study Module]` : getTextIn(course.name, language)
} (${course.code})`,
} ${credittypecode === 7 ? `, ${course.code} (korotettu)` : `(${course.code})`}`,
<div>
{icon}
{grade}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { func, shape, string, arrayOf, integer, bool } from 'prop-types'
import { connect } from 'react-redux'
import { connect, useDispatch } from 'react-redux'
import { Segment, Loader } from 'semantic-ui-react'
import { isEmpty, sortBy } from 'lodash'
import moment from 'moment'
@@ -10,7 +10,7 @@ import { withRouter } from 'react-router-dom'
import { getStudent, removeStudentSelection, resetStudent } from '../../../redux/students'
import { getSemesters } from '../../../redux/semesters'
import StudentInfoCard from '../StudentInfoCard'
import { getUserIsAdmin } from '../../../common'
import { getUserIsAdmin, bachelorHonoursProgrammes as bachelorCodes, getNewestProgramme } from '../../../common'
import { clearCourseStats } from '../../../redux/coursestats'
import { getProgrammes } from '../../../redux/populationProgrammes'
import BachelorHonours from './BachelorHonours'
@@ -19,6 +19,7 @@ import TagsTable from './TagsTable'
import CourseParticipationTable from './CourseParticipationTable'
import StudentGraphs from './StudentGraphs'
import useLanguage from '../../LanguagePicker/useLanguage'
import { getMandatoryCourseModules } from '../../../redux/populationMandatoryCourses'
const StudentDetails = ({
student,
@@ -36,9 +37,11 @@ const StudentDetails = ({
fetching,
clearCourseStats,
}) => {
const dispatch = useDispatch()
const { language } = useLanguage()
const [graphYearStart, setGraphYear] = useState('')
const [studyrightid, setStudyrightid] = useState('')
const [honoursCode, setHonoursCode] = useState(null)
useEffect(() => {
getProgrammes()
@@ -54,6 +57,22 @@ const StudentDetails = ({
}
}, [studentNumber])
useEffect(() => {
if (Programmes.programmes && student && student.studyrights) {
const bachelorStudyrights = student.studyrights.filter(sr => sr.extentcode === 1)
const newestBachelorProgramme = getNewestProgramme(
bachelorStudyrights,
student.studentNumber,
null,
Programmes.programmes
)
// currently only for matlu
const shouldRender = bachelorCodes.includes(newestBachelorProgramme.code)
if (shouldRender) dispatch(getMandatoryCourseModules(newestBachelorProgramme.code))
if (shouldRender) setHonoursCode(newestBachelorProgramme.code)
}
}, [Programmes, student])
const getAbsentYears = () => {
semesterenrollments.sort((a, b) => a.semestercode - b.semestercode)
const acualSemesters = semesters.semesters
@@ -173,7 +192,7 @@ const StudentDetails = ({
showPopulationStatistics={showPopulationStatistics}
studyrightid={studyrightid}
/>
<BachelorHonours student={student} programmes={Programmes.programmes || {}} absentYears={getAbsentYears()} />
{honoursCode && <BachelorHonours student={student} absentYears={getAbsentYears()} programmeCode={honoursCode} />}
<CourseParticipationTable student={student} language={language} clearCourseStats={clearCourseStats} />
</Segment>
)
@@ -45,15 +45,7 @@ const StudentInfoCard = ({ student }) => {
<div className="startDate">{`Started: ${reformatDate(student.started, DISPLAY_DATE_FORMAT)}`}</div>
<div className="startDate">{email}</div>
</Card.Meta>
<Card.Description>
{`Credits: ${student.credits || 0}`}
{student.mismatch && '*'}
<p>
<i>
{student.mismatch && '*There is difference between the total credits of this student in Oodi and Sisu'}
</i>
</p>
</Card.Description>
<Card.Description>{`Credits: ${student.credits || 0}`}</Card.Description>
<div style={{ paddingTop: '4px' }}>
<EnrollmentAccordion semesterEnrollments={student.semesterenrollments} />
<p style={{ fontSize: 14 }}>{`Updated at ${formattedTimestamp}`}</p>
@@ -2,7 +2,7 @@
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Loader, Header, Accordion, Divider } from 'semantic-ui-react'
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
import { useHistory } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { getCustomPopulation } from '../../redux/populations'
@@ -20,7 +20,7 @@ import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationP
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
const SingleStudyGroupContent = ({ population, language }) => {
const SingleStudyGroupContent = ({ population, group, language }) => {
const custompop = population?.students || []
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
@@ -79,6 +79,7 @@ const SingleStudyGroupContent = ({ population, language }) => {
render={false}
trayOpen={trayOpen}
language={language}
startYear={new Date(parseInt(group?.tags?.year, 10), 7, 1).getTime()}
/>
</div>
),
@@ -150,12 +151,16 @@ const SingleStudyGroupContent = ({ population, language }) => {
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
return (
<div>
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
<div className="segmentContainer">{children}</div>
</div>
<>
<div className="segmentContainer">
<Segment className="contentSegment">
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Divider />
<Header size="medium">{getTextIn(groupName, language)}</Header>
</Segment>
</div>
{children}
</>
)
}
@@ -204,7 +209,7 @@ const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
{renderGroup ? <SingleStudyGroupContent population={population} group={group} language={language} /> : null}
</SingleStudyGroupViewWrapper>
)
}
@@ -1,38 +1,159 @@
import React from 'react'
import { useSelector } from 'react-redux'
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import SortableTable from '../SortableTable'
import { getTextIn } from '../../common'
import { getTextIn, textAndDescriptionSearch } from '../../common'
import StyledMessage from './StyledMessage'
import { changeStudyGuidanceGroupTags } from '../../redux/studyGuidanceGroups'
import { getElementDetails } from '../../redux/elementdetails'
import { useToggle } from '../../common/hooks'
const LinkToGroup = ({ group, language }) => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
)
const prettifyCamelCase = str => {
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
}
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
const dispatch = useDispatch()
return (
<div style={{ display: 'flex', gap: '8px' }}>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => {
dispatch(changeStudyGuidanceGroupTags(group.id, values))
toggleEdit()
}}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<Form onSubmit={formik.handleSubmit} style={{ display: 'flex', gap: '8px' }}>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
search={textAndDescriptionSearch}
fluid
placeholder={
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
/>
) : (
<Datetime
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
renderYear={(props, selectableYear) => <td {...props}>{selectableYear}</td>}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
/>
)}
<Button type="submit" style={{ margin: '0 0 1em 0' }}>
Add {prettifyCamelCase(tagName)}
</Button>
</Form>
)}
</Formik>
{group.tags?.[tagName] ? (
<Button icon style={{ margin: '0 0 1em 0' }} onClick={() => toggleEdit()}>
Close
<Icon name="close" />
</Button>
) : null}
</div>
)
}
const TagCell = ({ tagName, group, studyProgrammes }) => {
const [showEdit, toggleEdit] = useToggle()
const value = group.tags?.[tagName]
const text = tagName === 'studyProgramme' ? studyProgrammes.find(p => p.value === value)?.text : value
return group.tags?.[tagName] && !showEdit ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
<p>{text}</p>
<Button type="button" onClick={() => toggleEdit()}>
Edit {prettifyCamelCase(tagName)}
</Button>
</div>
) : (
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
)
}
const StudyGuidanceGroupOverview = () => {
const dispatch = useDispatch()
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const { data: elementDetails } = useSelector(({ elementdetails }) => elementdetails)
useEffect(() => {
if (elementDetails && elementDetails.length > 0) return
dispatch(getElementDetails())
}, [dispatch])
const studyProgrammesFilteredForDropdown =
elementDetails
?.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
.map(elem => ({
key: elem.code,
value: elem.code,
description: elem.code,
text: getTextIn(elem.name, language),
})) || []
const headers = [
{
key: 'name',
title: 'name',
getRowVal: group => getTextIn(group.name, language),
getRowContent: group => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/studyguidancegroups/${group.id}`}
>
{getTextIn(group.name, language)}
</Link>
),
getRowContent: group => <LinkToGroup group={group} language={language} />,
cellProps: {
style: {
padding: '0',
},
},
},
{
key: 'studyProgramme',
title: 'Study programme',
getRowVal: () => 'studyProgramme',
headerProps: { onClick: null, sorted: null },
getRowContent: group => (
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
),
},
{
key: 'associatedyear',
title: 'Associated year',
getRowVal: () => 'associatedYear',
headerProps: { onClick: null, sorted: null },
getRowContent: group => <TagCell tagName="year" group={group} />,
},
]
if (groups.length === 0) {
@@ -41,7 +162,11 @@ const StudyGuidanceGroupOverview = () => {
return (
<>
<StyledMessage>
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
<p>
Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin. Voit halutessasi
lisätä ohjausryhmään aloitusvuoden ja koulutusohjelman, jolloin yksittäisen ohjausryhmän näkymään avautuu
lisäominaisuuksia.{' '}
</p>
</StyledMessage>
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
</>
@@ -23,20 +23,22 @@ const StudyGuidanceGroups = () => {
const isLoaded = pending === false
return (
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}
</Segment>
</div>
<>
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{isLoading ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
</Segment>
</div>
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
</>
)
}
@@ -1,5 +1,4 @@
import React, { useEffect } from 'react'
import { string, func, shape, bool, oneOfType, array } from 'prop-types'
import { connect } from 'react-redux'
import ProductivityTable from '../ProductivityTable'
import ThroughputTable from '../ThroughputTable'
@@ -53,29 +52,6 @@ const Overview = props => {
)
}
Overview.propTypes = {
studyprogramme: string.isRequired,
dispatchGetProductivity: func.isRequired,
dispatchGetThroughput: func.isRequired,
dispatchGetBachelors: func.isRequired,
history: shape({}).isRequired,
productivity: shape({
error: bool,
pending: bool,
data: shape({}),
}).isRequired, // eslint-disable-line
throughput: shape({
error: bool,
pending: bool,
data: shape({}),
}).isRequired, // eslint-disable-line
bachelors: shape({
error: bool,
pending: bool,
data: oneOfType([shape({}), array]),
}).isRequired // eslint-disable-line
}
const mapStateToProps = ({ studyProgrammeProductivity, studyProgrammeThroughput, studyProgrammeBachelors }) => ({
productivity: studyProgrammeProductivity,
throughput: studyProgrammeThroughput,
@@ -1,5 +1,4 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Segment } from 'semantic-ui-react'
import { Switch, Route, Link, useRouteMatch, useHistory } from 'react-router-dom'
import { useTitle } from '../../common/hooks'
@@ -32,20 +31,10 @@ const NavTile = ({ to, title, imageSrc }) => {
)
}
NavTile.propTypes = {
to: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
}
const NavTiles = ({ children }) => {
return <div className="cool-ds-nav-tiles">{children}</div>
}
NavTiles.propTypes = {
children: PropTypes.node.isRequired,
}
const baseURI = '/trends'
const Trends = () => {
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import { Form, Button, Divider, Message } from 'semantic-ui-react'
import PropTypes from 'prop-types'
import { getAccessGroups } from '../../redux/accessGroups'
import { modifyAccessGroups } from '../../redux/users'
@@ -31,7 +30,7 @@ const AccessGroups = ({
selected.forEach(value => {
rights[value] = true
})
modifyAccessGroups(user.id, rights)
modifyAccessGroups(user.username, rights)
}
return (
@@ -58,19 +57,6 @@ const AccessGroups = ({
)
}
AccessGroups.propTypes = {
user: PropTypes.shape({
id: PropTypes.string,
}).isRequired,
getAccessGroups: PropTypes.func.isRequired,
modifyAccessGroups: PropTypes.func.isRequired,
groups: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
userGroups: PropTypes.arrayOf(PropTypes.string).isRequired,
saveError: PropTypes.bool.isRequired,
savePending: PropTypes.bool.isRequired,
pending: PropTypes.bool.isRequired,
}
const mapStateToProps = ({ accessGroups, users }, { user }) => {
const { data, pending } = accessGroups
const { accessgroupError: saveError, accessgroupPending: savePending } = users
@@ -1,7 +1,6 @@
import React, { useState } from 'react'
import { connect } from 'react-redux'
import { Form, Divider } from 'semantic-ui-react'
import PropTypes from 'prop-types'
import { isEqual } from 'lodash'
import { textAndDescriptionSearch, getTextIn } from '../../common'
import selectors from '../../selectors/programmes'
@@ -69,15 +68,6 @@ const AccessRights = ({ uid, programmes, pending, ...props }) => {
)
}
AccessRights.propTypes = {
addUserUnits: PropTypes.func.isRequired,
uid: PropTypes.string.isRequired,
// used in filteredDropdownProgrammeSelector
rights: PropTypes.arrayOf(PropTypes.string).isRequired,
programmes: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
pending: PropTypes.bool.isRequired,
}
const mapStateToProps = (state, props) => ({
programmes: selectors.filteredDropdownProgrammeSelector(state, props),
pending: Boolean(state.users.userunitpending),
@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, Fragment } from 'react'
import { bool, func, string } from 'prop-types'
import React, { useState, useEffect, useCallback } from 'react'
import { Button, Icon, Modal, Message } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { sendEmail, clearErrors } from '../../redux/userAccessEmail'
@@ -15,11 +14,6 @@ const SendFailBanner = ({ userEmail, error }) => (
<Message error header={`Email could not be sent to ${userEmail}`} list={[error]} />
)
SendFailBanner.propTypes = {
userEmail: string.isRequired,
error: string.isRequired,
}
const EmailConfirm = ({ userEmail, isLoading, error, open, onCancel, onConfirm }) => (
<Modal open={open} onClose={onCancel}>
<Modal.Header>Send email about receiving access to oodikone</Modal.Header>
@@ -41,19 +35,6 @@ const EmailConfirm = ({ userEmail, isLoading, error, open, onCancel, onConfirm }
</Modal>
)
EmailConfirm.defaultProps = {
error: undefined,
}
EmailConfirm.propTypes = {
userEmail: string.isRequired,
error: string,
open: bool.isRequired,
isLoading: bool.isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
}
const EmailNotification = ({ userEmail, onEmailSend, isSendLoading, sendError, onClearErrors }) => {
const [confirmOpen, setConfirmOpen] = useState(false)
@@ -99,19 +80,6 @@ const EmailNotification = ({ userEmail, onEmailSend, isSendLoading, sendError, o
)
}
EmailNotification.defaultProps = {
userEmail: undefined,
sendError: null,
}
EmailNotification.propTypes = {
userEmail: string,
onEmailSend: func.isRequired,
isSendLoading: bool.isRequired,
sendError: string,
onClearErrors: func.isRequired,
}
const mapStateToProps = ({ userAccessEmail }) => ({
isSendLoading: userAccessEmail.sending.pending,
sendError: userAccessEmail.sending.error,
@@ -1,15 +1,10 @@
import React, { useEffect, Fragment } from 'react'
import { bool, func, string, node } from 'prop-types'
import { Label, Message, Segment } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { getPreview } from '../../redux/userAccessEmail'
const Light = ({ children }) => <span style={{ fontWeight: 'normal' }}>{children}</span>
Light.propTypes = {
children: node.isRequired,
}
const EmailHeader = ({ isLoading, subject, to }) => {
if (isLoading) {
return (
@@ -37,17 +32,6 @@ const EmailHeader = ({ isLoading, subject, to }) => {
)
}
EmailHeader.defaultProps = {
subject: undefined,
to: undefined,
}
EmailHeader.propTypes = {
isLoading: bool.isRequired,
subject: string,
to: string,
}
const EmailPreview = ({ userEmail, isLoading, error, subject, html, onPreviewRequested }) => {
useEffect(() => {
onPreviewRequested()
@@ -80,21 +64,6 @@ const EmailPreview = ({ userEmail, isLoading, error, subject, html, onPreviewReq
)
}
EmailPreview.defaultProps = {
subject: null,
html: null,
error: null,
}
EmailPreview.propTypes = {
onPreviewRequested: func.isRequired,
isLoading: bool.isRequired,
userEmail: string.isRequired,
subject: string,
html: string,
error: string,
}
const mapStateToProps = ({ userAccessEmail: { preview } }) => {
const { pending, error, data } = preview
return {
@@ -3,7 +3,7 @@ import { Button, Card, Divider, List, Icon, Popup, Dropdown, Header } from 'sema
import { connect, useSelector } from 'react-redux'
import { sortBy } from 'lodash'
import { withRouter } from 'react-router-dom'
import { getTextIn, getUserRoles, setMocking, textAndDescriptionSearch } from '../../common'
import { getTextIn, getUserRoles, textAndDescriptionSearch } from '../../common'
import { removeUserUnits, setFaculties } from '../../redux/users'
import { getAccessGroups } from '../../redux/accessGroups'
import { getFaculties } from '../../redux/faculties'
@@ -13,6 +13,7 @@ import AccessGroups from './AccessGroups'
import EmailNotification from './EmailNotification'
import { getElementDetails } from '../../redux/elementdetails'
import useLanguage from '../LanguagePicker/useLanguage'
import { useShowAsUser } from '../../common/hooks'
const UserPage = ({
user,
@@ -22,7 +23,6 @@ const UserPage = ({
faculties,
setFaculties,
goBack,
history,
associations,
pending,
getElementDetails,
@@ -33,6 +33,7 @@ const UserPage = ({
}) => {
const { language } = useLanguage()
const { userId: currentUserId } = useSelector(state => state?.auth?.token)
const showAsUser = useShowAsUser()
useEffect(() => {
if (elementdetails.length === 0) getElementDetails()
@@ -45,12 +46,6 @@ const UserPage = ({
const removeAccess = (uid, unit) => () => removeUserUnits(uid, [unit])
const showAs = uid => {
setMocking(uid)
history.push('/')
window.location.reload()
}
const renderUnitList = (usersElementdetailCodes, elementdetails, user) => {
if (!usersElementdetailCodes) return null
const nameInLanguage = element => getTextIn(element.name, language)
@@ -96,7 +91,14 @@ const UserPage = ({
<Popup
content="Show Oodikone as this user"
trigger={
<Button floated="right" circular size="tiny" basic icon="spy" onClick={() => showAs(user.username)} />
<Button
floated="right"
circular
size="tiny"
basic
icon="spy"
onClick={() => showAsUser(user.username)}
/>
}
/>
)}
@@ -1,21 +1,16 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Link } from 'react-router-dom'
import { Button, Icon, Label } from 'semantic-ui-react'
import { getTextIn, reformatDate } from '../../common'
import useLanguage from '../LanguagePicker/useLanguage'
import SortableTable from '../SortableTable'
import { useShowAsUser } from '../../common/hooks'
const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
const { language } = useLanguage()
let usersToRender
if (enabledOnly) {
usersToRender = users.filter(u => u.is_enabled)
} else {
usersToRender = users
}
const usersToRender = enabledOnly ? users.filter(u => u.is_enabled) : users
const showAsUser = useShowAsUser()
return error ? null : (
<div>
@@ -94,6 +89,14 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
<p style={{ color: 'gray' }}>Not saved</p>
),
},
{
key: 'SHOWAS',
title: 'Show as user',
getRowVal: user => (
<Button circular size="tiny" basic icon="spy" onClick={() => showAsUser(user.username)} />
),
headerProps: { onClick: null, sorted: null },
},
{
key: 'EDIT',
title: '',
@@ -113,11 +116,4 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
)
}
UserSearchList.propTypes = {
enabledOnly: PropTypes.bool.isRequired,
users: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
error: PropTypes.bool.isRequired,
elementdetails: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
}
export default UserSearchList
@@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { useHistory, useParams } from 'react-router-dom'
import { Radio, Icon, Header, Segment, Loader, Popup } from 'semantic-ui-react'
import { getUsers } from '../../redux/users'
import { getUnits } from '../../redux/units'
import { getElementDetails } from '../../redux/elementdetails'
import { copyToClipboard } from '../../common'
import UserPageNew from './UserPage'
import UserSearchList from './UserSearchList'
import { useToggle, useTitle } from '../../common/hooks'
const Users = () => {
useTitle('Users')
const dispatch = useDispatch()
const history = useHistory()
const [enabledOnly, toggleEnabledOnly] = useToggle(true)
const [popupTimeout, setPopupTimeout] = useState(null)
const [popupOpen, setPopupOpen] = useState(false)
const { data: units } = useSelector(({ units }) => units)
const { data: elementdetails } = useSelector(({ elementdetails }) => elementdetails)
const { data: users, pending } = useSelector(({ users }) => users)
const { userid } = useParams()
useEffect(() => {
if (elementdetails.length === 0) dispatch(getElementDetails())
if (units.length === 0) dispatch(getUnits())
if (users.length === 0) dispatch(getUsers())
return () => {
clearTimeout(popupTimeout)
}
}, [])
const openUsersPage = () => {
history.push('/users')
}
const copyEmailsToClippoard = () => {
const clipboardString = users
.filter(u => u.is_enabled && u.email)
.map(u => u.email)
.join('; ')
copyToClipboard(clipboardString)
}
const renderUserPage = userid => {
const user = users.find(u => u.id === userid)
return !user ? <Loader active /> : <UserPageNew userid={userid} user={user} goBack={openUsersPage} />
}
const handlePopupOpen = () => {
setPopupOpen(true)
setPopupTimeout(
setTimeout(() => {
setPopupOpen(false)
}, 1500)
)
}
const handlePopupClose = () => {
setPopupOpen(false)
setPopupTimeout(null)
}
const isLoading = pending === undefined || pending === true
const error = users.error || false
const sortedUsers = [...users].sort((a, b) => a.full_name.localeCompare(b.full_name))
return (
<div style={{ marginBottom: '10px' }} className="segmentContainer">
<Header className="segmentTitle" size="large">
Oodikone users
</Header>
<Radio
label={`Showing ${enabledOnly ? 'only enabled' : 'all'} users`}
toggle
onClick={() => toggleEnabledOnly()}
/>
<Segment loading={isLoading} className="contentSegment">
{!userid ? (
<UserSearchList enabledOnly={enabledOnly} users={sortedUsers} error={error} elementdetails={elementdetails} />
) : (
renderUserPage(userid)
)}
</Segment>
<Popup
trigger={<Icon link name="envelope" onClick={copyEmailsToClippoard} style={{ float: 'right' }} />}
content="Copied email(s)!"
on="click"
onOpen={handlePopupOpen}
onClose={handlePopupClose}
open={popupOpen}
/>
</div>
)
}
export default Users
+2 -9
View File
@@ -4,18 +4,14 @@ import { Provider } from 'react-redux'
import 'semantic-ui-css/semantic.min.css'
import 'react-datetime/css/react-datetime.css'
import './styles/custom.css'
import { debugContextDevtool } from 'react-context-devtool'
import store from './store'
import store from './redux'
import ErrorBoundary from './components/ErrorBoundary'
import Main from './components/Main'
import CommonContext from './CommonContext'
import initializeSentry from './util/sentry'
import { isProduction } from './conf'
initializeSentry()
const container = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<CommonContext>
@@ -24,8 +20,5 @@ ReactDOM.render(
</ErrorBoundary>
</CommonContext>
</Provider>,
container
document.getElementById('root')
)
// Best not to use this in production as the performance impact appears to be quite high.
debugContextDevtool(container, { disable: isProduction })
+3
View File
@@ -1,5 +1,8 @@
import { decodeToken } from '../common'
export const loginPrefix = 'LOGIN_'
export const logoutPrefix = 'LOGOUT_'
export const login = () => ({
type: 'LOGIN_ATTEMPT',
})
@@ -1,85 +0,0 @@
import { actionTypes } from '../../apiConnection'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
export default createReducer
@@ -0,0 +1,33 @@
import { actionSuffixes } from '../../apiConnection'
export const matcherReducers = [
{
matcher: action => action.type.endsWith(actionSuffixes.attempt),
reducer(state) {
state.error = false
state.pending = true
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.failure),
reducer(state) {
state.error = true
state.pending = false
},
},
{
matcher: action => action.type.endsWith(actionSuffixes.success),
reducer(state) {
state.error = false
state.pending = false
},
},
]
export const defaultReducer = () => {}
export const initialState = {
pending: false,
error: false,
data: [],
}
+47 -43
View File
@@ -1,4 +1,5 @@
import { combineReducers } from 'redux'
import { configureStore } from '@reduxjs/toolkit'
import { handleRequest, handleAuth } from '../apiConnection'
import actionHistory from './actionHistory'
import users from './users'
import populations from './populations'
@@ -41,46 +42,49 @@ import customPopulationSearch from './customPopulationSearch'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
export default combineReducers({
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
export default configureStore({
reducer: {
actionHistory,
users,
mandatoryCourseLabels,
populations,
populationCourses,
populationSelectedStudentCourses,
populationProgrammes,
populationProgrammesUnfiltered,
populationMandatoryCourses,
studyProgrammeProductivity,
studyProgrammeThroughput,
studyProgrammeBachelors,
faculties,
units,
students,
errors,
courses,
courseInstances,
graphSpinner,
settings,
teachers,
providers,
semesters,
teacherStatistics,
teachersTop,
teachersTopCategories,
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
tags,
tagstudent,
auth,
singleCourseStats,
userAccessEmail,
customPopulationSearch,
coolDataScience,
studyGuidanceGroups,
},
middleware: getDefaultMiddleware => [...getDefaultMiddleware(), handleRequest, handleAuth],
})
@@ -1,35 +1,37 @@
import { callController } from '../apiConnection'
import { createReducer } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
import { initialState, matcherReducers, defaultReducer } from './common'
const prefix = 'GET_STUDY_GUIDANCE_GROUPS_'
export const getStudyGuidanceGroups = () => {
const route = '/studyguidancegroups'
return callController(route, prefix)
const baseUrl = '/studyguidancegroups'
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
const getTypes = {
...actionTypes(getPrefix),
}
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
const changeTagsTypes = {
...actionTypes(changeTagsPrefix),
}
const reducer = (state = { data: [] }, action) => {
switch (action.type) {
case `${prefix}ATTEMPT`:
return {
...state,
pending: true,
}
case `${prefix}FAILURE`:
return {
...state,
pending: false,
error: true,
}
case `${prefix}SUCCESS`:
return {
...state,
pending: false,
error: false,
data: action.response,
}
default:
return state
}
}
export const getStudyGuidanceGroups = () => callController(baseUrl, getPrefix)
export const changeStudyGuidanceGroupTags = (groupId, tags) =>
callController(`${baseUrl}/${groupId}/tags`, changeTagsPrefix, tags, 'put')
const reducer = createReducer(
initialState,
{
[getTypes.success]: (state, action) => {
state.data = action.response
},
[changeTagsTypes.success]: (state, action) => {
state.data.find(group => group.id === action.response.studyGuidanceGroupId).tags = (({
studyGuidanceGroupId,
...rest
}) => rest)(action.response)
},
},
matcherReducers,
defaultReducer
)
export default reducer
+83 -2
View File
@@ -1,5 +1,86 @@
import createReducer from './common/crudreducer'
import { callController } from '../apiConnection/index'
import { callController, actionTypes } from '../apiConnection/index'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
const prefix = 'PROGRAMME_THESIS_'
@@ -1,4 +1,4 @@
import { combineReducers } from 'redux'
import { combineReducers } from '@reduxjs/toolkit'
import { callController, actionTypes } from '../apiConnection'
const CLEAR_USER_ACCESS_ERRORS = 'CLEAR_USER_ACCESS_ERRORS'

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