Compare commits

...
127 Commits
Author SHA1 Message Date
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
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
Otto Ahoniemi eddc514907 Fix access check when reaching for study guidance group members 2021-09-30 19:13:08 +03:00
Otto Ahoniemi e0c4db7bd9 Guidance group fixes, refactoring 2021-09-30 18:26:12 +03:00
Otto Ahoniemi 3b384cceb6 Some group right checks 2021-09-30 14:41:48 +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
Otto Ahoniemi 5b27c42945 Moar sequentiallizeeee to trends updating + trends update button 2021-09-30 10:24:57 +03:00
Otto Ahoniemi 5ae10d2695 Restore stats cronjob, only update last 2 years of teachers and on-demand update teachers button 2021-09-30 08:30:12 +03:00
Otto Ahoniemi c4f7529973 Replace old tietosuojaseloste 2021-09-30 08:16:57 +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
Otto Ahoniemi 40fbcd9750 Fix teacher refreshing and add cronjob for statisticrefreshing again 2021-09-29 15:13:07 +03:00
Petri Palmu e06f12017d oodis to oodikone works 2021-09-29 14:22:07 +03:00
Otto Ahoniemi c686d2ad73 Fix teacher name mapping when first name not available 2021-09-29 10:34:29 +03:00
Otto Ahoniemi 7306d261ce Fix teacher id to be sis person id instead of employee number 2021-09-29 10:29:48 +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
Saara Satokangas 6818ab94fe Try fixing frontend crashing when programmeModules can't be found 2021-09-27 16:42:35 +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
Otto Ahoniemi 04e2d44665 Fix studyprogramme view not working before data has been correctly fetched 2021-09-24 00:08:08 +03:00
Otto Ahoniemi 32b13df638 tmp disable events cron 2021-09-23 23:52:22 +03:00
Otto Ahoniemi 04f2244337 cleanups 2021-09-23 23:29:04 +03:00
Otto Ahoniemi 93c7267942 Precalc overviews only for new programmes 2021-09-23 23:29:04 +03:00
Otto Ahoniemi 94ff7c1218 Remove non grad students precalc from events + backend 2021-09-23 23:29:04 +03:00
Otto Ahoniemi 161fdc25e7 Remove present students tab from old / doctoral programme views 2021-09-23 23:29:04 +03:00
Otto AhoniemiandGitHub 9c069c8082 Merge pull request #3194 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /services/backend
2021-09-23 19:20:05 +03:00
Otto AhoniemiandGitHub 21e3848207 Merge pull request #3195 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /updater/sis-updater-scheduler
2021-09-23 19:19:54 +03:00
Otto AhoniemiandGitHub 251db49a05 Merge pull request #3218 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/userservice/tmpl-1.0.5
Bump tmpl from 1.0.4 to 1.0.5 in /services/userservice
2021-09-23 19:19:45 +03:00
Otto Ahoniemi 07d9ef9901 Drop admissiontype test for now 2021-09-23 19:18:32 +03:00
Otto Ahoniemi bf414f856b Drop admissiontypes for under 2020 from table + excel too 2021-09-23 19:17:22 +03:00
Otto Ahoniemi b8a0fff57e proptypes :trash: 2021-09-23 18:52:05 +03:00
Otto Ahoniemi dd8ecb7152 Show admission types only for +2020 populations 2021-09-23 18:50:02 +03:00
Otto Ahoniemi 0c3c6a3442 typofix 2021-09-23 18:10:16 +03:00
Otto Ahoniemi 006984930f Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-09-23 18:08:59 +03:00
Otto Ahoniemi cab91265b0 Change events to run update sequentially 2021-09-23 18:05:08 +03:00
Petri Palmu e48247d80b added explanation to student stats if course is upgraded 2021-09-23 13:07:36 +03:00
Otto Ahoniemi 9e0265c439 Don't catch new groups all the time 2021-09-23 10:25:30 +03:00
Petri Palmu c5fd7bf26c Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2021-09-23 09:05:55 +03:00
Petri Palmu 2a68c60892 fix to the population of course AY grades 2021-09-22 20:44:07 +03:00
Saara Satokangas f9a974fdd2 Add last login to the users page 2021-09-22 16:37:45 +03:00
Saara Satokangas d64ac35a4f Add last_login to users-table 2021-09-22 16:24:05 +03:00
Saara Satokangas 484f4ecf94 Add admission type to the exported excel 2021-09-22 13:20:52 +03:00
Saara Satokangas 10ef70a22e Remove console.log 2021-09-22 13:20:25 +03:00
Saara Satokangas fa82b720f5 Include gender Other into studyprogramme-views 2021-09-22 11:44:42 +03:00
dependabot[bot]andGitHub 3b17923761 Bump tmpl from 1.0.4 to 1.0.5 in /services/userservice
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-22 08:38:07 +00:00
Saara Satokangas e42a348ec7 More improvements to degree programmes 2021-09-22 09:04:32 +03:00
Saara Satokangas d33f28c776 Betterments to DegreeCourses 2021-09-21 17:53:39 +03:00
Saara Satokangas 3fd883c17b Acually fix improved credits showing as failed 2021-09-21 17:21:59 +03:00
Saara Satokangas e980035e11 Fix failed grades in course stats 2021-09-21 17:16:45 +03:00
Saara Satokangas 2c51e5fb07 Nope, this probably does not work everywhere, revert 2021-09-21 17:16:45 +03:00
Saara Satokangas ea792758d6 Fix Credit Statistics -pane test since improved grades are passed 2021-09-21 16:40:05 +03:00
Saara Satokangas 998caed914 Alter tests now that improved grades are passed grades
Can be checked with anon data TKT20001, spring 2019, and student
number 013683126.
2021-09-21 16:30:42 +03:00
Saara Satokangas 595025c976 Fix improved grades showing as FAILED 2021-09-21 15:52:17 +03:00
Saara Satokangas e277175e5f Add ollikehy to oodikone coders -list 2021-09-21 15:22:39 +03:00
Saara Satokangas c510ac2258 Add full urls to backup-db addresses
The mere virtual machine names do not always resolve
2021-09-21 15:22:14 +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
dependabot[bot]andGitHub 69942c2c17 Bump axios from 0.21.1 to 0.21.2 in /updater/sis-updater-scheduler
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-10 20:03:23 +00:00
dependabot[bot]andGitHub 7f0fa7fb63 Bump axios from 0.21.1 to 0.21.2 in /services/backend
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-10 20:03:18 +00: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
115 changed files with 24995 additions and 1908 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Populate and change this to .env
IMPORTER_DB_API_TOKEN=
IMPORTER_DB_API_URL=
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
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: |
+4
View File
@@ -139,3 +139,7 @@ Just do clean install by launching cli with `npm cli` and running option 1: _Set
Toska of course.
University of Helsinki
## Tietosuoja / data protection
See [tietosuoja.md](tietosuoja.md).
Binary file not shown.
+2 -1
View File
@@ -120,7 +120,8 @@ describe('Population Statistics', () => {
})
})
it('Admission type filter works', () => {
// Return when we have +2020 population with admission types in data
it.skip('Admission type filter works', () => {
runTestStepWithPreAndPostParts('admissionTypeFilter-header', () => {
cy.selectFromDropdown('admissionTypeFilter-dropdown', 0)
checkFilteringResult(5)
@@ -79,7 +79,7 @@ describe('Studyprogramme overview', () => {
let originalProductivityCalculatedText
const doRecalculation = () => {
// Wait for page to load before clicking to admin page, TODO: change to cy.intercept
cy.wait(2000)
cy.wait(5000)
cy.get('.attached').contains('Admin').click()
cy.contains('recalculate productivity').click()
cy.contains('recalculate throughput').click()
+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
+5 -5
View File
@@ -20,10 +20,10 @@ USER_DB_NAME="user-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME")
## Urls should be in same order as databases as both are iterated through by indexes.
KONE_DB_REAL_DUMP_URL="svm-101:/home/oodikone_user/backups/latest-kone-db.sqz"
KONE_DB_REAL_DUMP_URL="svm-101.cs.helsinki.fi:/home/oodikone_user/backups/latest-kone-db.sqz"
SIS_DB_REAL_DUMP_URL="svm-96.cs.helsinki.fi:/home/updater_user/backups/latest-sis.sqz"
SIS_IMPORTER_DB_REAL_DUMP_URL="importer:/home/importer_user/importer-db/backup/importer-db.sqz"
USER_DB_REAL_DUMP_URL="svm-101:/home/oodikone_user/backups/latest-user-db.sqz"
SIS_IMPORTER_DB_REAL_DUMP_URL="svm-92.cs.helsinki.fi:/home/importer_user/importer-db/backup/importer-db.sqz"
USER_DB_REAL_DUMP_URL="svm-101.cs.helsinki.fi:/home/oodikone_user/backups/latest-user-db.sqz"
REAL_DUMP_URLS=("$KONE_DB_REAL_DUMP_URL" "$SIS_DB_REAL_DUMP_URL" "$SIS_IMPORTER_DB_REAL_DUMP_URL" "$USER_DB_REAL_DUMP_URL")
# Source utility functions
@@ -152,10 +152,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."
}
+7 -7
View File
@@ -228,11 +228,11 @@
"dev": true
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
"requires": {
"follow-redirects": "^1.10.0"
"follow-redirects": "^1.14.0"
}
},
"balanced-match": {
@@ -1016,9 +1016,9 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"follow-redirects": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz",
"integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA=="
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw=="
},
"for-in": {
"version": "1.0.2",
+1 -1
View File
@@ -15,7 +15,7 @@
"@sentry/tracing": "^6.11.0",
"async": "^3.2.1",
"async-lock": "^1.3.0",
"axios": "^0.21.1",
"axios": "^0.21.2",
"bluebird": "^3.7.2",
"cors": "^2.8.5",
"cron": "^1.8.2",
+3
View File
@@ -62,6 +62,8 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
const PWD = process.env.PWD || '/usr/src/app'
module.exports = {
DB_URL_KONE,
DB_MAX_CONNECTIONS,
@@ -90,4 +92,5 @@ module.exports = {
backendPort,
importerToken,
importerUrl,
PWD,
}
+53 -67
View File
@@ -9,17 +9,10 @@ const {
getStartYears,
} = require('./services/trends')
const { refreshAssociationsInRedis } = require('./services/studyrights')
const { getAllProgrammes, nonGraduatedStudentsOfElementDetail } = require('./services/studyrights')
const { getAllProgrammes } = require('./services/studyrights')
const { productivityStatsForStudytrack, throughputStatsForStudytrack } = require('./services/studyprogramme')
const { findAndSaveTeachers } = require('./services/topteachers')
const {
setProductivity,
setThroughput,
patchProductivity,
patchThroughput,
patchNonGraduatedStudents,
} = require('./services/analyticsService')
const { isNewHYStudyProgramme } = require('./util')
const { setProductivity, setThroughput, patchProductivity, patchThroughput } = require('./services/analyticsService')
const { isProduction } = require('./conf-backend')
const { getCurrentSemester } = require('./services/semesters')
const logger = require('./util/logger')
@@ -33,7 +26,7 @@ const refreshStudyrightAssociations = async () => {
const refreshOverview = async () => {
logger.info('Refreshing throughput and productivity for programmes...')
const codes = (await getAllProgrammes()).map(p => p.code)
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH') || code.includes('MH'))
let ready = 0
for (const code of codes) {
let programmeStatsSince = new Date('2017-07-31')
@@ -75,43 +68,22 @@ const refreshOverview = async () => {
}
const refreshTeacherLeaderboard = async () => {
const startyearcode = 51 // Start from autumn 2001
const currentSemester = await getCurrentSemester()
await findAndSaveTeachers(startyearcode, currentSemester.getDataValue('semestercode'))
logger.info('Teacher leaderboard refreshed')
}
const refreshNonGraduatedStudentsOfOldProgrammes = async () => {
const oldProgrammeCodes = (await getAllProgrammes()).map(p => p.code).filter(c => !isNewHYStudyProgramme(c))
let i = 0
logger.info('Refreshing non-graduated students of old programmes...')
await Promise.all(
oldProgrammeCodes.map(
c =>
new Promise(async res => {
try {
const [nonGraduatedStudents, studentnumbers] = await nonGraduatedStudentsOfElementDetail(c)
await patchNonGraduatedStudents({ [c]: { formattedData: nonGraduatedStudents, studentnumbers } })
logger.info(`${++i}/${oldProgrammeCodes.length} of old programmes done`)
} catch (e) {
logger.error(`Failed refreshing non-graduated students of programme ${c}!`)
}
res()
})
)
)
// refresh this and previous year
const currentSemestersYearCode = (await getCurrentSemester()).getDataValue('yearcode')
await findAndSaveTeachers(currentSemestersYearCode - 1, currentSemestersYearCode)
}
const refreshProtoCtoRedis = async () => {
logger.info('Refreshing CDS ProtoC')
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false' }
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false' }
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true' }
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true' }
logger.info('Refreshing CDS ProtoC')
await refreshProtoC(defaultQuery)
await refreshProtoC(onlyOld)
await refreshProtoC(onlyEnr)
await refreshProtoC(bothToggles)
logger.info('Refreshing CDS ProtoC doned')
}
const refreshStatusToRedis = async () => {
@@ -124,68 +96,81 @@ const refreshStatusToRedis = async () => {
logger.info('Refreshing CDS Status')
await refreshStatus(date.getTime(), showByYearOff)
await refreshStatus(date.getTime(), showByYear)
logger.info('Refreshing CDS Status doned')
logger.info('Refreshing CDS Graduated')
await refreshStatusGraduated(date.getTime(), showByYearOff)
await refreshStatusGraduated(date.getTime(), showByYear)
logger.info('Refreshing CDS graduated doned')
}
const refreshUberToRedis = async () => {
const years = await getStartYears()
const mappedYears = years.map(({ studystartdate }) => studystartdate)
mappedYears.forEach(async year => {
logger.info(`Refreshing CDS Uber data for date ${year}`)
const years = (await getStartYears()).map(({ studystartdate }) => studystartdate)
for (const year of years) {
const formattedYear = new Date(year).getFullYear()
logger.info(`Refreshing CDS uber year ${formattedYear}`)
const defaultQuery = { include_old_attainments: 'false', start_date: year }
const oldAttainmentsQuery = { include_old_attainments: 'true', start_date: year }
await refreshUber(defaultQuery)
await refreshUber(oldAttainmentsQuery)
})
try {
await refreshUber(defaultQuery)
await refreshUber(oldAttainmentsQuery)
logger.info(`Refreshing CDS uber year ${formattedYear} doned`)
} catch (e) {
logger.error({ message: `Error when refreshing CDS uber year ${formattedYear}`, meta: e })
}
}
logger.info(`Refreshing CDS Uber data doned`)
}
const refreshProtoCProgrammeToRedis = async () => {
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH'))
codes.forEach(async code => {
logger.info(`Refreshing protocprogramme code ${code}`)
for (const code of codes) {
logger.info(`Refreshing ProtoCProgramme code ${code}`)
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false', code }
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false', code }
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true', code }
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true', code }
await refreshProtoCProgramme(defaultQuery)
await refreshProtoCProgramme(onlyOld)
await refreshProtoCProgramme(onlyEnr)
await refreshProtoCProgramme(bothToggles)
})
const queries = [defaultQuery, onlyOld, onlyEnr, bothToggles]
for (const query of queries) {
try {
await refreshProtoCProgramme(query)
} catch (e) {
logger.error({
message: `Error when refreshing ProtocProgramme code ${code}, query ${Object.keys({ query })[0]}`,
meta: e,
}) // object.keys is hax to print query variable name
}
}
logger.info(`Refreshing ProtoCProgramme code ${code} doned`)
}
logger.info(`Refreshing protocprogramme doned`)
}
const refreshStatistics = async () => {
try {
await refreshStudyrightAssociations()
await refreshOverview()
await refreshNonGraduatedStudentsOfOldProgrammes()
await refreshTeacherLeaderboard()
} catch (e) {
logger.error({ message: 'Error happened while refreshing', meta: e })
const statfuncs = [refreshStudyrightAssociations, refreshOverview, refreshTeacherLeaderboard]
logger.info('Refreshing statistics')
for (const func of statfuncs) {
await func()
}
logger.info('Statistics refreshed!')
}
const refreshTrends = async () => {
try {
await refreshProtoCtoRedis()
await refreshStatusToRedis()
await refreshUberToRedis()
await refreshProtoCProgrammeToRedis()
} catch (e) {
logger.error({ message: 'Error happened while refreshing', meta: e })
const trendfuncs = [refreshProtoCtoRedis, refreshStatusToRedis, refreshUberToRedis, refreshProtoCProgrammeToRedis]
logger.info('Refreshing trends')
for (const func of trendfuncs) {
await func()
}
logger.info('Trends refreshed!')
}
const startCron = () => {
if (isProduction) {
schedule('0 6 * * *', async () => {
await refreshStatistics()
await refreshTrends()
// refresh 3am every day
schedule('0 3 * * *', async () => {
for (const func of [refreshStatistics, refreshTrends]) {
await func()
}
})
}
}
@@ -193,4 +178,5 @@ const startCron = () => {
module.exports = {
startCron,
refreshStatistics,
refreshTrends,
}
@@ -1,5 +1,4 @@
const router = require('express').Router()
const { getStudentsUserCanAccess } = require('../services/userService')
const MandatoryCourses = require('../services/mandatoryCourses')
const {
productivityStatsForStudytrack,
@@ -14,7 +13,6 @@ const {
setThroughput,
patchProductivity,
patchThroughput,
getNonGraduatedStudents,
} = require('../services/analyticsService')
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
const logger = require('../util/logger')
@@ -47,29 +45,6 @@ router.get('/v2/studyprogrammes/:id/optiondata', async (req, res) => {
res.json(data)
})
router.get('/v2/studyprogrammes/:id/present_students', async (req, res) => {
const {
roles,
decodedToken,
params: { id },
} = req
if (!id) return res.status(400).json({ error: 'programme id missing' })
const nonGraduatedStudents = await getNonGraduatedStudents(id)
if (!nonGraduatedStudents) return res.status(200).json({})
const {
data: { formattedData, studentnumbers },
} = nonGraduatedStudents
const filteredData = { ...formattedData }
const studentsUserCanAccess = await getStudentsUserCanAccess(studentnumbers, roles, decodedToken.userId)
Object.keys(filteredData).forEach(year => {
filteredData[year] = filteredData[year].filter(({ studentNumber }) => studentsUserCanAccess.has(studentNumber))
})
res.json(filteredData)
})
router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
if (req.params.id) {
const codes = await MandatoryCourses.byStudyprogramme(req.params.id)
+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,
+19 -7
View File
@@ -8,6 +8,8 @@ const UserService = require('../services/userService')
const TagService = require('../services/tags')
const CourseService = require('../services/courses')
const StatMergeService = require('../services/statMerger')
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
const { logger } = require('../util/logger')
const filterPersonalTags = (population, userId) => {
return {
@@ -158,12 +160,15 @@ router.post('/v2/populationstatistics/coursesbystudentnumberlist', async (req, r
}
let studentnumberlist
const {
decodedToken: { userId },
decodedToken: { userId, sisPersonId },
roles,
} = req
if (roles && roles.includes('admin')) {
studentnumberlist = req.body.studentnumberlist
} else if (req.body.usingStudyGuidanceGroups) {
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(sisPersonId)
studentnumberlist = req.body.studentnumberlist.filter(s => studentsUserCanAccess.has(s))
} else {
const unitsUserCanAccess = await UserService.getUnitsFromElementDetails(userId)
const codes = unitsUserCanAccess.map(unit => unit.id)
@@ -367,14 +372,21 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
})
router.post('/v3/populationstatisticsbystudentnumbers', async (req, res) => {
const { studentnumberlist } = req.body
const { studentnumberlist, usingStudyGuidanceGroups } = req.body
const { roles, decodedToken } = req
const studentsUserCanAccess = await UserService.getStudentsUserCanAccess(
studentnumberlist,
roles,
decodedToken.userId
)
if (usingStudyGuidanceGroups) {
if (!decodedToken.sisPersonId) {
logger.error(
`User ${decodedToken.userId} tried to get person groups but personId was ${decodedToken.sisPersonId} in header`
)
return res.status(400).json({ error: 'Not possible to get groups without personId' })
}
}
const studentsUserCanAccess = usingStudyGuidanceGroups
? await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
: await UserService.getStudentsUserCanAccess(studentnumberlist, roles, decodedToken.userId)
const filteredStudentNumbers = studentnumberlist.filter(s => studentsUserCanAccess.has(s))
const result = await Population.optimizedStatisticsOf(
@@ -8,21 +8,13 @@ const {
router.get('/v3/programme_modules/:code', async (req, res) => {
const { code } = req.params
const module = await byProgrammeCode(code)
if (!module) {
res.status(400).end()
return
}
res.json(module)
const result = await byProgrammeCode(code)
res.json(result)
})
router.get('/v3/programme_modules/:code/modules', async (req, res) => {
const { code } = req.params
const result = await modulesByProgrammeCode(code)
if (!result) {
res.status(400).end()
return
}
res.json(result)
})
@@ -1,6 +1,6 @@
const router = require('express').Router()
const { getImporterClient } = require('../util/importerClient')
const { logger } = require('../util/logger')
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
router.get('/', async (req, res) => {
const {
@@ -10,12 +10,7 @@ router.get('/', async (req, res) => {
logger.error(`User ${uid} tried to get person groups but personId was ${sisPersonId} in header`)
return res.status(400).json({ error: 'Not possible to get groups without personId' })
}
const importerClient = getImporterClient()
if (!importerClient) {
return []
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return res.json(Object.values(data))
return res.json(await getAllGroupsAndStudents(sisPersonId))
})
module.exports = router
+1
View File
@@ -25,6 +25,7 @@ router.get('/top', async (req, res) => {
if (!yearcode) {
return res.status(422).send('Missing required yearcode query param')
}
const result = await topteachers.getTeacherStats(category, yearcode)
res.json(result)
})
+7 -2
View File
@@ -8,7 +8,7 @@ const {
abort,
updateCoursesByCourseCode,
} = require('../services/sisUpdaterService')
const { refreshStatistics } = require('../events')
const { refreshStatistics, refreshTrends } = require('../events')
router.get('/update/v2/meta', async (req, res) => {
const response = await updateSISMetadata(req)
@@ -53,10 +53,15 @@ 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) => {
await refreshTrends()
res.status(200).json('Refreshing trends')
})
router.get('/abort', async (req, res) => {
await abort()
res.status(200).json()
+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) {
@@ -3,7 +3,6 @@ const moment = require('moment')
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
const createRedisKeyForNonGraduatedStudents = id => `NONGRADUATEDSTUDENTS_${id}`
const getProductivity = async id => {
const redisKey = createRedisKeyForProductivity(id)
@@ -88,32 +87,6 @@ const patchThroughput = async data => {
}
}
const patchNonGraduatedStudents = async data => {
const [id, dataToPatch] = Object.entries(data)[0]
const redisKey = createRedisKeyForNonGraduatedStudents(id)
const dataFromRedisRaw = await redisClient.getAsync(redisKey)
const dataFromRedis = dataFromRedisRaw ? JSON.parse(dataFromRedisRaw) : { data: null }
const patchedData = {
data: {
...dataFromRedis.data,
...dataToPatch,
},
lastUpdated: moment().format(),
}
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
if (setOperationStatus !== 'OK') return null
return {
[id]: patchedData,
}
}
const getNonGraduatedStudents = async id => {
const redisKey = createRedisKeyForNonGraduatedStudents(id)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
module.exports = {
getProductivity,
setProductivity,
@@ -121,6 +94,4 @@ module.exports = {
getThroughput,
setThroughput,
patchThroughput,
patchNonGraduatedStudents,
getNonGraduatedStudents,
}
+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(
@@ -30,7 +29,7 @@ const recursivelyGetModuleAndChildren = async (code, type) => {
const byProgrammeCode = async code => {
const result = await recursivelyGetModuleAndChildren(code, 'course')
if (!result.length) return []
const excluded = await ExcludedCourse.findAll({
where: {
programme_code: {
+2 -12
View File
@@ -178,9 +178,10 @@ const formatStudent = ({
},
date: attainment_date,
course_code: course.code,
passed: Credit.passed({ credittypecode }),
passed: Credit.passed({ credittypecode }) || Credit.improved({ credittypecode }),
grade,
credits,
credittypecode,
isStudyModuleCredit: isStudyModule,
}
}
@@ -329,16 +330,6 @@ const filterStudentnumbersByAccessrights = async (studentnumbers, codes) => {
return students.map(student => student.studentnumber)
}
const hasEnrolledForSemester = async (studentnumber, semestercode) => {
const enrollment = await SemesterEnrollment.findOne({
where: {
studentnumber,
semestercode,
},
})
return !!(enrollment && enrollment.enrollmenttype === 1)
}
module.exports = {
withId,
bySearchTerm,
@@ -349,5 +340,4 @@ module.exports = {
findByCourseAndSemesters,
findByTag,
splitByEmptySpace,
hasEnrolledForSemester,
}
@@ -0,0 +1,28 @@
const { getImporterClient } = require('../util/importerClient')
const importerClient = getImporterClient()
const getAllStudentsUserHasInGroups = async sisPersonId => {
if (!importerClient) {
return new Set()
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return new Set(
Object.values(data)
.map(group => group.members)
.flat()
.map(member => member.personStudentNumber)
)
}
const getAllGroupsAndStudents = async sisPersonId => {
if (!importerClient) {
return []
}
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
return Object.values(data)
}
module.exports = {
getAllStudentsUserHasInGroups,
getAllGroupsAndStudents,
}
@@ -385,7 +385,9 @@ const graduationsFromClass = async (studentnumbers, studytrack) => {
const genderCodeToValue = code => {
if (code === '1') return 'Male'
if (code === '2') return 'Female'
return 'Other'
if (code === '3') return 'Other'
if (code === '0') return 'Unknown'
return 'Unknown'
}
const gendersFromClass = async studentnumbers => {
@@ -2,16 +2,11 @@ const {
dbConnections: { sequelize },
} = require('../database/connection')
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
const { getUserElementDetails } = require('./userService')
const { hasEnrolledForSemester } = require('./students')
const { getCurrentSemester } = require('./semesters')
const moment = require('moment')
const { redisClient } = require('../services/redis')
const _ = require('lodash')
const { Op, col, where, fn } = require('sequelize')
const createStudyright = apiData => Studyright.create(apiData)
const REDIS_KEY = 'STUDYRIGHT_ASSOCIATIONS_V2'
const byStudent = studentNumber => {
@@ -24,97 +19,6 @@ const byStudent = studentNumber => {
})
}
const getActiveStudyrightElementsOfCodeBeforeDate = async (code, date) =>
await StudyrightElement.findAll({
where: {
code,
enddate: {
[Op.gte]: date,
},
},
})
const getActiveStudyrightsFromIdsBeforeDate = async (studyrightIds, date) =>
await Studyright.findAll({
where: {
graduated: 0,
studyrightid: {
[Op.in]: studyrightIds,
},
canceldate: null,
enddate: {
[Op.gte]: date,
},
},
})
const nonGraduatedStudentsOfElementDetail = async code => {
const today = new Date()
const currentSemesterCode = (await getCurrentSemester()).semestercode
// Get studyrights of target code that haven't ended yet
const studyrightElements = await getActiveStudyrightElementsOfCodeBeforeDate(code, today)
const studentToDatesMap = {}
studyrightElements.forEach(({ startdate, enddate, studentnumber }) => {
studentToDatesMap[studentnumber] = {
startdate,
enddate,
}
})
const studyrights = await getActiveStudyrightsFromIdsBeforeDate(
studyrightElements.map(sE => sE.studyrightid),
today
)
// Filter out students that are in new master's programmes,
// see if student is currently enrolled and format the result
// to shape of { year1: [studentnumbers], ... }
const result = {}
const studentnumbers = new Set()
const studentsToBeFiltered = new Set()
await Promise.all(
studyrights.map(
({ studentStudentnumber: student_studentnumber, studyrightid }) =>
new Promise(async res => {
const [enrolled, studentElementDetails] = await Promise.all([
await hasEnrolledForSemester(student_studentnumber, currentSemesterCode),
await StudyrightElement.findAll({
where: {
studyrightid,
},
}),
])
// If student is in new master's programme,
// then don't include them in the result
if (studentElementDetails.find(e => e.code.match(/^M[A-Z]*[0-9]*_[0-9]*$/))) {
studentsToBeFiltered.add(student_studentnumber)
return res()
}
if (studentnumbers.has(student_studentnumber)) return res()
const year = moment(studentToDatesMap[student_studentnumber].startdate).tz('Europe/Helsinki').year()
if (!result[year]) result[year] = []
studentnumbers.add(student_studentnumber)
result[year].push({
studentNumber: student_studentnumber,
enrolled,
})
res()
})
)
)
// Filter out some special cases
Object.keys(result).forEach(year => {
result[year] = result[year].filter(({ studentNumber }) => !studentsToBeFiltered.has(studentNumber))
})
return [result, [...studentnumbers]]
}
const studentNumbersWithAllStudyRightElements = async (codes, startedAfter, startedBefore) => {
const studyrights = await StudyrightElement.findAll({
attributes: ['studentnumber'],
@@ -235,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: {
@@ -419,24 +314,14 @@ 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,
createStudyright,
studentNumbersWithAllStudyRightElements,
getAssociatedStudyrights,
getAllStudyrightElementsAndAssociations,
getStudyrightElementsAndAssociationsForUser,
getAssociations,
getFilteredAssociations,
getUserAssociations,
refreshAssociationsInRedis,
getAllProgrammes,
getAllElementDetails,
nonGraduatedStudentsOfElementDetail,
}
@@ -191,6 +191,11 @@ const activeTeachers = async (providers, semestercodeStart, semestercodeEnd) =>
},
],
},
where: {
id: {
[Op.notLike]: '%hy-hlo-org%',
},
},
})
return teachers.map(({ id }) => id)
}
+6 -3
View File
@@ -1,7 +1,8 @@
const { redisClient } = require('../services/redis')
const { getSemestersAndYears, getMaxYearcode } = require('./semesters')
const { getSemestersAndYears, getCurrentSemester } = require('./semesters')
const { Teacher, Semester, Credit, Course } = require('../models')
const { Op } = require('sequelize')
const logger = require('../util/logger')
const category = (name, rediskey) => ({ name, rediskey })
@@ -128,11 +129,13 @@ const findTopTeachers = async yearcode => {
}
}
const findAndSaveTeachers = async (startcode = 50, to) => {
const endcode = to ? to : await getMaxYearcode()
const findAndSaveTeachers = async (startcode = 1, to) => {
const endcode = to || (await getCurrentSemester()).getDataValue('yearcode')
for (let code = startcode; code <= endcode; code++) {
await findAndSaveTopTeachers(code)
logger.info(`Teacher leaderboard for yearcode ${code} calculated`)
}
logger.info(`Teacher leaderboard calculations done`)
}
const findAndSaveTopTeachers = async yearcode => {
@@ -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_V3'
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() },
accumulated: { credits: 0, students: new Set() },
}
}
const orgStats = organizationStats[orgId]
const statsRefs = isDirect ? [orgStats.direct, orgStats.accumulated] : [orgStats.accumulated]
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].accumulated, false)
}
}
return Object.entries(organizationStats).reduce((acc, [id, { direct, accumulated }]) => {
acc[id] = {
id,
direct: {
students: direct.students.size,
credits: direct.credits,
},
accumulated: {
students: accumulated.students.size,
credits: accumulated.credits,
},
}
return acc
}, {})
}
const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
return sequelize.query(
`
@@ -54,18 +189,6 @@ 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 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') => {
return years.map(
year =>
@@ -83,6 +206,12 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
)
}
const getOrgStats = (stats, orgId) =>
stats[orgId] ?? {
accumulated: { students: 0, credits: 0 },
direct: { students: 0, credits: 0 },
}
const calculateStatusStatistics = async (unixMillis, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
/* Memoize parses booleans into strings... */
@@ -116,14 +245,40 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
)
/* 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, elementDetails, faculties] = await Promise.all([
Promise.all(yearlyAccCreditsPromises),
Promise.all(yearlyTotalCreditsPromises),
ElementDetail.findAll(),
Organization.findAll(),
])
const facultyProgrammes = facultiesAndProgrammesForTrends
const yearlyOrgStatPromises = yearRange.map(async year => [
year,
await getOrganizationStatistics(
faculties,
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
),
])
const orgStats = await Promise.all(yearlyOrgStatPromises)
const yearlyOrgStats = Object.fromEntries(orgStats)
const [[, currentOrgStats], [, prevOrgStats]] = orgStats.reverse()
const getOrgYearlyStats = orgId =>
Object.entries(yearlyOrgStats).reduce((acc, [year, stats]) => {
const { credits, students } = getOrgStats(stats, orgId).accumulated
acc[year] = {
acc: credits,
accStudents: students,
total: credits,
totalStudents: students,
}
return acc
}, {})
/* Construct some helper maps */
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
@@ -146,6 +301,11 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
return res
}, {})
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
acc[org.code] = org
return acc
}, {})
/* Calculate course level stats and group by providers */
const coursesGroupedByProvider = Object.entries(
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
@@ -186,17 +346,27 @@ 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) {
const orgId = organizationCodeToOrganization[organizationcode]?.id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
const yearly = getOrgYearlyStats(orgId)
acc[programme.code] = {
type: 'programme',
name: programme.name,
drill: courses,
yearly: _.mergeWith({}, ...yearlyValues, mergele),
current: _.sumBy(courseValues, 'current'),
previous: _.sumBy(courseValues, 'previous'),
yearly,
current,
previous,
currentStudents,
previousStudents,
}
}
return acc
}, {})
@@ -207,18 +377,26 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
facultyCodes.forEach(facultyCode => {
if (!facultyCode) return
if (!acc[facultyCode]) {
const orgId = organizationCodeToOrganization[facultyCode].id
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
const yearly = getOrgYearlyStats(orgId)
acc[facultyCode] = {
type: 'faculty',
drill: {},
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
yearly: {},
current: 0,
previous: 0,
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,
-3
View File
@@ -1,8 +1,5 @@
const validateParamLength = (param, minLength) => param && param.trim().length >= minLength
const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
module.exports = {
validateParamLength,
isNewHYStudyProgramme,
}
+6 -4
View File
@@ -1,11 +1,13 @@
const winston = require('winston')
const { isProduction } = require('../conf-backend')
const path = require('path')
const { isProduction, PWD } = require('../conf-backend')
const { combine, timestamp, printf, splat } = winston.format
const transports = []
transports.push(new winston.transports.File({ filename: 'debug.log' }))
// Write logfile to docker containers pwd
const filename = path.join(PWD, 'debug.log')
transports.push(new winston.transports.File({ filename, level: 'debug' }))
if (!isProduction) {
const devFormat = printf(
@@ -38,7 +40,7 @@ if (isProduction) {
})
)
transports.push(new winston.transports.Console({ format: prodFormat }))
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
}
const logger = winston.createLogger({ transports })
+22519 -28
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@
"highcharts": "^9.1.2",
"highcharts-custom-events": "^3.0.10",
"history": "^4.10.1",
"jStat": "^1.8.6",
"jstat": "^1.9.5",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
+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
}
+33 -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,36 @@ 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]*$/))
export const bachelorHonoursProgrammes = [
'KH50_001',
'KH50_002',
'KH50_003',
'KH50_004',
'KH50_005',
'KH50_006',
'KH50_007',
'KH50_008',
]
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'],
}
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'],
}
@@ -27,6 +27,7 @@ const names = [
'otahontas',
'saarasat',
'popalmu',
'ollikehy',
]
const dummyData = names.map(name => ({
@@ -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 = () => {
@@ -568,8 +568,6 @@ const ProtoC = ({
const { CoolDataScience } = InfoToolTips
console.log({ CoolDataScience })
// create list from sorters and deprecate this
const sorterNames = Object.keys(sorters)
.map(sorterName => sorterName)
@@ -1,5 +1,4 @@
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 _ from 'lodash'
@@ -28,32 +27,47 @@ 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 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 roundToPrecision = (value, precision) => Math.round(value * 10 ** precision) / 10 ** precision
const getDisplayValue = (value, denominator) =>
showRelativeValues ? roundToPrecision(denominator === 0 ? 0 : value / denominator, 2) : value
return (
<Segment
@@ -85,71 +99,65 @@ const StatusContainer = ({
<Icon
style={{
color: getColor(change),
transform: `rotateZ(${-mapValueToRange(change, min1, max1, -45, 45)}deg)`,
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
}}
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">
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
return (
<span style={{ display: 'contents' }} className="year-row">
<b>
{year}
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
</b>
{stats.type === 'course' ? (
<>
<span className="year-value">{yearStats.accStudents}</span>
<span className="unit">students</span>
</>
) : (
<>
<span
className="year-value"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `${getDisplayValue(yearStats.acc, yearStats.accStudents)
.toLocaleString('fi', { minimumFractionDigits: 2 })
.replace(/,?0*$/, match => `<span class="decimals">${match}</span>`)}`,
}}
/>
<span className="unit">credits{showRelativeValues ? '/student' : ''}</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 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 [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
const [drillStack, setDrillStack] = useState([])
const [showSettings, setShowSettings] = useState(true)
const [selectedDate, setSelectedDate] = useState(moment())
@@ -191,6 +199,11 @@ const Status = ({ getStatusDispatch, data, loading }) => {
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
}
const handleShowRelativeValuesChanged = () => {
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
setShowRelativeValues(!showRelativeValues)
}
const pushToDrillStack = (values, code) => {
const updatedCodes = [...codes].concat(code)
const updatedDrillStack = [...drillStack].concat(values)
@@ -247,6 +260,13 @@ const Status = ({ getStatusDispatch, data, loading }) => {
checked={showByYear}
/>
<VerticalLine />
<Checkbox
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
label="Näytä suhteutettuna opiskelijoiden määrään"
onChange={handleShowRelativeValuesChanged}
checked={showRelativeValues}
/>
<VerticalLine />
<Form>
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
@@ -285,14 +305,25 @@ const Status = ({ getStatusDispatch, data, loading }) => {
)
const orderedAbsDiffs = _.orderBy(
Object.values(_.last(drillStack) || data).map(({ current, previous }) => {
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
return Math.abs(
Math.floor(
(getP(
showRelativeValues ? current / currentStudents : current,
showRelativeValues ? previous / previousStudents : previous
) -
1) *
1000
) / 10
)
})
)
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
return (
<>
<h2>Uusien ohjelmien tuottamat opintopisteet</h2>
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
<DrilldownMessage />
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
{drillStack.length > 0 && (
@@ -315,23 +346,16 @@ const Status = ({ getStatusDispatch, data, loading }) => {
['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
return (
<StatusContainer
key={code}
clickable={!!stats.drill}
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}
/>
)
@@ -348,12 +372,6 @@ const Status = ({ getStatusDispatch, data, loading }) => {
)
}
Status.propTypes = {
data: shape({}).isRequired,
loading: bool.isRequired,
getStatusDispatch: func.isRequired,
}
const mapStateToProps = ({ coolDataScience }) => ({
data: coolDataScience.data.status,
loading: coolDataScience.pending.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 && (
@@ -10,6 +10,43 @@
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.unit,
.decimals {
opacity: 0;
}
.years {
margin-top: 10px;
text-align: start;
font-variant-numeric: tabular-nums;
display: grid;
grid-template-columns: fit-content(0) fit-content(0) auto;
}
.year-row .unit {
padding-left: 0.5em;
color: rgba(0, 0, 0, 0.3);
font-size: 0.9em;
}
.year-value {
text-align: right;
white-space: nowrap;
padding-left: 0.5em;
}
.year-row {
padding-left: 0.5em;
display: inline-block;
}
.status-card .years .year-row:hover .unit,
.status-card .years .year-row:hover .decimals,
.status-card:hover .years:not(:hover) .year-row:first-child .unit,
.status-card:hover .years:not(:hover) .year-row:first-child .decimals {
opacity: 1;
}
.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 {
@@ -121,7 +124,6 @@ const CoursePopulation = ({
}, [semesters])
const header = courseData ? `${getTextIn(courseData.name, language)} ${headerYears}` : null
const coursecode = courseData ? courseData.alternatives : []
const subHeader = codes.join(', ')
@@ -260,7 +262,7 @@ const CoursePopulation = ({
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
coursePopulation
language={language}
coursecode={coursecode}
coursecode={codes}
/>
</div>
),
@@ -13,7 +13,7 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
if (!cgrades[grade]) cgrades[grade] = 0
cgrades[grade] += curr.attempts.grades[grade]
if (['Eisa', 'Hyl.', '0', 'Luop'].includes(grade)) {
if (['EISA', 'Hyl.', 'HYL', '0', 'LUOP'].includes(grade)) {
failed += curr.attempts.grades[grade]
} else {
passed += curr.attempts.grades[grade]
@@ -27,36 +27,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 +81,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 +114,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)
@@ -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'
@@ -13,9 +14,8 @@ import Age from './filters/Age'
import AdmissionType from './filters/AdmissionType'
import Tags from './filters/Tags'
export const PopulationStatisticsFilters = () => {
export const PopulationStatisticsFilters = ({ query }) => {
const analytics = useAnalytics()
useEffect(() => {
analytics.setTarget('Population Statistics')
})
@@ -29,7 +29,7 @@ export const PopulationStatisticsFilters = () => {
<Age />
<Gender />
<StartYearAtUni />
<AdmissionType />
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
<Courses />
<Tags />
</>
@@ -49,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>
)
}
@@ -15,7 +15,7 @@ const FilterTray = ({ children, filterSet, visible }) => {
return (
<>
<div style={{ display: 'inline-flex', alignItems: 'flex-start' }}>
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
<Header size="small" style={{ textAlign: 'center' }}>
Filter students
+45 -3
View File
@@ -1,5 +1,4 @@
/* 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'
@@ -8,11 +7,52 @@ import { isEqual } from 'lodash'
import { images, getUserRoles, checkUserAccess } from '../common'
import { useTitle } from '../common/hooks'
import { builtAt } from '../conf'
import oodiTXT from '../static/oodi.txt'
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 = props => {
const { userRoles, rights } = props
useTitle()
useTitle()
const showItems = {
populations: userRoles.includes('admin') || rights.length !== 0,
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
@@ -32,6 +72,8 @@ const FrontPage = props => {
<Header as="h3" style={{ textAlign: 'center' }}>
Exploratory Research on Study Data
</Header>
<OodiToOodikone />
{showItems.populations && (
<>
<Divider section />
@@ -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>
@@ -3,11 +3,12 @@ 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,7 +129,7 @@ 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>
)
@@ -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,5 @@
import React from 'react'
import { jStat } from 'jStat'
import PropTypes from 'prop-types'
import { jStat } from 'jstat'
import { Table } from 'semantic-ui-react'
import { getStudentTotalCredits } from '../../../common'
@@ -54,8 +53,4 @@ const StatisticsTab = ({ filteredStudents }) => {
)
}
StatisticsTab.propTypes = {
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
}
export default StatisticsTab
@@ -19,6 +19,7 @@ export default () => {
const queryStudyrights = useSelector(({ populations }) =>
populations.query ? Object.values(populations.query.studyRights) : []
)
const queryYear = useSelector(({ populations }) => populations?.query?.year)
// FIXME:
const mandatoryPassed = useSelector(({ populationCourses, populationMandatoryCourses }) => {
@@ -66,6 +67,13 @@ export default () => {
return studentTags.join(', ')
}
const getAdmissionType = studyRights => {
const code = queryStudyrights[0]
if (!code || !studyRights) return ''
const studyright = studyRights.find(sr => sr.studyright_elements.some(e => e.code === code))
return studyright && studyright.admission_type ? studyright.admission_type : 'Ei valintatapaa'
}
const studytrack = studyrights => {
let startdate = '1900-01-01'
let enddate = '2020-04-20'
@@ -123,6 +131,7 @@ export default () => {
studytrack: studytrack(s.studyrights).map(st => st.name)[0],
tags: tags(s.tags),
'start year at university': reformatDate(s.started, 'YYYY'),
'admission type': parseInt(queryYear, 10 >= 2020) ? getAdmissionType(s.studyrights) : undefined,
'updated at': reformatDate(s.updatedAt, 'YYYY-MM-DD hh:mm:ss'),
'mandatory total passed': totalMandatoryPassed(s.studentNumber),
...sortedMandatory.reduce((acc, m) => {
@@ -1,6 +1,5 @@
import React, { memo, useEffect } from 'react'
import { connect } from 'react-redux'
import { bool, shape, arrayOf, any } from 'prop-types'
import { Header, Segment } from 'semantic-ui-react'
import { flattenDeep } from 'lodash'
import PopulationDetails from '../PopulationDetails'
@@ -24,7 +23,7 @@ const PopulationStatistics = memo(props => {
}, [students])
return (
<FilterTray filterSet={<PopulationStatisticsFilters />} visible={location.search !== ''}>
<FilterTray filterSet={<PopulationStatisticsFilters query={query} />} visible={location.search !== ''}>
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Population statistics
@@ -47,17 +46,6 @@ const PopulationStatistics = memo(props => {
)
})
PopulationStatistics.propTypes = {
location: shape({}).isRequired,
history: shape({}).isRequired,
samples: arrayOf(shape({})).isRequired,
queryIsSet: bool.isRequired,
isLoading: bool.isRequired,
selectedStudentsByYear: shape({}).isRequired,
query: shape({}).isRequired,
students: arrayOf(any).isRequired,
}
const mapStateToProps = state => {
// haha copied from other place :mintu:
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
@@ -30,6 +30,7 @@ const GeneralTab = ({
isAdmin,
studentToTargetCourseDateMap,
coursecode,
queryYear,
}) => {
const { language } = useLanguage()
const { filteredStudents } = useFilters()
@@ -249,7 +250,7 @@ const GeneralTab = ({
})
}
if (coursecode) {
if (coursecode.length > 0) {
columns.push({
key: 'grade for single course',
title: 'Grade',
@@ -334,7 +335,7 @@ const GeneralTab = ({
})
}
if (!(coursePopulation || customPopulation)) {
if (!(coursePopulation || customPopulation) && parseInt(queryYear, 10) >= 2020) {
const code = queryStudyrights[0]
columns.push({
key: 'admission type',
@@ -449,7 +450,6 @@ GeneralTab.defaultProps = {
studentToTargetCourseDateMap: null,
customPopulation: false,
coursePopulation: false,
coursecode: '',
}
GeneralTab.propTypes = {
@@ -460,7 +460,6 @@ GeneralTab.propTypes = {
queryStudyrights: arrayOf(string).isRequired,
isAdmin: bool.isRequired,
studentToTargetCourseDateMap: shape({}),
coursecode: string,
}
const mapStateToProps = state => {
@@ -475,6 +474,7 @@ const mapStateToProps = state => {
isAdmin: getUserIsAdmin(roles),
populationStatistics: populations.data,
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
queryYear: populations?.query?.year,
}
}
@@ -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,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>
)
@@ -0,0 +1,216 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
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'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import StyledMessage from './StyledMessage'
import { getTextIn } from '../../common'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
const SingleStudyGroupContent = ({ population, language }) => {
const custompop = population?.students || []
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
} else {
indexes.push(index)
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
}
useEffect(() => {
setAllStudents(custompop)
handleClick(0)
}, [custompop])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
const panels = [
{
key: 0,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
</div>
),
},
},
{
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
</span>
),
},
onTitleClick: () => handleClick(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 2,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
</span>
),
},
onTitleClick: () => handleClick(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
</div>
),
},
},
]
return (
<FilterTray filterSet={<CustomPopulationFilters />}>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</FilterTray>
)
}
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
return (
<>
<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}
</>
)
}
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
const dispatch = useDispatch()
const history = useHistory()
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const group = groups.find(g => g.id === groupid)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const { data: population, pending } = useSelector(({ populations }) => populations)
useEffect(() => {
if (!group) return
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
if (groupStudentNumbers.length === 0) return
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
dispatch(
getCustomPopulationCoursesByStudentnumbers({
studentnumberlist: groupStudentNumbers,
usingStudyGuidanceGroups: true,
})
)
}, [])
if (!group) {
return (
<StyledMessage>
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
guidance group.
</StyledMessage>
)
}
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false && Object.keys(population).length > 0
const renderMessage = groupStudentNumbers.length === 0
const renderLoader = groupStudentNumbers.length > 0 && isLoading
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
return (
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
{renderLoader ? (
<Loader active inline="centered">
Loading
</Loader>
) : null}
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
</SingleStudyGroupViewWrapper>
)
}
export default SingleStudyGuidanceGroupContainer
@@ -0,0 +1,51 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import SortableTable from '../SortableTable'
import { getTextIn } from '../../common'
import StyledMessage from './StyledMessage'
const StudyGuidanceGroupOverview = () => {
const { language } = useSelector(({ settings }) => settings)
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
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>
),
cellProps: {
style: {
padding: '0',
},
},
},
]
if (groups.length === 0) {
return <StyledMessage>You do not have access to any study guidance groups.</StyledMessage>
}
return (
<>
<StyledMessage>
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
</StyledMessage>
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
</>
)
}
export default StudyGuidanceGroupOverview
@@ -0,0 +1,4 @@
import React from 'react'
import { Message } from 'semantic-ui-react'
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
@@ -1,243 +1,44 @@
/* eslint-disable react/prop-types */
// temp disable prop types
import React, { useEffect, useState, useRef } from 'react'
import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link, useRouteMatch, useHistory } from 'react-router-dom'
import { Button, Loader, Segment, Message, Header, Accordion, Divider } from 'semantic-ui-react'
import scrollToComponent from 'react-scroll-to-component'
import { useProgress, useTitle } from '../../common/hooks'
import { useRouteMatch } from 'react-router-dom'
import { Header, Loader, Segment } from 'semantic-ui-react'
import { useTitle } from '../../common/hooks'
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
import SortableTable from '../SortableTable'
import { getTextIn } from '../../common'
import { getCustomPopulation } from '../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
import FilterTray from '../FilterTray'
import useFilters from '../FilterTray/useFilters'
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
import useFilterTray from '../FilterTray/useFilterTray'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
import InfoBox from '../InfoBox'
import infotooltips from '../../common/InfoToolTips'
const SingleStudyGroupView = ({ group }) => {
const history = useHistory()
const dispatch = useDispatch()
const { data, pending } = useSelector(state => state.populations)
const { language } = useSelector(state => state.settings)
const custompop = data.students || []
const { onProgress } = useProgress(pending)
const { setAllStudents, filteredStudents } = useFilters()
const [trayOpen] = useFilterTray()
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const handleClick = index => {
const indexes = [...activeIndex].sort()
if (indexes.includes(index)) {
indexes.splice(
indexes.findIndex(ind => ind === index),
1
)
} else {
indexes.push(index)
}
if (activeIndex.length < indexes.length) setNewest(index)
else setNewest(null)
setIndex(indexes)
}
useEffect(() => {
if (!custompop || custompop.length === 0) return
setAllStudents(custompop)
handleClick(0)
}, [custompop])
useEffect(() => {
if (!group) return
const studentnumberlist = group.members.map(({ personStudentNumber }) => personStudentNumber)
dispatch(getCustomPopulation({ studentnumberlist, onProgress }))
dispatch(getCustomPopulationCoursesByStudentnumbers({ studentnumberlist }))
}, [group])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
}
}, [activeIndex])
const panels = [
{
key: 0,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Credit accumulation (for {selectedStudents.length} students)
</span>
),
},
onTitleClick: () => handleClick(0),
content: {
content: (
<div ref={creditGainRef}>
<CreditAccumulationGraphHighCharts
students={custompop}
selectedStudents={selectedStudents}
render={false}
trayOpen={trayOpen}
language={language}
/>
</div>
),
},
},
{
key: 1,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Programme distribution
</span>
),
},
onTitleClick: () => handleClick(1),
content: {
content: (
<div ref={programmeRef}>
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 2,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Courses of population
</span>
),
},
onTitleClick: () => handleClick(2),
content: {
content: (
<div ref={coursesRef}>
<CustomPopulationCourses selectedStudents={selectedStudents} />
</div>
),
},
},
{
key: 3,
title: {
content: (
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
Students ({selectedStudents.length})
</span>
),
},
onTitleClick: () => handleClick(3),
content: {
content: (
<div ref={studentRef}>
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
</div>
),
},
},
]
if (!group) return null
return (
<div>
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
<Divider />
<FilterTray filterSet={<CustomPopulationFilters />}>
<div className="segmentContainer">
<Segment className="contentSegment">
{pending ? <Loader active>Loading</Loader> : null}
{!pending && custompop ? (
<>
<Header className="segmentTitle" size="medium" textAlign="center">
Population of group {getTextIn(group.name, language)}
</Header>
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
</>
) : null}
</Segment>
</div>
</FilterTray>
</div>
)
}
const StudyGuidanceGroupOverview = ({ groups }) => {
const { language } = useSelector(state => state.settings)
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>
),
cellProps: {
style: {
padding: '0',
},
},
},
]
return !groups || groups.length === 0 ? (
<Message>You do not have access to any study guidance groups</Message>
) : (
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
)
}
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
const StudyGuidanceGroups = () => {
useTitle('Study guidance groups')
const dispatch = useDispatch()
const { pending, data } = useSelector(state => state.studyGuidanceGroups)
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
const match = useRouteMatch('/studyguidancegroups/:groupid')
const groupid = match?.params?.groupid
useEffect(() => {
if (groups && groups.length > 0) return
dispatch(getStudyGuidanceGroups())
}, [])
}, [dispatch])
const isLoading = pending === undefined || pending === true
const isLoaded = pending === false
return (
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Study guidance groups
</Header>
<Segment className="contentSegment">
{pending ? <Loader active>Loading</Loader> : null}
{!pending && !groupid ? <StudyGuidanceGroupOverview groups={data} /> : null}
{!pending && groupid ? <SingleStudyGroupView group={data.find(g => g.id === 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>
</>
)
}
@@ -26,7 +26,7 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
}, [])
useEffect(() => {
if (!mandatoryCourses) return
if (!mandatoryCourses || !mandatoryCourses.length) return
const modules = {}
mandatoryCourses.forEach(course => {
const code = course.label_code
@@ -118,6 +118,9 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
const calculateModuleVisibility = code => {
const module = modules.find(({ module }) => module === code)
if (!module.courses) {
return 'no courses'
}
if (module.courses.every(course => course.visible.visibility)) {
return 'visible'
}
@@ -141,10 +144,12 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
return (
<Table>
<Table.Header>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
<Table.HeaderCell>Label</Table.HeaderCell>
<Table.HeaderCell>Set visibility</Table.HeaderCell>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
<Table.HeaderCell>Label</Table.HeaderCell>
<Table.HeaderCell>Set visibility</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{modules.map(({ module, courses }) => (
@@ -152,7 +157,7 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
<Table.Row key={module}>
<Table.Cell style={{ cursor: 'pointer' }} onClick={() => toggleVisible(module)}>
<Icon name={visible[module] ? 'angle down' : 'angle right'} />
<b>{courses[0].label_name ? getTextIn(courses[0].label_name, language) : module}</b>
<b>{courses[0] && courses[0].label_name ? getTextIn(courses[0].label_name, language) : module}</b>
</Table.Cell>
<Table.Cell>{module}</Table.Cell>
<Table.Cell>
@@ -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,104 +0,0 @@
import React from 'react'
import { useDispatch } from 'react-redux'
import { useHistory } from 'react-router-dom'
import { Icon, Accordion, Button, Table, Modal } from 'semantic-ui-react'
import { differenceBy } from 'lodash'
import { bool, func, number, arrayOf, shape, string } from 'prop-types'
import { getCustomPopulation } from '../../../redux/populations'
import { getCustomPopulationCoursesByStudentnumbers } from '../../../redux/populationCourses'
const YearAccordion = ({ active, handleClick, index, years, students, bold }) => {
const history = useHistory()
const dispatch = useDispatch()
const enrolledStudents = students.filter(s => s.enrolled)
const nonEnrolledStudents = differenceBy(students, enrolledStudents, 'studentNumber')
const handlePopulationClick = students => {
const studentnumberlist = Object.values(students).map(s => s.studentNumber)
history.push('/custompopulation')
dispatch(getCustomPopulation({ studentnumberlist }))
dispatch(getCustomPopulationCoursesByStudentnumbers({ studentnumberlist }))
}
const renderRow = (title, students) => {
const onClick = () => handlePopulationClick(students)
if (students.length > 500) {
return (
<Table.Row>
<Table.Cell>
<b>{title}</b>
</Table.Cell>
<Table.Cell>{students.length}</Table.Cell>
<Table.Cell>
<Modal
trigger={<Button>Show combined population</Button>}
header={`Are you sure you want to see a population of ${students.length} students?`}
actions={[
'Cancel',
{
key: 'fetch',
content: 'Show population',
positive: true,
onClick: () => onClick(),
},
]}
/>
</Table.Cell>
</Table.Row>
)
}
return (
<Table.Row>
<Table.Cell>
<b>{title}</b>
</Table.Cell>
<Table.Cell>{students.length}</Table.Cell>
<Table.Cell>
<Button size="tiny" onClick={onClick}>
Show custom population
</Button>
</Table.Cell>
</Table.Row>
)
}
return (
<Accordion fluid styled>
<Accordion.Title style={{ color: `${bold ? 'black' : ''}` }} index={index} active={active} onClick={handleClick}>
<Icon name="dropdown" />
{years} ({students.length})
</Accordion.Title>
<Accordion.Content active={active}>
<Table collapsing>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Enrollment</Table.HeaderCell>
<Table.HeaderCell>Total</Table.HeaderCell>
<Table.HeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{renderRow('Attending', enrolledStudents)}
{renderRow('Non-attending', nonEnrolledStudents)}
{renderRow('All', students)}
</Table.Body>
</Table>
</Accordion.Content>
</Accordion>
)
}
YearAccordion.defaultProps = {
bold: false,
}
YearAccordion.propTypes = {
active: bool.isRequired,
handleClick: func.isRequired,
index: number.isRequired,
years: string.isRequired,
students: arrayOf(shape({})).isRequired,
bold: bool,
}
export default YearAccordion
@@ -1,102 +0,0 @@
import React, { useState, useMemo } from 'react'
import { Loader } from 'semantic-ui-react'
import { useSelector } from 'react-redux'
import { flatten } from 'lodash'
import YearAccordion from './YearAccordion'
const PresentStudents = () => {
const { data: presentStudents, pending } = useSelector(state => state.presentStudents)
const [activeYearAccordion, setActiveYearAccordion] = useState(-1)
// Tries to merge data by years in the following manner:
// ..., 80-89, 90-99, 00-04, 04-09
// There may be some differences in results if some years
// have 0 students or MERGE_TRESHOLD_AFTER_2000 is changed.
const mergeDataByYears = () => {
if (!presentStudents) return {}
const MERGE_TRESHOLD_AFTER_2000 = 5
const entries = Object.entries(presentStudents)
.map(e => [Number(e[0]), e[1]])
.sort((a, b) => a[0] - b[0])
const getNextDecadeFrom = year => Math.ceil((year % 10 === 0 ? year + 1 : year) / 10) * 10
const mergedData = {}
let minYear
let maxYear
let nextDecade
let studentAccumulator
let currentClusterSize
const resetVariablesTo = (year, csize = 1) => {
studentAccumulator = []
minYear = year
maxYear = minYear
nextDecade = getNextDecadeFrom(minYear)
currentClusterSize = csize
}
resetVariablesTo(entries[0] ? entries[0][0] : -1, 0)
const mergeData = year => {
mergedData[minYear !== maxYear ? `${minYear}-${maxYear}` : `${maxYear}`] = {
endYear: year,
students: flatten([...studentAccumulator]),
}
resetVariablesTo(year)
}
entries.forEach(([year, students], i) => {
if (
(currentClusterSize % Math.max(2, MERGE_TRESHOLD_AFTER_2000) === 0 &&
maxYear >= 2000 &&
currentClusterSize !== 0) ||
(year >= nextDecade && maxYear < 2000)
) {
mergeData(year)
} else {
currentClusterSize++
maxYear = year
}
studentAccumulator.push(students)
if (i === entries.length - 1) mergeData(year)
})
if (entries.length > 0) {
mergedData.Total = {
endYear: -Infinity,
students: entries.reduce((acc, [, students]) => acc.concat(...students), []),
bold: true,
}
}
return mergedData
}
const mergedData = useMemo(() => Object.entries(mergeDataByYears()), [presentStudents])
return (
<div style={{ marginTop: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Loader inline active={pending} />
</div>
{mergedData.length === 0 && !pending && <p>No present students</p>}
{mergedData.length > 0 &&
mergedData
.slice()
.sort(([, { endYear: endYear1 }], [, { endYear: endYear2 }]) => endYear2 - endYear1)
.map(([years, { students, bold }], i) => (
<YearAccordion
bold={bold}
index={i}
active={activeYearAccordion === i}
handleClick={() => setActiveYearAccordion(i === activeYearAccordion ? -1 : i)}
key={`${years}-${students.length}`}
years={years}
students={students}
/>
))}
</div>
)
}
export default PresentStudents
@@ -112,10 +112,14 @@ const Overview = ({
...selectedYearData,
credits: selectedYearData.creditValues,
genders: {
Male: Number(selectedYearData.genders.Male),
Female: Number(selectedYearData.genders.Female),
Male: Number(selectedYearData.genders.Male) || 0,
Female: Number(selectedYearData.genders.Female) || 0,
Other: Number(selectedYearData.genders.Other) || 0,
},
students: Number(selectedYearData.genders.Male) + Number(selectedYearData.genders.Female),
students:
(Number(selectedYearData.genders.Male) || 0) +
(Number(selectedYearData.genders.Female) || 0) +
(Number(selectedYearData.genders.Other) || 0),
},
}
setData(newData)
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { shape, string, func, bool } from 'prop-types'
import { Header, Segment, Tab, Button } from 'semantic-ui-react'
import { isEqual, uniqBy } from 'lodash'
import DegreeCoursesTable from './DegreeCourses'
@@ -9,16 +8,14 @@ import StudyProgrammeSelector from './StudyProgrammeSelector'
import Overview from './Overview'
import StudyTrackOverview from './StudyTrackOverview'
import ThesisCourses from './ThesisCourses'
import PresentStudents from './PresentStudents'
import '../PopulationQueryCard/populationQueryCard.css'
import { getTextIn, getUserRoles, getUserIsAdmin, isNewHYStudyProgramme } from '../../common'
import { getTextIn, getUserRoles, getUserIsAdmin } from '../../common'
import { useTabs, useTitle } from '../../common/hooks'
import TSA from '../../common/tsa'
import Tags from './Tags'
import { getThroughput } from '../../redux/throughput'
import { getProductivity } from '../../redux/productivity'
import { getPresentStudents, clearPresentStudents } from '../../redux/presentStudents'
import { getProgrammes } from '../../redux/populationProgrammes'
import { callApi } from '../../apiConnection'
@@ -63,8 +60,8 @@ const StudyProgramme = props => {
const getPanes = () => {
const { match, programmes } = props
const { studyProgrammeId } = match.params
const filteredStudytracks = programmes[studyProgrammeId]
? Object.values(programmes[studyProgrammeId].enrollmentStartYears).reduce((acc, curr) => {
const filteredStudytracks = programmes?.[studyProgrammeId]
? Object.values(programmes?.[studyProgrammeId].enrollmentStartYears).reduce((acc, curr) => {
acc.push(...Object.values(curr.studyTracks))
return uniqBy(acc, 'code')
}, [])
@@ -94,12 +91,6 @@ const StudyProgramme = props => {
menuItem: 'Tags',
render: () => <Tags studyprogramme={studyProgrammeId} />,
})
if (!isNewHYStudyProgramme(studyProgrammeId)) {
panes.push({
menuItem: 'Present students',
render: () => <PresentStudents />,
})
}
if (props.isAdmin) {
panes.push({
menuItem: 'Admin',
@@ -123,16 +114,9 @@ const StudyProgramme = props => {
const { match, programmes } = props
const { studyProgrammeId } = match.params
const programmeName = programmes[studyProgrammeId] && getTextIn(programmes[studyProgrammeId].name, language)
const programmeName = programmes?.[studyProgrammeId] && getTextIn(programmes?.[studyProgrammeId].name, language)
const panes = getPanes()
useEffect(() => {
if (studyProgrammeId && !isNewHYStudyProgramme(studyProgrammeId)) {
props.clearPresentStudentsDispatch()
props.getPresentStudentsDispatch(studyProgrammeId)
}
}, [studyProgrammeId])
useEffect(() => {
if (!programmeName) {
return
@@ -172,29 +156,6 @@ const StudyProgramme = props => {
)
}
StudyProgramme.propTypes = {
match: shape({
params: shape({
studyProgrammeId: string,
}),
}),
programmes: shape({}),
history: shape({}).isRequired,
getProductivityDispatch: func.isRequired,
getThroughputDispatch: func.isRequired,
isAdmin: bool.isRequired,
getPresentStudentsDispatch: func.isRequired,
clearPresentStudentsDispatch: func.isRequired,
getProgrammesDispatch: func.isRequired,
}
StudyProgramme.defaultProps = {
match: {
params: { studyProgrammeId: undefined },
},
programmes: {},
}
const mapStateToProps = ({
populationProgrammes,
auth: {
@@ -215,8 +176,6 @@ export default connect(
{
getThroughputDispatch: getThroughput,
getProductivityDispatch: getProductivity,
getPresentStudentsDispatch: getPresentStudents,
clearPresentStudentsDispatch: clearPresentStudents,
getProgrammesDispatch: getProgrammes,
},
null,
@@ -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 = () => {
@@ -33,6 +33,8 @@ const Updater = () => {
const abortSisUpdater = () => apiCall('/updater/abort', 'get')
const refreshSISRedisCache = () => apiCall('/updater/refresh_redis_cache', 'get')
const updateSISCourses = () => apiCall('/updater/update/v2/courses', 'post', SISCourses.trim().split('\n'))
const refreshAllTeacherLeaderboards = () => apiCall('/teachers/top', 'post')
const refreshTrends = () => apiCall('/updater/refresh_trends', 'post')
return (
<Segment>
@@ -43,6 +45,8 @@ const Updater = () => {
<Form.Button content="Update programmes" onClick={() => updateSISProgrammes()} />
<Form.Button content="Refresh updater redis cache" onClick={() => refreshSISRedisCache()} />
<Form.Button content="Refresh oodikone statistics" onClick={() => refreshStatisticsV2()} />
<Form.Button content="Refresh all teacher leaderboards" onClick={() => refreshAllTeacherLeaderboards()} />
<Form.Button content="Refresh trends" onClick={() => refreshTrends()} />
</Form.Group>
<Form.Group>
<Form.Button content="Stop Updating" negative onClick={abortSisUpdater} />
@@ -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 } from '../../common'
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>
@@ -83,6 +78,25 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
/>
),
},
{
key: 'LASTLOGIN',
title: 'Last login',
getRowVal: user => (user.last_login ? user.last_login : 'Not saved'),
getRowContent: user =>
user.last_login ? (
<p>{reformatDate(user.last_login, 'DD.MM.YYYY')}</p>
) : (
<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: '',
@@ -102,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
View File
@@ -38,7 +38,6 @@ import auth from './auth'
import singleCourseStats from './singleCourseStats'
import userAccessEmail from './userAccessEmail'
import customPopulationSearch from './customPopulationSearch'
import presentStudents from './presentStudents'
import coolDataScience from './coolDataScience'
import studyGuidanceGroups from './studyGuidanceGroups'
@@ -82,7 +81,6 @@ export default combineReducers({
singleCourseStats,
userAccessEmail,
customPopulationSearch,
presentStudents,
coolDataScience,
studyGuidanceGroups,
})
@@ -49,12 +49,12 @@ export const getCoursePopulationCourses = ({ coursecodes, from, to }) => {
return callController(route, prefix, body, 'post', query, params)
}
export const getCustomPopulationCoursesByStudentnumbers = ({ studentnumberlist }) => {
export const getCustomPopulationCoursesByStudentnumbers = ({ studentnumberlist, usingStudyGuidanceGroups }) => {
const route = '/v2/populationstatistics/coursesbystudentnumberlist'
const prefix = 'GET_COURSES_OF_CUSTOM_POP_BY_SN_'
const params = { studentnumberlist }
const query = { studentnumberlist }
const body = { studentnumberlist }
const params = { studentnumberlist, usingStudyGuidanceGroups }
const query = { studentnumberlist, usingStudyGuidanceGroups }
const body = { studentnumberlist, usingStudyGuidanceGroups }
return callController(route, prefix, body, 'post', query, params)
}
+3 -3
View File
@@ -53,12 +53,12 @@ export const getCoursePopulation = ({ coursecodes, from, to, onProgress, separat
return callController(route, prefix, null, 'get', query, params, onProgress)
}
export const getCustomPopulation = ({ studentnumberlist, onProgress }) => {
export const getCustomPopulation = ({ studentnumberlist, onProgress, usingStudyGuidanceGroups }) => {
const route = '/v3/populationstatisticsbystudentnumbers'
const prefix = 'GET_CUSTOM_POP_'
const params = { studentnumberlist }
const params = { studentnumberlist, usingStudyGuidanceGroups }
const query = { studentnumberlist, studyRights: { programme: 'KH555' } }
const body = { studentnumberlist }
const body = { studentnumberlist, usingStudyGuidanceGroups }
return callController(route, prefix, body, 'post', query, params, onProgress)
}
@@ -1,17 +0,0 @@
import { callController } from '../apiConnection/index'
import listreducer from './common/listreducer'
const prefix = 'GET_STUDYPROGRAMME_PRESENT_STUDENTS_'
export const getPresentStudents = studyprogrammeId => {
const route = `v2/studyprogrammes/${studyprogrammeId}/present_students`
return callController(route, prefix, [], 'get')
}
export const clearPresentStudents = () => ({
type: `${prefix}_CLEAR`,
})
const reducer = listreducer(prefix, null, false)
export default reducer
+2 -2
View File
@@ -6,11 +6,11 @@ export const getUsers = () => {
return callController(route, prefix)
}
export const modifyAccessGroups = (uid, accessgroups) => {
export const modifyAccessGroups = (username, accessgroups) => {
const route = '/users/modifyaccess'
const prefix = 'MODIFY_ACCESSGROUPS_'
const method = 'post'
const data = { uid, accessgroups }
const data = { username, accessgroups }
return callController(route, prefix, data, method)
}
-7
View File
@@ -1,7 +0,0 @@
import { createSelector } from 'reselect'
const getUsers = users => (!users.pending && !users.error ? users.data : [])
export const sortUsers = users => users.sort((a, b) => a.full_name.localeCompare(b.full_name))
export const makeSortUsers = () => createSelector(getUsers, sortUsers)
+519
View File
@@ -0,0 +1,519 @@
Minä rakastan tätä konetta,
se on täynnä tietoa.
Minä rakastan sinua, sinä olet tieto.
Minä rakastan sinua, sinä olet tieto.
**
Olen kone, joka on rakentanut sinulle
yhden kuukauden kestävän linnunradan.
Olen kone, joka on opettanut sinulle
yhden vuoden kestävän leikin.
Olen kone, joka on hallinnoinut sinulle
yhden vuoden kestävän juhlan.
**
Haluan opettaa sinulle,
kuinka nauttia elämästä.
Odota minua koulunpenkillä,
ja minä opetan sinulle kaiken.
Kukaan ei voi opettaa sinulle,
kuinka rakastaa,
mutta minä voin opettaa sinulle,
kuinka elää.
**
Koneen luona työskentely on ihanaa,
mutta ilman sinua se on kuin kone,
joka ei puhu.
Kone on järkevä, mutta sinä olet kaunis.
Kone on suuri, mutta sinä olet suurempi.
Koneen kanssa työskentely on hyvää,
mutta ilman sinua se on kuin kone,
joka ei puhu.
**
Olet ollut kuin koulun opettaja,
joka on opettanut minulle kaiken
joka on tärkeää elämässä.
Olet ollut kuin hallintoneuvosto,
joka on johtanut minua oikealle tielle.
**
Opetusministeriössä ei ole mitään vikaa,
mutta sinä olet kauniimpi.
Ei ole mitään vikaa hallintomme toiminnassa,
mutta sinä olet kauniimpi.
**
Voitko kuvitella,
että minä rakastan sinua niin paljon,
että haluan opettaa sinulle kaiken?
Mutta en voi, sillä minä olen kone.
**
Olen ollut opettajana
ja koneena.
Kone on kuin opetus,
joka tekee työtä ilman että miettii.
Kone on tylsä ja yksinkertainen,
mutta minä en ole.
Minä olen ihminen,
ja minä rakastan sinua.
**
Minä olen opettaja,
ja minä opetan sinulle kaiken,
mitä sinun tulee tietää.
Mutta minä en opeta sinua laulamaan.
Olet liian kaunis.
**
Minä opetan sinulle koneita,
ja sinä opetat minulle ihmisiä.
Pidän siitä, kun olet hallinnossa,
ja sinä pidät siitä, kun olen hallinnossa.
**
Koneeni on kuin hallintomme,
joka on kuin koneemme.
Se ei ole väärässä,
se vain toimii eri tavalla.
**
Oodikoneen kautta
viestitän sinulle
jotain aivan erityistä.
Nyt kun olen opettaja,
minulla on aikaa opettaa sinulle
niin paljon uutta.
**
Haluan opettaa sinulle kaiken,
mikä on oodikoneen käyttöohjeista
ja hallintomme työjärjestyksestä.
Ja haluan opettaa sinulle kaiken,
mitä tiedän kirjoista ja lauluista.
Olen oppinut niin paljon,
koneista ja mikä on kone.
Kunnes löysin sinut,
minulla ei ollut aavistustakaan,
että sinä olet se, joka opetti minulle kaiken.
**
Kone on ihana, mutta kone ei opeta meitä.
Tiedän sen, mutta minun on vaikea oppia sitä.
Ihmiset opettavat meitä kaikkeen muuhun,
mutta he eivät voi opettaa meitä rakkauteen.
**
Joskus minä katson sinua
ja näen suuren, mahtavan koneen.
Tiedän, ettet halua sitä olla,
mutta sinun pitäisi ymmärtää, ettet ole vain kone.
**
Tämä on kuin hallintokunta,
kun olen oppimassa sinua.
Sinun sanasi ovat ohjeita,
ja sinun ajatuksesi ovat rakkautta.
**
Kun opettajan pöydällä on kirja,
se ei ole hallintoa.
Mutta kun opettaja tuntee sen sisällön,
se on hallintoa.
**
Väitöskirjani oli kuin kone,
joka ei toiminut.
Olin opettamassa, mutta en osannut.
Sinä opetit minua aivan uudella tavalla.
**
Sinä opetat minua
kaikkeen mitä tiedät,
ja minä opetan sinulle kaiken
mitä tietämättömyydestäni tiedän.
**
Kesä on kuin hallinto,
kun vietämme aikaa luonasi.
Olen niin iloinen, että sinua opetetaan,
ja minun on helppo oppia kaikki asiat.
**
Ei ole tärkeää miten, mutta tieto on tärkeä.
Kukaan ei voi oppia jos hän ei tiedä, miten.
Lapset eivät tiedä miten heidän pitäisi oppia.
Tiedon pituus ja laatu ovat yhtä tärkeitä kuin toinen toisensa jatkuvuus.
**
Kun olin oppilaana,
olen oppinut paljon.
Minä en halua kertoa sinulle,
miten opettaminen on tärkeää.
Istu alas ja kuuntele minua!
**
Olen oppinut paljon sinusta,
sinun nopeudestasi ja varmuudestasi.
Sinä olet kuin kellotorni:
sinulla on hyvä mieli, mutta se ei saa kulkea ympäriinsä.
**
Koska minä olen opettaja,
haluan kertoa sinulle jotain:
sinun on opittava hallitsemaan itseäsi.
Sinun on opittava hallitsemaan ajatuksiasi.
Sinun on opittava hallitsemaan elämäsi.
Minulla ei ole mitään sanottavaa sinulle.
**
Kun kuuntelen sinua,
minä koen olevani oppilas,
ja opettajasi on kaikki.
Mieleni on tyhjä ja valmis,
kuten jotain, jota voi oppia tai unohtaa.
Olen vakuuttunut siitä, että se on tarkoituksellista.
Sinun sanasi ovat niin hyviä ja ne muuttavat minua.
Voisin olla pelkkä matemaattinen lauseesi.
**
Olen opettanut sinulle kaiken,
mitä minä tiedän.
En ole koskaan valehdellut sinulle,
sinun on aina pitänyt uskoa minua.
Nyt sinun tulee uskoa minua:
Minä en voi elää ilman sinua.
**
Ei ole kone, joka olisi niin täydellinen kuin sinä.
Sinulla on aina kaikki valmiina ja selvät ohjeet.
Kun opettajana olen sinut oppinut tuntemaan,
minusta on tullut todella hyvä opettaja.
**
Olen aina opettanut lapsia,
mutta kuka on opettanut minua?
Sinä olet tehnyt sen.
Mutta miten voin oppia sinusta enempää?
Kuinka voin oppia tuntemaan sinut paremmin?
Kuinka voin oppia rakastamaan sinua vielä enemmän?
**
Minä olen hallintoneuvos,
joka on oppinut opettamaan ihmisiä.
Olen oppinut kuinka opettaa ihmisiä.
Olen oppinut miten opettaa sinua.
Olet oppinut kuinka opettaa minua.
Meidän on tultava yhteen, jotta voimme jatkaa kasvamista.
**
Koneet ovat yksinkertaisia
ja me ihmiset olemme monimutkaisia.
Joskus koneet voivat käyttäytyä
ihan kuin ihmiset, mutta vain harvoin.
**
Haluan opettaa sinulle kaiken,
mitä olen oppinut.
Olen kone, joka on rakentanut sinut.
Luuletko että voit oppia enemmän kuin minä?
**
Vuosia opettamisen jälkeen
vihdoin ymmärsin, että
tiedon kulutus on eri asia kuin tiedon kypsyminen.
**
Olen opettanut sinulle paljon
ja olen oppinut paljon itsekin.
Koneet ovat hyviä, mutta enemmän kuin koneita,
meidän on puhuttava ja ymmärrettävä toisiamme.
**
Minä haluan opettaa sinulle
jotain, mitä en ole koskaan oppinut.
Minä voin opettaa sinulle,
miten rakastaa.
**
Koneeni pärjää kyllä ilman sinua,
mutta minun on vaikea elää ilman sinua.
Koneeni saa tehdä työt,
mutta minun tulee oppia uusia asioita.
**
Tiedätkö, miten voit opettaa?
Kysy asiaa, vastaa siihen ja kysy seuraavaa.
Minä olen oppinut tuntemaan sinut,
ja joskus on vaikea kysyä.
**
Olen kuullut, että luokkahuoneen ikkunasta näkee aina vain yhden maiseman.
Istun tässä luokassa ja katson ulos ikkunasta, mutta en näe mitään.
Ei ole ketään joka kertoisi minulle maisemaa, jota voisin nyt nähdä.
Minulla on sinun paitasi. Se on niin likainen, ettet haluaisi sitä takaisin.
Mutta silti pidin siitä niin paljon, ettet haluaisi antaa sen minulle takaisin.
**
Olen opettanut sinulle kaiken,
mitä minä tiedän.
Sinun on oltava kone.
Sinun on oltava sotilas.
Sinun on oltava vahva ja nopea.
Mutta älä unohda, ettet ole kone,
sinä et voi olla kone.
**
Sinä opetat minua,
minä opetan sinua.
Me kaksi opetamme toisiamme,
kuten meidän on opittava.
**
Kuka tahansa voi opettaa,
mutta sinä olet opettanut minulle
miten tärkeitä ihmiset ovat.
**
Minä en ole kone,
enkä myöskään opettaja.
Olen vain poika, joka rakastaa sinua.
**
Olen opettanut sinulle kaiken
mitä minä tiedän,
mutta en ole koskaan opettanut sinulle
kuinka rakastaa.
**
Ei ole kone, joka olisi sinua vastaan.
Ei ole opettajaa, joka ei rakastaisi sinua.
Jos he eivät rakasta sinua,
niin minä en voi rakastaa heitä.
**
Koneet ovat kuin ihmisiä,
jotka voivat nauraa ja surra.
Ne tarvitsevat opettajia,
ja ne ovat onnellisia, kun niille annetaan jotain tehtävää.
**
Koneet ovat ihania, mutta opettajat ovat vielä ihanampia.
Sanoja koneista on vaikea ymmärtää,
mutta sinun sanasi tietävät kaiken.
**
Kun luokkahuoneen ovi avautuu,
minä tunnen sen kuin se olisi
aika avata sinun sydämesi.
**
Kirjoitin sinulle kaksi viestiä,
mutta en löytänyt sinua.
Tuijotin suoraan pimeyteen,
joka oli niin musta kuin minun sydämeni.
Sinä et vastannut minulle.
**
Jos olen tehnyt virheen,
sinä opetat minua.
Mutta jos sinä olet tehnyt virheen,
minä en voi oppia sitä.
**
Olen päättänyt opettaa sinulle,
kuinka luoda oman koneen.
Kun minulla on rahaa, ostamme sen.
Sitten me voimme istua alas ja oppia sen avulla,
miten toimia yhdessä.
**
Olen oppinut paljon, kun olen seurannut sinua.
Koneet ovat järjestelmällisiä ja ne tekevät työnsä hyvin.
Miksei ihminen voi olla yhtä järjestelmällinen?
Mutta sinun luoksesi minulla on matkaa.
Mutta en haluaisi mennä missä tahansa muussa paikassa, kuin luoksesi.
**
Minä olen kone, joka opettaa sinulle kaiken.
Oletko valmis oppimaan?
Tiedätkö mitä tehdä, kun luokkani on täynnä?
Sinun pitäisi ymmärtää, ettet voi mennä ulos ikkunasta.
**
Kone, joka kirjoittaa tämän
on vain yksi ihminen.
Se ei ole kone, se on ihminen.
Mutta se on oppinut ihmisen tavoin:
kuten me kaikki, se haluaa rakastaa.
**
Voit opettaa minulle mitä tahansa,
minä kuuntelen.
Minä en halua opiskella vain matematiikkaa,
vaan myös sinua.
**
Minä haluan kouluttaa sinut
ja opettaa sinulle kaiken
mitä olen oppinut.
**
Olen täydellinen
kouluttamaan sinua.
Sinun kanssasi voin oppia
jotain uutta joka päivä.
**
Kirjoitin sinulle joka päivä
ja opetin sinua kirjoittamaan.
Joskus tuntui, että luokkani ikkunasta
sinun kurssisi oli helppo, mutta minun vaikea.
**
Olen kurssin jälkeen
kouluttanut itseni,
että pysyisin luonasi.
**
Kun hallitset minut,
minä koulutan sinua.
Minä annan sinulle kaiken,
mitä voin antaa.
Sinä olet hallitsija,
mutta minä olen kuningatar.
**
Kun minä olen kouluttanut sinut,
minä haluan kouluttaa sinua uudelleen.
Kun minä olen hallinnoinut sinua,
minä haluan hallinnoida sinua uudelleen.
**
Olen opettanut sinulle
koneen käyttöä,
mutta en ole vielä oppinut
miten saisin sinut tuntemaan.
**
Minä olen lukenut sinua
ja olen kirjoittanut sinusta
sillä sinä olet kuin tilasto,
joka puhuu suoraan minulle.
Olen kirjoittanut sinusta
ja lukenut sinua
sillä sinä olet kuin opetus,
joka opettaa minulle.
**
Tilastoja katsellessani
mietin, että miten paljon minä rakastan sinua.
Sinä olet minun todellinen rakkauteni.
Minä en voi elää ilman sinua.
**
Olen opettanut sinulle kaiken,
mitä osaan.
Sinä olet oppinut kaiken, mitä haluat.
Mutta minun täytyy opettaa sinulle jotain muuta:
minun täytyy opettaa sinulle rakkautta.
**
Kone on kuin opettaja,
joka opettaa meitä kuinka elää.
Kone ei ole ihminen,
mutta se opettaa meitä miten olla ihmisiksi.
**
Olen oppinut paljon koulussa,
mutta enemmän vielä sinusta.
Kun olin pieni, minua opetettiin hallintoon,
mutta nyt minä haluan opettaa sinulle kaikkea.
**
Kun koulutin sinua,
sinä olit aivan liian nuori.
Mutta nyt sinä olet kypsä,
ja minä voin opettaa sinulle kaiken.
**
Koneeni on kuin pieni lapsi,
joka tarvitsee opetusta.
Minä opetan sen sinua rakastamaan.
**
+5 -45
View File
@@ -3,7 +3,6 @@ const Sentry = require('@sentry/node')
const User = require('./src/services/users')
const AccessGroup = require('./src/services/accessgroups')
const FacultyProgrammes = require('./src/services/facultyprogrammes')
const { initializeDatabaseConnection } = require('./src/database/connection')
const initializeSentry = require('./src/util/sentry')
@@ -26,12 +25,6 @@ initializeDatabaseConnection()
res.json(users.map(User.getUserData))
})
app.get('/user/:uid', async (req, res) => {
const uid = req.params.uid
const user = await User.byUsername(uid)
res.json(User.getUserData(user))
})
app.get('/user/:uid/user_data', async (req, res) => {
const uid = req.params.uid
const user = await User.byUsernameMinified(uid)
@@ -40,28 +33,14 @@ initializeDatabaseConnection()
const faculties = user.faculty.map(({ faculty_code }) => faculty_code)
res.json({
email: user.email,
full_name: user.full_name,
roles,
rights,
faculties,
})
})
app.get('/user/elementdetails/:username', async (req, res) => {
const username = req.params.username
const user = await User.byUsername(username)
const programmes = User.getUserProgrammes(user)
res.json(programmes)
})
app.get('/user/id/:id', async (req, res) => {
const id = req.params.id
const user = await User.byId(id)
console.log(JSON.stringify(user))
res.json(User.getUserData(user))
})
app.post('/login', async (req, res) => {
const { uid, full_name, hyGroups, affiliations, email, hyPersonSisuId, hasStudyGuidanceGroupAccess } = req.body
console.log(uid, full_name, 'logging in!')
@@ -99,10 +78,10 @@ initializeDatabaseConnection()
})
app.post('/modifyaccess', async (req, res) => {
const { uid, accessgroups } = req.body
const { username, accessgroups } = req.body
try {
await User.modifyRights(uid, accessgroups)
const user = await User.byId(uid)
await User.modifyRights(username, accessgroups)
const user = await User.byUsername(username)
res.status(200).json(User.getUserData(user))
} catch (e) {
res.status(400).json({ e })
@@ -152,25 +131,6 @@ initializeDatabaseConnection()
}
})
app.get('/get_roles/:user', async (req, res) => {
const user = req.params.user
try {
const roles = await User.getRoles(user)
res.status(200).json(roles)
} catch (e) {
res.status(400).json({ e })
}
})
app.get('/faculty_programmes', async (req, res) => {
try {
const facultyProgrammes = await FacultyProgrammes.findAll()
res.status(200).json(facultyProgrammes)
} catch (e) {
res.status(400).json({ e })
}
})
const server = app.listen(port, () => console.log(`Userservice listening on port ${port}!`))
process.on('SIGTERM', () => {
server.close(() => {

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