Compare commits

...
276 Commits
Author SHA1 Message Date
Roni Lindholm fda6455a85 Fix randomly missing current programme in course population
Add also help text for semesters.
2022-05-02 13:15:35 +03:00
Petri Palmu 488020df23 new test in course statistics 2022-04-29 15:00:38 +03:00
Petri Palmu 65b48361b3 user can see students detailed info if has rights for course provider 2022-04-29 15:00:38 +03:00
Leo Varis 0be8f6f192 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-04-27 12:44:11 +03:00
Leo Varis 9888ae4599 fix #3643 2022-04-27 12:44:03 +03:00
Roni Lindholm 8b4198d477 Add count of present semester enrollments to population students table 2022-04-27 11:04:19 +03:00
Roni Lindholm 9bc73d7aaf Add grades to course statistics students table
For each student the best grade is shown.
2022-04-27 09:29:33 +03:00
Leo Varis ea6e77cd6a Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-04-26 11:54:42 +03:00
Leo Varis ba1350114a fix #3637 2022-04-26 11:54:32 +03:00
Petri Palmu 4a2bfe8362 study programme courses rtk-api connected to backend 2022-04-26 09:53:24 +03:00
Petri Palmu b3cb3b15d5 fix to sentry issue yearly is null 2022-04-26 09:53:24 +03:00
Roni Lindholm bdc862a935 Fix filtering by "no programme" 2022-04-25 17:20:43 +03:00
Leo Varis f63a4fdf20 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-04-25 16:49:03 +03:00
Leo Varis 57c53c254a finally fix (#3623) 2022-04-25 16:48:40 +03:00
Petri Palmu 83a15d737c test fix 2022-04-25 16:16:17 +03:00
Petri Palmu 68ee9bd31e Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-04-25 15:28:41 +03:00
Petri Palmu 1c4b8b3f33 Merge branch 'studyprograms' 2022-04-25 15:22:30 +03:00
Petri Palmu e065a64258 study programme courses moved behind admin status 2022-04-25 15:21:58 +03:00
SNurmivaara af5cb0e160 Do not show a term registration too early fix #3520 2022-04-25 15:06:54 +03:00
Petri Palmu 074942d4a5 is_open takes into account if attainment has no studyright 2022-04-25 14:21:49 +03:00
Mitja Karhusaari 92e402b0e6 Update years in page titles in trends 2022-04-25 11:59:04 +03:00
Mitja Karhusaari 515456feaf Better handling of Total-rows in sortable table 2022-04-25 11:49:26 +03:00
Mitja Karhusaari 1f3dce0f60 Show Empty only for null and undefined values in column filters 2022-04-25 11:21:13 +03:00
Mitja Karhusaari 5b21c246f8 Lower figure z-index to below that of modal's 2022-04-25 11:20:39 +03:00
Mitja Karhusaari 2be6a004e3 Fix [Object object] headers in export 2022-04-25 11:19:46 +03:00
Leo Varis 27e285c182 fix credit calculation again 2022-04-21 20:38:14 +03:00
SNurmivaara 6aeb78c90d Remove unused logging extras 2022-04-21 13:38:29 +03:00
Petri Palmu f8bc7606e8 studyprogramme courses backend structure with promise all done 2022-04-21 12:57:17 +03:00
SNurmivaara 3968a6407c Add granularity to updater sentry fingerprints 2022-04-21 12:36:54 +03:00
SNurmivaara 6467bdf555 Remove unnecessary conditional from updater sentry 2022-04-21 10:48:24 +03:00
SNurmivaara 677922a2cf Varied error messages for updater 2022-04-21 08:50:12 +03:00
Leo Varis 8e2e760ffc fix bug (#3623) 2022-04-20 23:00:53 +03:00
Mitja Karhusaari c606dd9040 Reduce the precision of the credit date filter to a day
Attempts to address #3535 even though I could not find anything that
triggered the bug
2022-04-20 15:16:50 +03:00
Mitja Karhusaari ea62047af5 Automatically include data up to the latest complete academic year in trends 2022-04-20 14:59:20 +03:00
Mitja Karhusaari 2990c6e903 Group credit columns together 2022-04-20 13:53:00 +03:00
Mitja Karhusaari c273cba4be Halve the credit target limit for the semester-view in credit distribution development 2022-04-20 10:27:07 +03:00
Roni Lindholm bef81b60fd Fix minor bugs reported by Sentry
Add missing return to send email method, fix broken months parameter
in total row of studyprogrammes by staring year.
2022-04-20 08:39:08 +03:00
Petri Palmu e314dd0647 firts route to programme courses made 2022-04-20 08:14:54 +03:00
Roni Lindholm eb87cff81e Better performance for courses of population
By default fetch stats only for mandatory courses and fetch it only
once.
2022-04-18 08:18:05 +03:00
Mitja Karhusaari 473d81eac0 Credit distribution development betterments
- Reversible stack ordering
- Green-Yellow-Red colorscheme from Norppa
- Better tooltips with percentages
- Fixed credit bucket bounds
- Exclude graduated students from statistics for timeslots after graduation
2022-04-13 22:49:17 +03:00
Roni Lindholm bcd8febcc8 Fix selectors in course stats tests 2022-04-13 15:44:24 +03:00
Roni Lindholm 401114e5c6 Try to fix tests
New sort in course stats
2022-04-13 14:43:52 +03:00
Roni Lindholm 49d237ada6 Sort years or semesters by code instead of name in course stats 2022-04-13 14:10:23 +03:00
Saara Satokangas 992eb51393 Show "data is complete"-message only for non-new programmes 2022-04-13 12:49:31 +03:00
Saara Satokangas 3d46f0d753 Update doctoral programmes too every night
And don't use redis for programmes that do not get their data
regularly updated
2022-04-13 12:48:33 +03:00
Saara Satokangas 7b2b760997 Search for licenciate-thesis or doctoral thesis for spec. programmes 2022-04-13 12:48:33 +03:00
Saara Satokangas 8401ba0d55 Add nodata-message 2022-04-13 12:48:33 +03:00
Saara Satokangas 9e3a1adcc7 Separate doctoral programmes into their own table 2022-04-13 12:48:33 +03:00
Roni Lindholm 1dee45c937 Enable tests for updater 2022-04-13 12:27:48 +03:00
Roni Lindholm 4c3f583712 Enrollment should not be required when fetching courses of population 2022-04-13 12:00:30 +03:00
Roni Lindholm d32608a0f2 Add new enrollments tab to courses of population
Include simple total students and enrolled no grade columns
2022-04-13 09:41:30 +03:00
Saara Satokangas c920729d57 Minor improvements 2022-04-12 18:37:16 +03:00
Saara Satokangas dae5c5181f Define correct provider code for doctoral programmes 2022-04-12 18:37:16 +03:00
Saara Satokangas 60ab9a849d Add new categories for credit progress for doctoral programmes 2022-04-12 18:37:16 +03:00
Saara Satokangas 8d6c8d71ef Don't show programmes before or after if they don't exist 2022-04-12 18:37:16 +03:00
Saara Satokangas 478722b1ad Change dates of the start date to match doctoral and spec. programmes 2022-04-12 18:37:16 +03:00
Saara Satokangas 2c1341fac7 Change color to blue for other than MH or KH programmes 2022-04-12 18:37:07 +03:00
Saara Satokangas 76ffc929b0 Improve studyright status infotext 2022-04-12 18:37:07 +03:00
Saara Satokangas 99dfb4ed63 Remove unnecessary year-mapping 2022-04-12 18:37:07 +03:00
Saara Satokangas 1d8db829cb Remove "NEW"-label from places that are not so new anymore 2022-04-12 18:37:07 +03:00
Saara Satokangas 5cb67f7f24 Hide credits-table if no data is found 2022-04-12 18:37:07 +03:00
Leo Varis d6d0824b6c add lääkis to credit calculation 2022-04-12 14:31:01 +03:00
Leo Varis 59b257fb90 fix async bug 2022-04-12 14:23:44 +03:00
Leo Varis d5e175f362 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-04-12 14:11:18 +03:00
Leo Varis c2a2331013 fix total credit calculation 2022-04-12 14:11:04 +03:00
Petri Palmu fe99727a6f student statistics show open courses in name correctly 2022-04-12 13:53:51 +03:00
Leo Varis 12c076740a hops student page changes (#3534) 2022-04-12 13:37:57 +03:00
Leo Varis a8b4aa3f1f fix updater hops issue when student has graduated (#3586) 2022-04-12 13:37:34 +03:00
Roni Lindholm f0549eb4ac No tests for updater 2022-04-08 13:25:29 +03:00
Roni Lindholm eebbb2d1db Try to fix updater tests 2022-04-08 12:27:03 +03:00
Petri Palmu 389a0b8512 changed showByYear as default in trends 2022-04-06 21:16:25 +03:00
Petri Palmu 7775ef6c54 fix to population when selevting many years 2022-04-06 21:16:25 +03:00
Saara Satokangas 2e984bdb89 Filter out students without studentnumber 2022-04-06 10:57:23 +03:00
Roni Lindholm ab28becd97 Fix on first attempt and eventually column values in course stats 2022-04-06 09:33:41 +03:00
Saara Satokangas 5a5039318c Betterments to the new studyright-status-filter 2022-04-06 08:17:29 +03:00
Saara Satokangas 433dd15020 Remove last mentions of cancelled 2022-04-05 18:22:41 +03:00
Saara Satokangas a971433273 Add studyright status -filter 2022-04-05 18:16:26 +03:00
Saara Satokangas 314642c1b5 don't skip students with no studentnumber here 2022-04-05 17:16:38 +03:00
SNurmivaara 44b4a43f16 Remove unused sentry init 2022-04-05 16:10:55 +03:00
SNurmivaara f483a564e8 Refactor and consolidate updater logging 2022-04-05 15:57:50 +03:00
SNurmivaara c112c27d59 Add sentry to updater worker 2022-04-05 13:42:56 +03:00
Saara Satokangas fec8346766 Remove canceldate from studyrights in updater 2022-04-05 10:56:03 +03:00
Saara Satokangas 9d152cac10 Remove canceldate from backend studyright-model 2022-04-05 10:31:15 +03:00
Saara Satokangas 63fea99dfd Adjust filters-tests to match the removal of canceldate 2022-04-05 10:30:21 +03:00
Saara Satokangas 46bbd79a88 Include also the cancelled studyrights in the populations tests
The canceldate was used in tests which made the total amount of
CS-students to be 149. Now that populations no longer uses canceldate
and no student gets filtered out based on it, the correct number is 170.
2022-04-05 09:54:34 +03:00
Saara Satokangas 1e65c70210 Remove mentions of canceldate 2022-04-05 09:54:28 +03:00
Saara Satokangas 031d33561c Stop updating canceldate for studyrights 2022-04-05 09:19:07 +03:00
Saara Satokangas 11eab49a0f Skip students with no studentnumber 2022-04-05 08:59:17 +03:00
SNurmivaara 9b2b4bc6d8 Fix updater version number for sentry releases 2022-04-04 17:52:38 +03:00
SNurmivaara 29f94bb94e Add sentry steps to updater workflow 2022-04-04 17:32:44 +03:00
SNurmivaara 0310292531 Add sentry to updater scheduler 2022-04-04 17:32:44 +03:00
Mitja Karhusaari 6c9ac8cd2d Fix ResizeObserver issue in older Firefox versions 2022-04-04 17:28:27 +03:00
Mitja Karhusaari 5afe92d85e Add Distribution Development tab to population credit statistics 2022-04-04 17:08:08 +03:00
Leo Varis e7e19b3f1b make all credits column show acually all credits 2022-03-31 11:30:20 +03:00
Petri Palmu 6df4f8f8da fix ti TKT1 population if many years is selected 2022-03-31 08:34:56 +03:00
Leo Varis b1d6648f7f enable hops stuff again 2022-03-30 15:01:51 +03:00
Leo Varis 80e0668a3c bypass tests 2022-03-30 14:21:35 +03:00
Saara Satokangas 9b9a585057 Include different combinations to studytrack redis-updates 2022-03-30 08:27:22 +03:00
Leo Varis c396f5437a temp fix 2022-03-29 21:35:05 +03:00
Saara Satokangas 5149bb3a62 Change "peruutettu" to "passiivinen" like in Sisu 2022-03-29 15:31:05 +03:00
Saara Satokangas acd4a34048 Fix trends not showing inactive students 2022-03-29 15:31:05 +03:00
Mitja Karhusaari fba8d92c5a Some fixes to course statistics table settings 2022-03-29 15:18:13 +03:00
Mitja Karhusaari a888d171a2 Better handle invalid parameters to RangeSelector 2022-03-29 15:13:07 +03:00
Mitja Karhusaari e4c56e11b7 Fix filter combination tests 2022-03-29 15:03:47 +03:00
Mitja Karhusaari c938d2440d Use RangeSelector for the Age and Credit filters 2022-03-29 13:55:12 +03:00
Mitja Karhusaari a4b32129f1 Separate range selector widget into its own component 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 7cb8d710d7 Better handle non-number input in range widget 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 038ffefa84 Add option for reseting single columns filters 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 2a8176ebe2 Use popper-based Popup for dropdowns so that they can flow outside of the overflow-hidden container 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 2be93ef839 Avoid some renders using use-context-selector 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 319a001c0e Remove dirty workaround 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 831b00ae75 Semester-related RTK Query fixes 2022-03-29 13:55:12 +03:00
Mitja Karhusaari be923fa80d Avoid recreation of contents when fullscreening a figure 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 596ebb66cb Allow mass-cycling filter values by pressing enter on the search field 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 118315385e Use better vertical titles in PopulationStudents courses table and other small improvements 2022-03-29 13:55:12 +03:00
Mitja Karhusaari 192b1eb54b Add curried getTextIn to useLanguage 2022-03-29 13:55:11 +03:00
Mitja Karhusaari ea9a8da78d Use cellProps instead of removed getCellProps 2022-03-29 13:55:11 +03:00
Mitja Karhusaari bcd44aa644 Use SortableTable help text functionalty for the CourseStatistics attempts table 2022-03-29 13:55:11 +03:00
Mitja Karhusaari 6381363722 Fix positioning of column help text and force floating column tool mode for columns having help text 2022-03-29 13:55:11 +03:00
Saara Satokangas b8d075be42 Fix tests 2022-03-29 11:21:16 +03:00
Saara Satokangas b109accac8 Adjust infobox-texts 2022-03-29 11:07:09 +03:00
Saara Satokangas 0a98526f18 Revert changes in populations.js for now
For some reason tests fail after removal of the "CANCELLED"-field,
even though the real data looks the same in local and production
environments.
2022-03-29 11:07:09 +03:00
Saara Satokangas 0f6cf38e73 Remove cancelled-field from overview tests and replace it with inactive 2022-03-29 11:07:09 +03:00
Saara Satokangas 2df92ec068 Remove inactive-field from studyprogramme basics 2022-03-29 11:07:09 +03:00
Saara Satokangas dae7bbb058 Include also expired studyrights to inactive ones 2022-03-29 11:07:09 +03:00
Saara Satokangas ab121b7ec6 Remove inactive-selection from populations 2022-03-29 11:07:09 +03:00
Saara Satokangas a2df1944c6 Remove inactive-selection from advanced filters 2022-03-29 11:07:09 +03:00
Saara Satokangas 62821e2f3b Switch places of median and mean 2022-03-29 11:06:04 +03:00
Saara Satokangas 14bd681260 Unify the way inactives are defined 2022-03-29 11:06:04 +03:00
Saara Satokangas fc123276ff Start using the new inactive-field for populations 2022-03-29 11:06:04 +03:00
Saara Satokangas ed96ae4f95 Start using the new inactive-field in studyprogramme-overview 2022-03-29 11:06:04 +03:00
Leo Varis c0f01fb224 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-03-28 16:08:26 +03:00
Leo Varis e50f631495 fix tests 2022-03-28 16:08:15 +03:00
Leo VarisandGitHub 2838099f0a Merge pull request #3549 from UniversityOfHelsinkiCS/studyplans
maybe fix tests
2022-03-28 15:12:43 +03:00
Leo Varis 9452ce08bd maybe fix tests 2022-03-28 15:05:47 +03:00
Leo VarisandGitHub 075385c962 Merge pull request #3547 from UniversityOfHelsinkiCS/studyplans
Studyplans
2022-03-28 14:03:07 +03:00
Saara Satokangas 87e6199093 Flatmap termregistrations 2022-03-28 13:38:53 +03:00
Leo Varis d47d0c22dd change title 2022-03-28 10:24:55 +03:00
Leo Varis 240edec696 revert dev value 2022-03-28 09:59:20 +03:00
Leo Varis 6dcdaae83e fix 2022-03-28 09:57:52 +03:00
Leo Varis 608670d65d add hops to study programme view (#3533) 2022-03-28 09:57:00 +03:00
Leo Varis 50ed56471c mangle hops to sis-db 2022-03-28 09:55:49 +03:00
Leo Varis 1d54d41861 add hops to study programme view (#3533) 2022-03-28 09:54:25 +03:00
Petri Palmu 0904703a57 changed laoding to loading and hope tests works 2022-03-25 18:28:26 +02:00
Roni Lindholm 8a4063486b Fix parsing of failed grades in totals 2022-03-25 12:59:32 +02:00
Matti Luukkainen 86cebeb628 presnet -> present 2022-03-25 12:04:19 +02:00
Matti Luukkainen 6770850822 rapodiff uncomment 2022-03-24 18:51:38 +02:00
Matti Luukkainen 133edbad89 eslint igonre 2022-03-24 17:12:45 +02:00
Petri Palmu e8556d0741 fix to the maxYearsToCreatePopulationFrom 2022-03-24 16:41:22 +02:00
Saara Satokangas b654a247ae Replace "ABSENT" with "NONATTENDING" 2022-03-24 14:15:20 +02:00
Roni Lindholm bba5c7e946 Fix grades on course stats attempts tab
Display all given grades for course.
2022-03-24 11:59:46 +02:00
Mitja Karhusaari 0cbd1f2e16 Work on making the tests happy 2022-03-24 01:41:02 +02:00
Mitja Karhusaari 7f79a95385 Use better slider library 2022-03-23 23:47:23 +02:00
Mitja Karhusaari d6901a40b3 Add hover effect to DateTimeSelector semester buttons 2022-03-23 22:27:32 +02:00
Mitja Karhusaari 46d786f9e5 Use RTK-Query for fetching semesters 2022-03-23 22:18:38 +02:00
Mitja Karhusaari e059ee3f93 Move column header info tooltip functionality to SortableTable so that it does not get overlaid by the column header tool icons 2022-03-23 20:50:44 +02:00
Mitja Karhusaari 4b164b1f2a Fix z-index on fullscreen figures 2022-03-23 19:34:28 +02:00
Mitja Karhusaari c44de1e2b5 Go through tables and tweak column settings 2022-03-23 19:34:23 +02:00
Mitja Karhusaari a3d65ab87b Fix Orientable not responding to prop change 2022-03-23 19:23:53 +02:00
Mitja Karhusaari 79eec49a51 Fix handling of non-number values in range filter 2022-03-23 19:23:52 +02:00
Mitja Karhusaari b15a7a2102 Fix dropdown disappearing on blur due to css cascade 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 99abeae5ad Prevent oscillation between the column header tool states fixed and floating 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 3b65e39e35 Add debouncing to the range slider 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 1af0bd77e9 Fix rendering of the Slider component in Dropdowns 2022-03-23 19:23:52 +02:00
Mitja Karhusaari f51bdfe23c Fix SortableTable value extraction 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 2d50786093 Fix column header tooling alignment 2022-03-23 19:23:52 +02:00
Mitja Karhusaari e8f899a96e Add debouncing to the range filter component 2022-03-23 19:23:52 +02:00
Mitja Karhusaari dd0f8f966d Implement vertical titles & smart positioning of filter and sort icons in SortableTable 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 2d684d1f70 Implement range sider filter for SortableTable 2022-03-23 19:23:52 +02:00
Mitja Karhusaari ec2bbfb497 Use date-filter type for appropriate columns 2022-03-23 19:23:52 +02:00
Mitja Karhusaari a07365c1a6 Split visitors into separate files and implement date-filter type for SortableTable columns 2022-03-23 19:23:52 +02:00
Mitja Karhusaari 5e3605930d Add quick select for semester start and end dates to the datetime picker 2022-03-23 19:23:52 +02:00
Petri Palmu 5ce24443f2 one fix test 2022-03-23 11:30:10 +02:00
Petri Palmu 9e825f9915 more fix to the tests 2022-03-23 10:34:14 +02:00
Petri Palmu 69c7f02dca little correction to the tests 2022-03-23 09:56:38 +02:00
Petri Palmu bf2c3638d3 selection between open and uni courses works if you drill into spesific year 2022-03-23 09:15:05 +02:00
Saara Satokangas d4ccc7a4dc Fix tests
By skipping some acually broken for now
2022-03-22 20:21:31 +02:00
Saara Satokangas 2bcafbabfd Try fixing filtering of students 2022-03-22 20:10:18 +02:00
Saara Satokangas bc5d44f18f Fix studytrack-filter showing relevant studytracks 2022-03-22 18:50:43 +02:00
Saara Satokangas 51b9e23693 Get correct populationlink also for unfolded studytrack 2022-03-22 18:49:39 +02:00
Saara Satokangas 75f0dca9f1 Add notification about all credits, not just studyprogramme credits 2022-03-22 18:49:39 +02:00
Saara Satokangas b802f92420 Change title of a test set 2022-03-22 18:49:39 +02:00
Matti Luukkainen bf54330c12 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-03-22 17:40:41 +02:00
Matti Luukkainen aba30641b2 rapodiff for bsc 2022-03-22 17:36:07 +02:00
Saara Satokangas 5dc06a8f95 Fix tests
Include also the total-line
2022-03-22 14:10:03 +02:00
Saara Satokangas 8dad3ef620 Remove unnecessary useState 2022-03-22 13:55:51 +02:00
Saara Satokangas e23774023d Fix total-row population-link
Closes #https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3517
2022-03-22 13:45:54 +02:00
Saara Satokangas abd31bc807 Use the filtered studytracklist also in case where no result
If the population is searched for a studytrack, and there are no
students who has that studytrack as their latest, still use that empty
list. Instead of showing the unfiltered list like before.
2022-03-22 13:45:54 +02:00
Saara Satokangas 120a6fbc5b Take the latest studytrack based on the startdate in the track
Not the enddate of the studyright
2022-03-22 13:45:54 +02:00
Saara Satokangas a6bc58b0fc Include total-row to the current tables and graphs, remove toggle 2022-03-22 13:45:54 +02:00
Roni Lindholm 4baf6d8509 Display enrollments in course stats based on yearcode instead of hack 2022-03-21 15:40:54 +02:00
Roni Lindholm f263913c03 Refine enrollments in course stats
Calculate  stats for normal view and enrollments view.
2022-03-21 15:17:24 +02:00
Saara Satokangas e9f6eb744f Adjust tests to match the new credit progress categories 2022-03-21 09:39:30 +02:00
Saara Satokangas cd6ef17a10 Add categories >= 300 and >= 180 to credit progress charts 2022-03-21 09:18:50 +02:00
Saara Satokangas f6272a0df0 Add active-field to backend studyright-model 2022-03-21 09:01:46 +02:00
Leo Varis 9b385afb36 mangle hops to sis-db 2022-03-17 17:02:12 +02:00
Matti Luukkainen b7a2d67bcb rapo diff script betterments 2022-03-15 17:41:45 +02:00
Matti Luukkainen 2f20cd7914 disable population fix due to issues in tests 2022-03-14 19:35:58 +02:00
Matti Luukkainen 55565e1721 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-03-14 19:08:14 +02:00
Matti Luukkainen e26c35ccd6 rapodiff tweak, #3519 and temporary fix to #3398 2022-03-14 19:07:48 +02:00
Roni Lindholm 034410e534 Include only relevant enrollments to stats
Remove also unused programme and faculty enrollment stats.
2022-03-11 12:15:50 +02:00
Roni Lindholm c0ee2477be Fix course stats crash when no enrollments 2022-03-10 10:08:08 +02:00
Roni Lindholm e7b0beb2cf Try to fix course stats tests
Remove duplicate click on attemps view mode
2022-03-10 09:39:14 +02:00
Roni Lindholm 4f8dcc03c2 Add enrollments to course stats 2022-03-10 08:44:50 +02:00
Saara Satokangas ff7bedccc1 Remove the graduation-condition from activityParser
If the studyright has ended due to graduation or any other reason,
it should not be confused with inactive studyrights (thus the ones
where the student has not registered for the term or has cancelled
their studyright)
2022-03-09 15:41:24 +02:00
SNurmivaara 3b153df6ef Fix package.lock for updater 2022-03-09 15:32:22 +02:00
SNurmivaara 93b0e283fa Lock updater knex version to a known working version. 0.20.11 onwards requires changes to codebase, created ticket 2022-03-09 15:26:19 +02:00
SNurmivaara f52fddf85c Semver safe non-breaking updates for dependencies of services 2022-03-09 14:49:44 +02:00
Saara Satokangas f0fca7f7ab Add active-status to studyrigths in updater 2022-03-09 14:07:34 +02:00
Saara Satokangas 44058ce537 Remove some unused parameters 2022-03-09 14:07:23 +02:00
SNurmivaara 48356cbb8a Fix dependabot to acually do things and label PRs with correct service 2022-03-09 13:50:40 +02:00
SNurmivaara 50b579ede2 Removed outdated target branch 'trunk' from dependabot config 2022-03-09 07:52:00 +02:00
Petri Palmu 16df458433 added attainment date population of course 2022-03-08 13:51:38 +02:00
Petri Palmu 2c84a0f73d test for unify toggle 2022-03-08 10:35:51 +02:00
Petri Palmu 0152d0c549 unify open courses radio buttons disabled if no data 2022-03-08 10:35:51 +02:00
Petri Palmu fe03fa89c6 make course stats comparizon view horizontal again 2022-03-08 10:35:51 +02:00
Mitja Karhusaari 891cabfbd7 Move name columns to the left-most edge of the student search results table 2022-03-08 09:45:43 +02:00
Mitja Karhusaari b7d2b7f804 Display pointer cursor when hovering sortable column header 2022-03-08 09:45:42 +02:00
Mitja Karhusaari b7740c3b9b Unoverkillify the student search results table
Resolves #3461
2022-03-08 09:45:42 +02:00
Mitja Karhusaari 620c6859ae Limit width of the course name column in Courses of Population tables
Resolves #3459
2022-03-08 09:45:42 +02:00
Saara SatokangasandGitHub 5e30c279ed Merge pull request #3389 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/nanoid-3.2.0
Bump nanoid from 3.1.23 to 3.2.0 in /services/frontend
2022-03-07 16:39:26 +02:00
Saara Satokangas 637f23a96f Remove ThesisCourses-table
closes #3334
2022-03-07 15:57:01 +02:00
Saara Satokangas 9b8db2096d Remove thesisCourse-reducer and backend-logic
These are redundant after the new studyprogramme overview
2022-03-07 15:43:45 +02:00
Saara Satokangas 6d174d0275 Adjust the updates to match the new options 2022-03-07 12:51:01 +02:00
Saara Satokangas 2e882ff8a0 Clean up the options for studyprogramme overviews 2022-03-07 11:03:19 +02:00
Saara Satokangas f8a6f89199 Clean up the way years are defined for studyprogramme stats 2022-03-07 10:36:33 +02:00
Saara Satokangas b272f9e94a Clean up the toggle container 2022-03-07 09:21:05 +02:00
Saara Satokangas 4cc73d5fd8 Clean up settings for studytrack-overview 2022-03-07 09:12:31 +02:00
Saara Satokangas c19810fca8 Add "years combined"-selection to front and redis 2022-03-07 08:35:22 +02:00
Saara Satokangas 298c9e9b4b Add possibility to choose all students in the studytrack-view 2022-03-07 08:11:43 +02:00
Mitja Karhusaari 58ed0ac417 Fix course filter toggle button in Courses of Population
Resolves #3458
2022-03-06 19:12:31 +02:00
Mitja Karhusaari cb40fd970c Fix reseting table filters 2022-03-04 10:55:00 +02:00
Mitja Karhusaari cdc7331667 Fix rest of the tests 2022-03-04 10:17:45 +02:00
Mitja Karhusaari 2ffc222bcf Fix at at least some of the tests 2022-03-04 09:46:50 +02:00
Mitja Karhusaari 2fcb5fd91d Remove dead files 2022-03-03 08:43:19 +02:00
Mitja Karhusaari f4be37a3b8 Use "fully qualified" column name in exports to avoid name conflicts 2022-03-03 08:41:43 +02:00
Mitja Karhusaari 67d7b05cc6 Remove use of collapsing-property in SortableTable cells 2022-03-03 08:36:06 +02:00
Mitja Karhusaari 88ad8db7bb UI fix 2022-03-03 08:32:50 +02:00
Mitja Karhusaari dda612f284 Handle property not being present 2022-03-03 08:30:49 +02:00
Mitja Karhusaari 6a9cf26f4b Use the new SortableTable in the flat versions of PopulationCourseStats tables 2022-03-03 08:29:26 +02:00
Mitja Karhusaari 2d7b679b04 Add options to fold/unfold all groups 2022-03-03 08:28:18 +02:00
Mitja Karhusaari 5aa4c5d146 Misc. UI/UX improvements to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari ff9aaa1119 Add course filter toggles and links to all Population Courses tables 2022-03-03 07:09:18 +02:00
Mitja Karhusaari f443f3b7db Add exclude/include all buttons to export modal 2022-03-03 07:09:18 +02:00
Mitja Karhusaari a6a47e5ec8 Add Excel export to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari b414fd3501 Go through and tweak components using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 88ce7bd2a5 Implement Courses of Population tables using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari eb35ea503d Polish the row grouping functionality 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 20de79a26f Implement row grouping in sortable table 2022-03-03 07:09:18 +02:00
Mitja Karhusaari dab21ab390 Revamp sortable table 2022-03-03 07:09:18 +02:00
Leo Varis 6069dbc328 fix broken grade distribution (#3456) 2022-03-02 13:40:02 +02:00
Saara SatokangasandGitHub 7cb8041ca0 Update Pate-url 2022-03-02 09:13:14 +02:00
Roni Lindholm 729192cddc Fix enrollment updater when missing education 2022-03-01 15:14:27 +02:00
SNurmivaara 4b3b10d033 Teacher leaderboard tests go to the shadow realm 2022-03-01 14:48:50 +02:00
SNurmivaara 9c6812d7cf Return the magic wait and reload to teacher leaderboard test 2022-03-01 13:56:08 +02:00
SNurmivaara 58ce626d97 Acually fix teacher leadearboard tests 2022-03-01 13:44:58 +02:00
SNurmivaara 0834b33778 Fix teacher leaderboard tests 2022-03-01 12:54:13 +02:00
SNurmivaara cbce1d7dbd Remove statistics update button from teacher leaderboards
The button is obsolete, the update is run daily as a cronjob
2022-03-01 12:34:27 +02:00
SNurmivaara b8e214f8cd Filter faculties out of top teachers leaderboard 2022-03-01 12:00:37 +02:00
SNurmivaara 69d920015c Added required props to teacherStatisticsTable in teacher leaderboards 2022-03-01 12:00:37 +02:00
Roni Lindholm cf64249d4f Add enrollments to updater 2022-02-28 17:13:33 +02:00
Leo VarisandGitHub f372e4f362 Change required version 2022-02-28 13:57:37 +02:00
Saara SatokangasandGitHub dd619d375d Merge pull request #3421 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/follow-redirects-1.14.8
Bump follow-redirects from 1.14.3 to 1.14.8 in /updater/sis-updater-scheduler
2022-02-28 12:31:15 +02:00
Saara SatokangasandGitHub 0888eaf547 Merge pull request #3445 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/url-parse-1.5.10
Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
2022-02-28 12:15:19 +02:00
Saara Satokangas 14275a3e6f Fix faculty statistics 2022-02-28 11:54:13 +02:00
Saara Satokangas 956b9302d2 Rename newStudyprogramme.js to studyprogramme.js 2022-02-28 11:12:00 +02:00
dependabot[bot]andGitHub 51f462ce7b Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-27 20:54:50 +00:00
Saara Satokangas a38bd1a3b0 Enable custom populations for studyguidance-group users too 2022-02-24 16:01:50 +02:00
Saara Satokangas 316f3511c2 Fix to toolTip-texts 2022-02-24 15:00:03 +02:00
Saara Satokangas e36c7aa6ba Fix semesters showing invalid dates 2022-02-24 14:35:14 +02:00
Saara Satokangas ffda9af909 Use common component also for percentage-toggle 2022-02-24 14:33:49 +02:00
Saara Satokangas 7b56978e7c Improve the appearance of toggles and move them into one file 2022-02-24 14:29:37 +02:00
Saara Satokangas b95d439836 Fix coursepopulation bugging with academic terms 2022-02-24 13:15:58 +02:00
SNurmivaara 1f7573a0ba Added oodikone architecture picture to README. Closes issue #3110 2022-02-24 08:55:12 +02:00
dependabot[bot]andGitHub be0f14326a Bump follow-redirects in /updater/sis-updater-scheduler
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.3 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.3...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-16 07:49:06 +00:00
dependabot[bot]andGitHub fbb17894cb Bump nanoid from 3.1.23 to 3.2.0 in /services/frontend
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-22 08:37:54 +00:00
198 changed files with 11873 additions and 17674 deletions
+45 -13
View File
@@ -1,15 +1,47 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
time: "03:00"
open-pull-requests-limit: 10
target-branch: "trunk"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: weekly
time: "03:00"
target-branch: "trunk"
- package-ecosystem: npm
directory: '/'
schedule:
interval: weekly
time: '03:00'
labels:
- 'dependencies'
- 'infra'
- package-ecosystem: npm
directory: '/updater/sis-updater-scheduler'
schedule:
interval: weekly
time: '03:00'
labels:
- 'dependencies'
- 'updater'
- package-ecosystem: npm
directory: '/updater/sis-updater-worker'
schedule:
interval: weekly
time: '03:00'
labels:
- 'dependencies'
- 'updater'
- package-ecosystem: npm
directory: '/services/backend'
schedule:
interval: weekly
time: '03:00'
labels:
- 'dependencies'
- 'back'
- package-ecosystem: npm
directory: '/services/frontend'
schedule:
interval: weekly
time: '03:00'
labels:
- 'dependencies'
- 'front'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: weekly
time: '03:00'
@@ -19,6 +19,9 @@ jobs:
- name: 'Build the image'
uses: docker/build-push-action@v2
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
context: updater/sis-updater-worker/
target: production
push: false
@@ -40,6 +43,9 @@ jobs:
- name: 'Build the image'
uses: docker/build-push-action@v2
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
context: updater/sis-updater-scheduler/
target: production
push: false
@@ -128,6 +134,19 @@ jobs:
push: true
tags: toska/sis-updater-worker:latest
target: production
- name: 'Create Sentry release'
uses: getsentry/action-release@v1
env:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: toska
SENTRY_PROJECT: updater
with:
environment: production
set_commits: 'skip'
sourcemaps: './build/static/js'
url_prefix: '~/static/js'
version: ${{ steps.extract_version.outputs.VERSION }}
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+5 -1
View File
@@ -8,7 +8,7 @@ To run Oodikone locally, you will need the following:
1. Applications:
- [Docker](https://docs.docker.com/install/) (version 20.10+),
- [Docker Compose](https://docs.docker.com/compose/install/)(version 0.29+) and
- [Docker Compose](https://docs.docker.com/compose/install/)(version 1.29+) and
- [npm](https://docs.npmjs.com/cli/v7) (version 7+)
2. Access to Toska Docker Hub,
3. For real data setup, access to Toskas production servers.
@@ -48,6 +48,10 @@ What different CLI options do:
## ⌨️ Development
### Architecture
![Oodikone architecture](Oodikone.png)
### Basics
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for oodikone's two main components: **oodikone** and **updater**.
+44 -23
View File
@@ -155,8 +155,8 @@ describe('Course Statistics tests', () => {
cy.contains('Käyttöjärjestelmät')
cy.contains('TKT20003')
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Unified with open uni: Käyttöjärjestelmät 2019-2020')
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020 (open and normal')
cy.contains('TKT20003')
cy.contains('Students (127)').click()
@@ -169,8 +169,8 @@ describe('Course Statistics tests', () => {
cy.contains(/^TKT20001, AYTKT20001$/).click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('TKT20001, 58131, AYTKT20001 Tietorakenteet ja algoritmit')
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.contains('Population of course Unified with open uni: Tietorakenteet ja algoritmit 2018-2019')
cy.get('tbody >:nth-child(3) > :nth-child(2) .level').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2018-2019 (open and normal)')
cy.contains('Students (204)').click()
cy.contains('td', '010262566').siblings().contains('4')
cy.contains('td', '010674989').siblings().contains('1')
@@ -182,7 +182,7 @@ describe('Course Statistics tests', () => {
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
cy.get('[data-cy=unify_radio_reqular]').click()
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
cy.contains('Language distribution').click()
cy.contains('td', 'finnish').siblings().contains('122')
@@ -204,28 +204,28 @@ describe('Course Statistics tests', () => {
const yearRange = { from: '2000-2001', to: '2020-2021' }
const attemptsTableContents = [
// [time, passed, failed, passrate]
['Total', 511, 486, 25],
['Total', null, 511, 486, 25],
]
const attemptsTableContentsOpen = [
// [time, passed, failed, passrate]
['Total', 175, 175, 0],
['Total', null, 175, 175, 0],
]
const attemptsTableContentsReqular = [
// [time, passed, failed, passrate]
['Total', 312, 298, 14],
['Total', null, 312, 298, 14],
]
const gradesTableContents = [
// [time, attempts, 0, 1, 2, 3, 4, 5, other passed]
['Total', 511, 25, 11, 17, 21, 59, 365, 13],
['2020-2021', 1, 0, 0, 0, 0, 0, 1, 0],
['2019-2020', 54, 4, 3, 1, 3, 4, 39, 0],
['2018-2019', 154, 7, 2, 6, 5, 15, 119, 0],
['2017-2018', 186, 9, 3, 5, 6, 16, 139, 8],
['2016-2017', 71, 2, 1, 2, 5, 13, 48, 0],
['2015-2016', 21, 1, 1, 1, 0, 7, 8, 3],
['Total', null, 511, 25, 11, 17, 21, 59, 365, 13],
['2020-2021', null, 1, 0, 0, 0, 0, 0, 1, 0],
['2019-2020', null, 54, 4, 3, 1, 3, 4, 39, 0],
['2018-2019', null, 154, 7, 2, 6, 5, 15, 119, 0],
['2017-2018', null, 186, 9, 3, 5, 6, 16, 139, 8],
['2016-2017', null, 71, 2, 1, 2, 5, 13, 48, 0],
['2015-2016', null, 21, 1, 1, 1, 0, 7, 8, 3],
]
it('shows stats', () => {
@@ -252,6 +252,7 @@ describe('Course Statistics tests', () => {
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
@@ -265,6 +266,7 @@ describe('Course Statistics tests', () => {
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
@@ -289,6 +291,7 @@ describe('Course Statistics tests', () => {
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
@@ -301,6 +304,7 @@ describe('Course Statistics tests', () => {
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
@@ -309,7 +313,7 @@ describe('Course Statistics tests', () => {
cy.get('[data-cy=unify_radio_unify]').click()
})
it('after changing time range shows same stats', () => {
it('After changing time range shows same stats', () => {
const newYearRange = { from: '2016-2017', to: '2019-2020' }
cy.get("div[name='fromYear']")
.click()
@@ -338,6 +342,22 @@ describe('Course Statistics tests', () => {
cy.contains('Show population').should('be.enabled')
})
})
it('If no data available, unify toggle is disabled', () => {
cy.get("input[placeholder='Search by a course code']").type('AYCSM14111')
cy.contains('AYCSM14111').click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('AYCSM14111 Avoin yo: Full Stack -websovelluskehitys: React Native')
cy.get('[data-cy=unify_radio_reqular]').find('input').should('be.disabled')
cy.get('[data-cy=unify_radio_unify]').find('input').should('not.be.disabled')
})
it('Has right to see all the sudents, because course provider is TKT', () => {
cy.visit('coursestatistics?courseCodes=%5B%22TKT10004%22%5D&cs_tab=0&separate=false')
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
cy.contains('Students (154)').click()
cy.contains('015224224')
})
})
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
@@ -354,13 +374,13 @@ describe('Course Statistics tests', () => {
const attemptsTableContents = [
// [time, passed, failed, passrate]
['Total', 288, 213, 75, '73.96 %'],
// ['Total', 295, 216, 79, '73.22 %'],
['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
// ['2020-2021', 5, 2, 3, '40.00 %'],
['2019-2020', 164, 121, 43, '73.78 %'],
['2018-2019', 85, 60, 25, '70.59 %'],
['2017-2018', 39, 32, 7, '82.05 %'],
// ['Total', 288, 213, 75, '73.96 %'],
// ['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
['Total', null, 295, 216, 79, '73.22 %'],
['2020-2021', null, 5, 2, 3, '40.00 %'],
['2019-2020', null, 164, 121, 43, '73.78 %'],
['2018-2019', null, 85, 60, 25, '70.59 %'],
['2017-2018', null, 39, 32, 7, '82.05 %'],
]
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
@@ -370,6 +390,7 @@ describe('Course Statistics tests', () => {
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
+42 -38
View File
@@ -17,6 +17,28 @@ const clearSingleDropdownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
}
const testRangeFilter = (parentEl, min, max, expected) => {
cy.cs(parentEl)
.cs('range-selector-min')
.find('input')
.invoke('val')
.then(initialMin => {
cy.cs(parentEl)
.cs('range-selector-max')
.find('input')
.invoke('val')
.then(initialMax => {
cy.cs(parentEl).cs('range-selector-min').find('input').type(`{selectall}{backspace}${min}`).blur()
cy.cs(parentEl).cs('range-selector-max').find('input').type(`{selectall}{backspace}${max}`).blur()
checkFilteringResult(expected)
cy.cs(parentEl).cs('range-selector-min').find('input').type(`{selectall}{backspace}${initialMin}`).blur()
cy.cs(parentEl).cs('range-selector-max').find('input').type(`{selectall}{backspace}${initialMax}`).blur()
})
})
}
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
@@ -47,7 +69,7 @@ const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
describe('Population Statistics', () => {
const pathToCSBach2018 =
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2018'
const defaultAmountOfStudents = 148
const defaultAmountOfStudents = 159
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
before(() => {
@@ -78,7 +100,10 @@ describe('Population Statistics', () => {
})
})
it('Study track filter works', () => {
// The test does not work since it tries to filter by "Ravitsemustiede"-studytrack when checking
// CS Bachelor students. Now that the irrelevant studytracks have been removed, the test fails.
// So fix this
it.skip('Study track filter works', () => {
runTestStepWithPreAndPostParts('StudyTrack', () => {
const card = cy.cs('StudyTrack-filter-card')
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
@@ -115,37 +140,29 @@ describe('Population Statistics', () => {
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(0)
checkFilteringResult(137)
checkFilteringResult(148)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
})
it('Credit filter works', () => {
runTestStepWithPreAndPostParts('CreditsEarned', () => {
cy.cs('credit-filter-min').type('50')
cy.cs('credit-filter-max').type('150')
checkFilteringResult(116)
cy.cs('credit-filter-min').clear()
cy.cs('credit-filter-max').clear()
testRangeFilter('CreditsEarned-filter-card', 50, 150, 118)
})
})
it('Age filter works', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(40)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
testRangeFilter('Age-filter-card', 20, 40, 49)
})
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(36)
checkFilteringResult(42)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(112)
checkFilteringResult(117)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
@@ -160,7 +177,7 @@ describe('Population Statistics', () => {
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(141)
checkFilteringResult(152)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
})
@@ -200,11 +217,7 @@ describe('Population Statistics', () => {
runTestStepWithPreAndPostParts('Age', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
getCard().cs('option-graduated-true').click()
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
checkFilteringResult(11)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
testRangeFilter('Age-filter-card', 20, 30, 11)
getCard().cs('option-all').click()
})
})
@@ -213,7 +226,7 @@ describe('Population Statistics', () => {
describe('Course Statistics', () => {
const pathToDSAndAlgoSpring2019 =
'/coursepopulation?coursecodes=%5B%22TKT20001%22%2C%2258131%22%5D&from=138&separate=true&to=138&years=Kev%C3%A4t%202019'
'/coursepopulation?coursecodes=%5B%22TKT20001%22%2C%2258131%22%5D&from=138&separate=true&to=138&years=Kev%C3%A4t%202019&unifyCourses=unifyStats'
const defaultAmountOfStudents = 118
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
@@ -247,7 +260,10 @@ describe('Course Statistics', () => {
})
})
it('Study track filter works', () => {
// The test does not work since it tries to filter by "Ravitsemustiede"-studytrack when checking
// CS Bachelor students. Now that the irrelevant studytracks have been removed, the test fails.
// So fix this
it.skip('Study track filter works', () => {
runTestStepWithPreAndPostParts('StudyTrack', () => {
const card = cy.cs('StudyTrack-filter-card')
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
@@ -258,11 +274,7 @@ describe('Course Statistics', () => {
it('Age filter works', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('40')
checkFilteringResult(31)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
testRangeFilter('Age-filter-card', 20, 40, 35)
})
})
@@ -295,11 +307,7 @@ describe('Course Statistics', () => {
runTestStepWithPreAndPostParts('Grade', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('gradeFilter-3').click()
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
checkFilteringResult(1)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
testRangeFilter('Age-filter-card', 20, 30, 1)
cy.cs('gradeFilter-3').click()
})
})
@@ -323,11 +331,7 @@ describe('Custom Population Statistics', () => {
it('Age filter works', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('ageFilter-min').type('20')
cy.cs('ageFilter-max').type('30')
checkFilteringResult(1)
cy.cs('ageFilter-min').find('input').clear()
cy.cs('ageFilter-max').find('input').clear()
testRangeFilter('Age-filter-card', 20, 30, 1)
})
})
+26 -26
View File
@@ -3,15 +3,15 @@
const moment = require('moment')
const _ = require('lodash')
const setPopStatsUntil = (until, includeSettings = []) => {
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
includeSettings.forEach(setting => {
cy.contains('Advanced settings').parent().siblings().contains(setting).click()
})
cy.get('.adv-stats-until > .form-control').click().clear().type(until)
cy.contains('Fetch population with new settings').click()
cy.contains('Advanced settings')
}
// const setPopStatsUntil = (until, includeSettings = []) => {
// cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
// includeSettings.forEach(setting => {
// cy.contains('Advanced settings').parent().siblings().contains(setting).click()
// })
// cy.get('.adv-stats-until > .form-control').click().clear().type(until)
// cy.contains('Fetch population with new settings').click()
// cy.contains('Advanced settings')
// }
describe('Population Statistics tests', () => {
const pathToCSBach2017 =
@@ -65,13 +65,14 @@ describe('Population Statistics tests', () => {
it('Population statistics is usable on general level', () => {
cy.visit(pathToCSBach2017)
cy.cs('filtered-students')
setPopStatsUntil('toukokuu 2020')
// This advanced settings does not work correctly
// see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3552
// setPopStatsUntil('toukokuu 2020')
cy.get('.card').within(() => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Sample size: 149 students')
cy.contains('Sample size: 170 students')
cy.contains('Excludes exchange students')
cy.contains("Excludes students who haven't enrolled present nor absent")
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
@@ -80,9 +81,9 @@ describe('Population Statistics tests', () => {
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(15000) // a bit hacky way, wait until ui is ready
cy.get('[data-cy=expand-TKT1]').click()
cy.get('[data-cy=coursestats-link-TKT10002]').click()
cy.wait(25000) // a bit hacky way, wait until ui is ready
cy.get('[data-cy=toggle-group-module-TKT1]').click()
cy.contains('td', 'TKT10002').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
@@ -90,7 +91,7 @@ describe('Population Statistics tests', () => {
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
cy.visit(pathToCSBach2017)
cy.contains('Students (149)').click()
cy.contains('Students (170)').click()
cy.get('[data-cy=student-table-tabs]').contains('Tags').click()
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.contains('Tags').click()
@@ -119,12 +120,11 @@ describe('Population Statistics tests', () => {
cy.contains('Fetch population').click()
cy.contains('Credit accumulation (for 149 students)')
cy.contains('Credit accumulation (for 170 students)')
// spring + fall and include cancelled
// spring + fall
cy.get('[data-cy=advanced-toggle]').click()
cy.cs('toggle-spring').click()
cy.cs('toggle-cancelled').click()
cy.contains('Fetch population').click()
cy.contains('Credit accumulation (for 170 students)')
@@ -140,7 +140,7 @@ describe('Population Statistics tests', () => {
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
cy.get('.credits-gained-table').should('contain', '(n=149)')
cy.get('.credits-gained-table').should('contain', '(n=170)')
for (const [start, end] of ranges) {
let value = 'credits'
@@ -159,7 +159,7 @@ describe('Population Statistics tests', () => {
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
expect(sum).to.equal(149)
expect(sum).to.equal(170)
})
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
@@ -182,10 +182,10 @@ describe('Population Statistics tests', () => {
cy.contains('Credit statistics').click({ force: true })
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 170 Students')
cy.get("[data-cy='credit-stats-mean']").should('contain', '114.75')
cy.get("[data-cy='credit-stats-stdev']").should('contain', '65.53')
cy.get("[data-cy='credit-stats-min']").should('contain', '5')
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
})
})
@@ -198,7 +198,7 @@ describe('Population Statistics tests', () => {
const existing = '010113437'
const nonExisting = '66666666'
cy.visit(pathToCSBach2017)
cy.contains('Students (149)').click()
cy.contains('Students (170)').click()
cy.contains(existing)
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check studentnumbers').click()
+4 -4
View File
@@ -37,7 +37,7 @@ describe('Student Statistics tests', () => {
it('Can get student specific page by clicking student', () => {
cy.url().should('include', '/students')
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.contains('Started: 01.08.2017')
cy.contains('Tietojenkäsittelytieteen kandiohjelma (01.08.2017 - 01.08.2025')
cy.contains(student.lastname).should('not.exist')
@@ -54,7 +54,7 @@ describe('Student Statistics tests', () => {
it('Can get back to search menu', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.get('.remove').click()
cy.contains('Student number').should('not.exist')
cy.contains('Credits').should('not.exist')
@@ -62,7 +62,7 @@ describe('Student Statistics tests', () => {
it('Can jump to course', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.contains('Introduction to Machine Learning (DATA11002)')
.siblings()
.within(() => {
@@ -82,7 +82,7 @@ describe('Student Statistics tests', () => {
it('Can jump to population page', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains(student.studentnumber).click()
cy.contains('td a', student.studentnumber).click()
cy.get('i.level.up.alternate.icon').eq(0).click()
cy.contains('Population statistics')
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
+71 -49
View File
@@ -1,10 +1,10 @@
/// <reference types="Cypress" />
const deleteTag = name => {
cy.contains(name).siblings().contains('Delete').click()
cy.contains('td', name).siblings().contains('Delete').click()
cy.contains('Are you sure you want to delete tag')
cy.contains('Confirm').click()
cy.contains(name).should('not.exist')
cy.contains('td', name).should('not.exist')
}
const getEmptyYears = isAcademicYear => {
@@ -30,7 +30,7 @@ describe('Studyprogramme overview', () => {
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information -tab loads', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
@@ -39,23 +39,23 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct students', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Started, Graduated, Cancelled, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2021, 0, 0, 0, 1, 0],
[2020, 12, 36, 16, 3, 2],
[2019, 87, 17, 14, 0, 1],
[2018, 161, 1, 3, 0, 0],
[2017, 171, 0, 0, 0, 0],
// [Year, Started, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0]),
[2021, 0, 0, 1, 0],
[2020, 12, 36, 3, 2],
[2019, 87, 17, 0, 1],
[2018, 161, 1, 0, 0],
[2017, 171, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'StudentsOfTheStudyprogramme')
})
it('Basic information contains correct credits', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Total, Major credits, Non-major credits, Transferred credits]
@@ -71,7 +71,7 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct thesis writers and graduates', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
@@ -87,7 +87,7 @@ describe('Studyprogramme overview', () => {
})
it.skip('Special studyrights can be excluded and basic data changes accordingly', () => {
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=StudentToggle]').click()
const years = getEmptyYears()
const studentTableContents = [
@@ -129,18 +129,18 @@ describe('Studyprogramme overview', () => {
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=YearToggle]').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started, Graduated, Cancelled, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0, 0],
['2020 - 2021', 5, 0, 1, 0, 0],
['2019 - 2020', 27, 0, 1, 0, 0],
['2018 - 2019', 4, 0, 0, 0, 0],
['2017 - 2018', 0, 0, 0, 0, 0],
// [Year, Started, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0],
['2020 - 2021', 5, 0, 0, 0],
['2019 - 2020', 27, 0, 0, 0],
['2018 - 2019', 4, 0, 0, 0],
['2017 - 2018', 0, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
@@ -160,7 +160,7 @@ describe('Studyprogramme overview', () => {
})
it('Basic information graphs render', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
.should('contain', 'Started')
@@ -187,7 +187,7 @@ describe('Studyprogramme overview', () => {
cy.get('[data-cy=Graph-2019-AverageGraduationTimes]')
.should('contain', '2019')
.should('contain', 'n = 17')
.should('contain', '22 kk')
.should('contain', '23 kk')
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
@@ -200,14 +200,14 @@ describe('Studyprogramme overview', () => {
})
/* Studytrack overview -tests*/
describe('Basic information -view works for basic user', () => {
describe('Studytrack overview works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Studytracks and student populations -tab loads', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Section-StudytrackOverview]')
cy.get('[data-cy=Section-StudytrackProgress]')
@@ -215,7 +215,7 @@ describe('Studyprogramme overview', () => {
})
it('Students of the studyprogramme are shown correctly', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
// [Year, All, Started studying, Currently enrolled, Absent, Cancelled, Graduated, Men, Women, Finnish]
@@ -250,8 +250,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
1,
'1.2 %',
0,
'0 %',
1,
'1.2 %',
65,
@@ -271,8 +271,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
11,
'6.8 %',
0,
'0 %',
11,
'6.8 %',
118,
@@ -292,8 +292,8 @@ describe('Studyprogramme overview', () => {
'0 %',
0,
'0 %',
21,
'12.3 %',
3,
'1.8 %',
42,
'24.6 %',
135,
@@ -303,35 +303,57 @@ describe('Studyprogramme overview', () => {
166,
'97.1 %',
],
[
'Total',
431,
'100.0 %',
431,
'100.0 %',
0,
'0 %',
0,
'0 %',
3,
'0.7 %',
54,
'12.5 %',
328,
'76.1 %',
103,
'23.9 %',
425,
'98.6 %',
],
]
cy.checkTableStats(tableContents, 'StudytrackOverview')
})
it('Student progress data is shown correctly', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
['2020 - 2021', 12, 5, 2, 2, 0, 2, 1],
['2019 - 2020', 86, 13, 15, 32, 11, 2, 13],
['2018 - 2019', 162, 7, 16, 35, 33, 27, 44],
['2017 - 2018', 171, 10, 22, 18, 22, 24, 75],
['2020 - 2021', 12, 5, 2, 2, 0, 2, 0, 1],
['2019 - 2020', 86, 13, 15, 32, 11, 2, 0, 13],
['2018 - 2019', 162, 7, 16, 35, 33, 27, 18, 26],
['2017 - 2018', 171, 10, 22, 18, 22, 24, 27, 48],
['Total', 431, 35, 55, 87, 66, 55, 45, 88],
]
cy.checkTableStats(tableContents, 'StudytrackProgress')
})
it('Studytrack overview graphs render', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Graph-StudytrackProgress]')
.should('contain', 'Less than 30 credits')
.should('contain', '30-59 credits')
.should('contain', 'More than 150 credits')
.should('contain', '5.8%')
.should('contain', '4.3%')
.should('contain', '15.1%')
.should('contain', 'More than 180 credits')
.should('contain', '5.8%') // The percentage for less than 30 credits in 2017-2018, to check that the graph renders
.should('contain', '4.3%') // The percentage for less than 30 credits in 2018-2019
.should('contain', '15.1%') // The percentage for less than 30 credits in 2019-2020
cy.get('[data-cy=Graph-2018-AverageGraduationTimes]')
.should('contain', '2018 - 2019')
@@ -348,7 +370,7 @@ describe('Studyprogramme overview', () => {
it('can create and delete tags for population', () => {
const name = `tag-${new Date().getTime()}`
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -360,7 +382,7 @@ describe('Studyprogramme overview', () => {
it('can create personal tags', () => {
const name = `tag-${new Date().getTime()}`
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -376,7 +398,7 @@ describe('Studyprogramme overview', () => {
const student = '010113437'
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2018')
@@ -384,7 +406,7 @@ describe('Studyprogramme overview', () => {
cy.contains(name)
cy.contains('Add tags to students').click()
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
@@ -393,7 +415,7 @@ describe('Studyprogramme overview', () => {
cy.contains('Student statistics').click()
cy.get('.prompt').type(student)
cy.contains(student).click()
cy.contains('a', student).click()
cy.contains(name)
cy.go('back')
@@ -403,7 +425,7 @@ describe('Studyprogramme overview', () => {
cy.contains('Student statistics').click()
cy.get('.prompt').type(student)
cy.contains(student).click()
cy.contains('a', student).click()
cy.contains(name).should('not.exist')
})
})
+3 -11
View File
@@ -16,7 +16,7 @@ describe('Teachers page tests', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.cs('course-providers').children('.icon').click()
cy.get('.form > .fluid').click()
cy.contains('Name')
cy.contains('Teacher')
cy.contains(teacher1)
cy.contains(teacher2)
})
@@ -32,21 +32,13 @@ describe('Teachers page tests', () => {
cy.url().should('include', '/teachers')
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher1.split()[0])
cy.contains(teacher1).click()
cy.contains('td', teacher1).click()
cy.contains('Aineopintojen harjoitustyö: Tietokantasovellus')
})
it('Check leaderboad works', () => {
cy.get('.borderless > :nth-child(2)').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.contains('2019-2020').click({ force: true })
cy.contains('Recalculate this year').click({ force: true })
cy.wait(5000)
cy.reload()
cy.contains('2019-2020').click({ force: true })
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
cy.contains('Passed')
cy.contains('Ahokas Jarmo Ilkka')
cy.contains('2020-2021').click({ force: true })
})
})
+1 -3
View File
@@ -39,9 +39,7 @@ describe('Users tests', () => {
it("mocking normal user shows only the mocked user's programmes", () => {
cy.contains('mocking').should('not.exist')
cy.contains('tr', 'basic').within(() => {
cy.contains('.button', 'Edit').click()
})
cy.cs('user-edit-button-basic').click()
cy.get('i.spy').click()
cy.contains('mocking as basic')
cy.wait(1000)
+10
View File
@@ -14,3 +14,13 @@
// ***********************************************************
import './commands'
// This error is benign and seems to be only present in Cypress
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
Cypress.on('uncaught:exception', err => {
/* returning false here prevents Cypress from failing the test */
if (resizeObserverLoopErrRe.test(err.message)) {
return false
}
})
+822 -333
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -28,24 +28,24 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@babel/eslint-parser": "^7.15.0",
"@babel/plugin-syntax-jsx": "^7.14.5",
"concurrently": "^6.2.1",
"cypress": "^8.3.1",
"@babel/eslint-parser": "^7.17.0",
"@babel/plugin-syntax-jsx": "^7.16.7",
"concurrently": "^6.5.1",
"cypress": "^8.7.0",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-prettier": "^8.3.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prefer-object-spread": "^1.2.1",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"husky": "^7.0.2",
"lint-staged": "^11.1.2",
"prettier": "^2.3.2",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7.0.4",
"lint-staged": "^11.2.6",
"prettier": "^2.5.1",
"stylelint": "^13.13.1",
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
+288 -257
View File
@@ -3,10 +3,15 @@
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
},
"@dabh/diagnostics": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz",
"integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
"integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
"requires": {
"colorspace": "1.1.x",
"enabled": "2.0.x",
@@ -76,48 +81,48 @@
}
},
"@sentry/tracing": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.11.0.tgz",
"integrity": "sha512-9VA1/SY++WeoMQI4K6n/sYgIdRtCu9NLWqmGqu/5kbOtESYFgAt1DqSyqGCr00ZjQiC2s7tkDkTNZb38K6KytQ==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.18.2.tgz",
"integrity": "sha512-hg6NLqrqJ5sUPTyWEQ2RqdnhQVnyLtx8II0IyWxQLDWD8UCe3Mu6G7mroDtakPWcP+lWz6OnKfMEfuhMcxR8fw==",
"requires": {
"@sentry/hub": "6.11.0",
"@sentry/minimal": "6.11.0",
"@sentry/types": "6.11.0",
"@sentry/utils": "6.11.0",
"@sentry/hub": "6.18.2",
"@sentry/minimal": "6.18.2",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/hub": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.11.0.tgz",
"integrity": "sha512-pT9hf+ZJfVFpoZopoC+yJmFNclr4NPqPcl2cgguqCHb69DklD1NxgBNWK8D6X05qjnNFDF991U6t1mxP9HrGuw==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.18.2.tgz",
"integrity": "sha512-d0AugekMkbnN12b4EXMjseJxtLPc9S20DGobCPUb4oAQT6S2oDQEj1jwP6PQ5vtgyy+GMYWxBMgqAQ4pjVYISQ==",
"requires": {
"@sentry/types": "6.11.0",
"@sentry/utils": "6.11.0",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
}
},
"@sentry/minimal": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.11.0.tgz",
"integrity": "sha512-XkZ7qrdlGp4IM/gjGxf1Q575yIbl5RvPbg+WFeekpo16Ufvzx37Mr8c2xsZaWosISVyE6eyFpooORjUlzy8EDw==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.18.2.tgz",
"integrity": "sha512-n7KYuo34W2LxE+3dnZ47of7XHuORINCnXq66XH72eoj67tf0XeWbIhEJrYGmoLRyRfoCYYrBLWiDl/uTjLzrzQ==",
"requires": {
"@sentry/hub": "6.11.0",
"@sentry/types": "6.11.0",
"@sentry/hub": "6.18.2",
"@sentry/types": "6.18.2",
"tslib": "^1.9.3"
}
},
"@sentry/types": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.11.0.tgz",
"integrity": "sha512-gm5H9eZhL6bsIy/h3T+/Fzzz2vINhHhqd92CjHle3w7uXdTdFV98i2pDpErBGNTSNzbntqOMifYEB5ENtZAvcg=="
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.18.2.tgz",
"integrity": "sha512-WzpJf/Q5aORTzrSwer/As1NlO90dBAQpaHV2ikDDKqOyMWEgjKb5/4gh59p9gH8JMMnLetP1AvQel0fOj5UnUw=="
},
"@sentry/utils": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.11.0.tgz",
"integrity": "sha512-IOvyFHcnbRQxa++jO+ZUzRvFHEJ1cZjrBIQaNVc0IYF0twUOB5PTP6joTcix38ldaLeapaPZ9LGfudbvYvxkdg==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.18.2.tgz",
"integrity": "sha512-EC619jesknyu4xpwud5WC/5odYLz6JUy7OSFy5405PpdGeh/m8XUvuJAx4zDx0Iz/Mlk0S1Md+ZcQwqkv39dkw==",
"requires": {
"@sentry/types": "6.11.0",
"@sentry/types": "6.18.2",
"tslib": "^1.9.3"
}
}
@@ -137,10 +142,28 @@
"tslib": "^1.9.3"
}
},
"@types/debug": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
"integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==",
"requires": {
"@types/ms": "*"
}
},
"@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
},
"@types/node": {
"version": "14.14.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.39.tgz",
"integrity": "sha512-Qipn7rfTxGEDqZiezH+wxqWYR8vcXq5LRpZrETD19Gs4o8LbklbmqotSUsMU+s5G3PJwMRDfNEYoxrcBwIxOuw=="
"version": "17.0.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz",
"integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ=="
},
"@types/validator": {
"version": "13.7.1",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.1.tgz",
"integrity": "sha512-I6OUIZ5cYRk5lp14xSOAiXjWrfVoMZVjDuevBYgQDYzZIjsf2CAISpEcXOkFAtpAHbmWIDLcZObejqny/9xq5Q=="
},
"abbrev": {
"version": "1.1.1",
@@ -149,12 +172,12 @@
"dev": true
},
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
"integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"requires": {
"mime-types": "~2.1.24",
"negotiator": "0.6.2"
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
}
},
"agent-base": {
@@ -165,11 +188,6 @@
"debug": "4"
}
},
"any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8="
},
"arr-diff": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
@@ -206,9 +224,9 @@
"dev": true
},
"async": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz",
"integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg=="
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
"integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g=="
},
"async-each": {
"version": "1.0.3",
@@ -217,9 +235,9 @@
"dev": true
},
"async-lock": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.3.0.tgz",
"integrity": "sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg=="
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.3.1.tgz",
"integrity": "sha512-zK7xap9UnttfbE23JmcrNIyueAn6jWshihJqA33U/hEnKprF/lVGBDsBv/bqLm2YMMl1DnpHhUY044eA0t1TUw=="
},
"atob": {
"version": "2.1.2",
@@ -228,9 +246,9 @@
"dev": true
},
"axios": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
@@ -291,20 +309,20 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"body-parser": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
"integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
"version": "1.19.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz",
"integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==",
"requires": {
"bytes": "3.1.0",
"bytes": "3.1.2",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "~1.1.2",
"http-errors": "1.7.2",
"http-errors": "1.8.1",
"iconv-lite": "0.4.24",
"on-finished": "~2.3.0",
"qs": "6.7.0",
"raw-body": "2.4.0",
"type-is": "~1.6.17"
"qs": "6.9.7",
"raw-body": "2.4.3",
"type-is": "~1.6.18"
},
"dependencies": {
"debug": {
@@ -338,9 +356,9 @@
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"cache-base": {
"version": "1.0.1",
@@ -514,12 +532,12 @@
}
},
"color": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz",
"integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"requires": {
"color-convert": "^1.9.1",
"color-string": "^1.5.2"
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"color-convert": {
@@ -536,25 +554,20 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"color-string": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
"integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz",
"integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==",
"requires": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
},
"colorspace": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz",
"integrity": "sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
"integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
"requires": {
"color": "3.0.x",
"color": "^3.1.3",
"text-hex": "1.0.x"
}
},
@@ -571,11 +584,18 @@
"dev": true
},
"content-disposition": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"requires": {
"safe-buffer": "5.1.2"
"safe-buffer": "5.2.1"
},
"dependencies": {
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"content-type": {
@@ -602,7 +622,8 @@
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"cors": {
"version": "2.8.5",
@@ -838,16 +859,16 @@
}
},
"express": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
"integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
"version": "4.17.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz",
"integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==",
"requires": {
"accepts": "~1.3.7",
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.19.0",
"content-disposition": "0.5.3",
"body-parser": "1.19.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.4.0",
"cookie": "0.4.2",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~1.1.2",
@@ -861,13 +882,13 @@
"on-finished": "~2.3.0",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.7",
"proxy-addr": "~2.0.5",
"qs": "6.7.0",
"proxy-addr": "~2.0.7",
"qs": "6.9.7",
"range-parser": "~1.2.1",
"safe-buffer": "5.1.2",
"send": "0.17.1",
"serve-static": "1.14.1",
"setprototypeof": "1.1.1",
"safe-buffer": "5.2.1",
"send": "0.17.2",
"serve-static": "1.14.2",
"setprototypeof": "1.2.0",
"statuses": "~1.5.0",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
@@ -875,9 +896,9 @@
},
"dependencies": {
"cookie": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="
},
"debug": {
"version": "2.6.9",
@@ -891,6 +912,11 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
@@ -951,11 +977,6 @@
}
}
},
"fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
},
"fecha": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz",
@@ -1003,9 +1024,9 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"follow-redirects": {
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"for-in": {
"version": "1.0.2",
@@ -1014,9 +1035,9 @@
"dev": true
},
"forwarded": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
"integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ="
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
},
"fragment-cache": {
"version": "0.2.1",
@@ -1106,22 +1127,15 @@
}
},
"http-errors": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
"integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz",
"integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
"requires": {
"depd": "~1.1.2",
"inherits": "2.0.3",
"setprototypeof": "1.1.1",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
"toidentifier": "1.0.1"
}
},
"https-proxy-agent": {
@@ -1160,9 +1174,9 @@
"dev": true
},
"inflection": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.1.tgz",
"integrity": "sha512-dldYtl2WlN0QDkIDtg8+xFwOS2Tbmp12t1cHa5/YClU6ZQjTFm7B66UcVbh9NQB+HvT5BAd2t5+yKsBkw5pcqA=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.2.tgz",
"integrity": "sha512-cmZlljCRTBFouT8UzMzrGcVEvkv6D/wBdcdKG7J1QH5cXjtU75Dm+P27v9EKu/Y43UYyCJd1WC4zLebRrC8NBw=="
},
"inherits": {
"version": "2.0.4",
@@ -1303,7 +1317,8 @@
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true
},
"isexe": {
"version": "2.0.0",
@@ -1334,14 +1349,14 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"logform": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
"integrity": "sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz",
"integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==",
"requires": {
"colors": "^1.2.1",
"fast-safe-stringify": "^2.0.4",
"@colors/colors": "1.5.0",
"fecha": "^4.2.0",
"ms": "^2.1.1",
"safe-stable-stringify": "^2.3.1",
"triple-beam": "^1.3.0"
}
},
@@ -1400,16 +1415,16 @@
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
},
"mime-db": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz",
"integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw=="
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
},
"mime-types": {
"version": "2.1.30",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz",
"integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==",
"version": "2.1.34",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
"requires": {
"mime-db": "1.47.0"
"mime-db": "1.51.0"
}
},
"minimatch": {
@@ -1443,9 +1458,9 @@
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"moment-timezone": {
"version": "0.5.33",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
"integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
"version": "0.5.34",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz",
"integrity": "sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==",
"requires": {
"moment": ">= 2.9.0"
}
@@ -1514,9 +1529,9 @@
}
},
"negotiator": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
},
"nodemon": {
"version": "1.19.4",
@@ -2205,14 +2220,14 @@
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
},
"pg": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz",
"integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==",
"version": "8.7.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.7.3.tgz",
"integrity": "sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==",
"requires": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.4.1",
"pg-pool": "^3.5.1",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
@@ -2229,9 +2244,9 @@
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
},
"pg-pool": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz",
"integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ=="
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.1.tgz",
"integrity": "sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ=="
},
"pg-protocol": {
"version": "1.5.0",
@@ -2251,11 +2266,11 @@
}
},
"pgpass": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.4.tgz",
"integrity": "sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"requires": {
"split2": "^3.1.1"
"split2": "^4.1.0"
}
},
"posix-character-classes": {
@@ -2290,14 +2305,15 @@
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"proxy-addr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
"integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"requires": {
"forwarded": "~0.1.2",
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
}
},
@@ -2314,9 +2330,9 @@
"dev": true
},
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
"integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw=="
},
"range-parser": {
"version": "1.2.1",
@@ -2324,12 +2340,12 @@
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"raw-body": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
"integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz",
"integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==",
"requires": {
"bytes": "3.1.0",
"http-errors": "1.7.2",
"bytes": "3.1.2",
"http-errors": "1.8.1",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
}
@@ -2350,6 +2366,7 @@
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"dev": true,
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -2430,12 +2447,9 @@
"dev": true
},
"retry-as-promised": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-3.2.0.tgz",
"integrity": "sha512-CybGs60B7oYU/qSQ6kuaFmRd9sTZ6oXSc0toqePvV74Ac6/IFZSI1ReFQmtCN+uvW1Mtqdwpvt/LGOiCBAY2Mg==",
"requires": {
"any-promise": "^1.3.0"
}
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-5.0.0.tgz",
"integrity": "sha512-6S+5LvtTl2ggBumk04hBo/4Uf6fRJUwIgunGZ7CYEBCeufGFW1Pu6ucUf/UskHeWOIsUcLOGLFXPig5tR5V1nA=="
},
"safe-buffer": {
"version": "5.1.2",
@@ -2451,11 +2465,39 @@
"ret": "~0.1.10"
}
},
"safe-stable-stringify": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
"integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg=="
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"requires": {
"lru-cache": "^6.0.0"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
},
"semver-diff": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz",
@@ -2474,9 +2516,9 @@
}
},
"send": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
"integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz",
"integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==",
"requires": {
"debug": "2.6.9",
"depd": "~1.1.2",
@@ -2485,9 +2527,9 @@
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "~1.7.2",
"http-errors": "1.8.1",
"mime": "1.6.0",
"ms": "2.1.1",
"ms": "2.1.3",
"on-finished": "~2.3.0",
"range-parser": "~1.2.1",
"statuses": "~1.5.0"
@@ -2507,76 +2549,66 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"ms": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
}
}
},
"sequelize": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.6.5.tgz",
"integrity": "sha512-QyRrJrDRiwuiILqTMHUA1yWOPIL12KlfmgZ3hnzQwbMvp2vJ6fzu9bYJQB+qPMosck4mBUggY4Cjoc6Et8FBIQ==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.17.0.tgz",
"integrity": "sha512-AZus+0YZDq91Zg0hzDaO5atTzHgJruI23V8nBlAhkLuI81Z53nSRdAe/4R1A6vGOZ/RfCLP9idF4tfQnoAsM5A==",
"requires": {
"debug": "^4.1.1",
"dottie": "^2.0.0",
"inflection": "1.13.1",
"lodash": "^4.17.20",
"moment": "^2.26.0",
"moment-timezone": "^0.5.31",
"retry-as-promised": "^3.2.0",
"semver": "^7.3.2",
"sequelize-pool": "^6.0.0",
"@types/debug": "^4.1.7",
"@types/validator": "^13.7.1",
"debug": "^4.3.3",
"dottie": "^2.0.2",
"inflection": "^1.13.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"pg-connection-string": "^2.5.0",
"retry-as-promised": "^5.0.0",
"semver": "^7.3.5",
"sequelize-pool": "^7.1.0",
"toposort-class": "^1.0.1",
"uuid": "^8.1.0",
"validator": "^13.6.0",
"uuid": "^8.3.2",
"validator": "^13.7.0",
"wkx": "^0.5.0"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"debug": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
"integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
"requires": {
"yallist": "^4.0.0"
"ms": "2.1.2"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"requires": {
"lru-cache": "^6.0.0"
}
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
},
"sequelize-pool": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-6.1.0.tgz",
"integrity": "sha512-4YwEw3ZgK/tY/so+GfnSgXkdwIJJ1I32uZJztIEgZeAO6HMgj64OzySbWLgxj+tXhZCJnzRfkY9gINw8Ft8ZMg=="
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz",
"integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg=="
},
"serve-static": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
"integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz",
"integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==",
"requires": {
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.17.1"
"send": "0.17.2"
}
},
"set-value": {
@@ -2618,9 +2650,9 @@
}
},
"setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"shebang-command": {
"version": "1.2.0",
@@ -2842,24 +2874,9 @@
}
},
"split2": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
"integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==",
"requires": {
"readable-stream": "^3.0.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
"integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ=="
},
"stack-trace": {
"version": "0.0.10",
@@ -3072,9 +3089,9 @@
}
},
"toidentifier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
},
"toposort-class": {
"version": "1.0.1",
@@ -3323,19 +3340,20 @@
}
},
"winston": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz",
"integrity": "sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.6.0.tgz",
"integrity": "sha512-9j8T75p+bcN6D00sF/zjFVmPp+t8KMPB1MzbbzYjeN9VWxdsYnTB40TkbNUEXAmILEfChMvAMgidlX64OG3p6w==",
"requires": {
"@dabh/diagnostics": "^2.0.2",
"async": "^3.1.0",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.2.0",
"logform": "^2.4.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.4.0"
"winston-transport": "^4.5.0"
},
"dependencies": {
"is-stream": {
@@ -3356,12 +3374,25 @@
}
},
"winston-transport": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.4.0.tgz",
"integrity": "sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz",
"integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==",
"requires": {
"readable-stream": "^2.3.7",
"triple-beam": "^1.2.0"
"logform": "^2.3.2",
"readable-stream": "^3.6.0",
"triple-beam": "^1.3.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"wkx": {
+9 -9
View File
@@ -8,27 +8,27 @@
},
"dependencies": {
"@sentry/node": "^5.30.0",
"@sentry/tracing": "^6.11.0",
"async": "^3.2.1",
"async-lock": "^1.3.0",
"axios": "^0.21.2",
"@sentry/tracing": "^6.18.2",
"async": "^3.2.3",
"async-lock": "^1.3.1",
"axios": "^0.21.4",
"bluebird": "^3.7.2",
"cors": "^2.8.5",
"cron": "^1.8.2",
"express": "^4.17.1",
"express": "^4.17.3",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^5.1.1",
"moment": "^2.29.1",
"moment-timezone": "^0.5.33",
"moment-timezone": "^0.5.34",
"morgan": "^1.10.0",
"pg": "^8.7.1",
"pg": "^8.7.3",
"redis": "^3.1.2",
"sequelize": "^6.6.5",
"sequelize": "^6.17.0",
"umzug": "^2.3.0",
"unfuck-utf8-headers-middleware": "^1.0.1",
"uuid": "^3.4.0",
"winston": "^3.3.3"
"winston": "^3.6.0"
},
"devDependencies": {
"nodemon": "^1.19.4"
@@ -0,0 +1,6 @@
module.exports = {
up: async queryInterface => {
await queryInterface.dropTable('thesis_courses')
},
down: async () => {},
}
+3 -1
View File
@@ -25,7 +25,9 @@ const refreshStudyrightAssociations = async () => {
const refreshNewOverviews = async () => {
logger.info('Refreshing studyprogramme and studytrack overview statistics for all programmes')
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH') || code.includes('MH'))
const codes = (await getAllProgrammes())
.map(p => p.code)
.filter(code => code.includes('KH') || code.includes('MH') || /^(T)[0-9]{6}$/.test(code))
let ready = 0
for (const code of codes) {
try {
+69
View File
@@ -0,0 +1,69 @@
const { Model, DATE, INTEGER, STRING, BOOLEAN } = require('sequelize')
const { dbConnections } = require('../database/connection')
class Enrollment extends Model {}
Enrollment.init(
{
id: {
primaryKey: true,
type: STRING,
},
studentnumber: {
type: STRING,
references: {
model: 'student',
key: 'studentnumber',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
course_code: {
type: STRING,
},
state: {
type: STRING,
},
enrollment_date_time: {
type: DATE,
},
course_id: {
type: STRING,
references: {
model: 'course',
key: 'id',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semester_composite: {
type: STRING,
references: {
model: 'semesters',
key: 'composite',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semestercode: {
type: INTEGER,
},
createdAt: {
type: DATE,
},
updatedAt: {
type: DATE,
},
is_open: {
type: BOOLEAN,
},
},
{
underscored: false,
sequelize: dbConnections.sequelize,
modelName: 'enrollment',
tableName: 'enrollment',
}
)
module.exports = Enrollment
+13
View File
@@ -10,6 +10,7 @@ const CreditType = require('./creditType')
const Credit = require('./credit')
const CreditTeacher = require('./creditTeacher')
const ElementDetail = require('./elementDetail')
const Studyplan = require('./studyplan')
const StudyrightExtent = require('./studyrightExtent')
const Studyright = require('./studyright')
const StudyrightElement = require('./studyrightElement')
@@ -17,6 +18,7 @@ const Transfer = require('./transfer')
const ProgrammeModule = require('./programmeModule')
const ProgrammeModuleChild = require('./programmeModuleChild')
const ExcludedCourse = require('./excludedCourse')
const Enrollment = require('./enrollment')
CourseType.hasMany(Course, { foreignKey: 'coursetypecode', sourceKey: 'coursetypecode' })
Course.belongsTo(CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
@@ -62,6 +64,9 @@ Teacher.belongsToMany(Credit, { through: CreditTeacher, foreignKey: 'teacher_id'
Organization.hasMany(Studyright, { foreignKey: 'facultyCode', sourceKey: 'code' })
Studyright.belongsTo(Organization, { foreignKey: 'facultyCode', targetKey: 'code' })
Studyplan.belongsTo(Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
Student.hasMany(Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
Studyright.belongsTo(Student, { foreignKey: 'studentStudentnumber', targetKey: 'studentnumber' })
Student.hasMany(Studyright, { foreignKey: 'studentStudentnumber', sourceKey: 'studentnumber' })
@@ -88,6 +93,12 @@ Studyright.hasMany(Transfer, { foreignKey: 'studyrightid', sourceKey: 'studyrigh
Transfer.belongsTo(ElementDetail, { as: 'source', foreignKey: 'sourcecode' })
Transfer.belongsTo(ElementDetail, { as: 'target', foreignKey: 'targetcode' })
Enrollment.belongsTo(Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
Student.hasMany(Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
Enrollment.belongsTo(Course, { foreignKey: 'course_id' })
Course.hasMany(Enrollment, { foreignKey: 'course_id' })
Enrollment.belongsTo(Semester, { foreignKey: { name: 'semester_composite', allowNull: false } })
ProgrammeModule.belongsToMany(ProgrammeModule, {
as: 'parents',
through: ProgrammeModuleChild,
@@ -114,6 +125,7 @@ module.exports = {
Credit,
CreditTeacher,
ElementDetail,
Studyplan,
StudyrightExtent,
Studyright,
StudyrightElement,
@@ -121,4 +133,5 @@ module.exports = {
ProgrammeModule,
ProgrammeModuleChild,
ExcludedCourse,
Enrollment,
}
@@ -104,20 +104,6 @@ const MandatoryCourseLabels = sequelizeKone.define('mandatory_course_labels', {
},
})
const ThesisCourse = sequelizeKone.define('thesis_courses', {
programmeCode: {
primaryKey: true,
type: Sequelize.STRING,
},
courseCode: {
primaryKey: true,
type: Sequelize.STRING,
},
thesisType: {
type: Sequelize.ENUM([ThesisTypeEnums.BACHELOR, ThesisTypeEnums.MASTER]),
},
})
const CustomPopulationSearch = sequelizeKone.define('custom_population_searches', {
id: {
primaryKey: true,
@@ -173,7 +159,6 @@ module.exports = {
MandatoryCourse,
TagStudent,
Tag,
ThesisCourse,
ThesisTypeEnums,
MandatoryCourseLabels,
CustomPopulationSearch,
+48
View File
@@ -0,0 +1,48 @@
const { Model, DATE, ARRAY, STRING, INTEGER } = require('sequelize')
const { dbConnections } = require('../database/connection')
class Studyplan extends Model {}
Studyplan.init(
{
id: {
primaryKey: true,
type: INTEGER,
autoIncrement: true,
allowNull: false,
},
studentnumber: {
type: STRING,
references: {
model: 'student',
key: 'studentnumber',
},
onUpdate: 'cascade',
onDelete: 'cascade',
unique: 'source', // This and programme_code create a composite key for this table.
allowNull: false,
},
programme_code: {
type: STRING,
allowNull: false,
unique: 'source',
},
included_courses: {
type: ARRAY(STRING),
},
createdAt: {
type: DATE,
},
updatedAt: {
type: DATE,
},
},
{
underscored: false,
sequelize: dbConnections.sequelize,
modelName: 'studyplan',
tableName: 'studyplan',
}
)
module.exports = Studyplan
+3 -3
View File
@@ -9,9 +9,6 @@ Studyright.init(
primaryKey: true,
type: STRING,
},
canceldate: {
type: DATE,
},
startdate: {
type: DATE,
},
@@ -27,6 +24,9 @@ Studyright.init(
graduated: {
type: INTEGER,
},
active: {
type: INTEGER,
},
// irtisanomisperuste
studentStudentnumber: {
type: STRING,
+333 -34
View File
@@ -1,41 +1,69 @@
/* eslint no-console: 0 */
// script needs rapo certs, so you need to run this in production pannu
//
// possible workflows:
// - go to oodikone pannu, then "docker exec -it backend sh", then run this script with "npm
// run rapodiff". Modify with vim / nano until you're happy, then copy changes to local version and commit.
// - or bind mount a file to backend container in pannu, overriding this file. Modify that file
// inside or outside container, however you want. Keep file in pannu, or put it to pannu's git repo (oodikone-server-setup).
/*
depends on nodeproxy running in importer.cs.helsinki.fi, and env RAPO_NODEPROXY (see importer for a proper value)
needs also acces to importer db api, uses env IMPORTER_API
known differences in APIs
- rapo does not show students that have graduated (it however takes some days for rapo to notice a graduation)
- rapo does not handle the starting year of transfers properly (shows in old staring population)
- rapo show some students incorrectly passive, see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3521
*/
const axios = require('axios').default
const fs = require('fs')
const https = require('https')
const _ = require('lodash')
const { optimizedStatisticsOf } = require('./services/populations')
const { Student, Transfer, StudyrightElement, Studyright } = require('./models')
const { RAPO_CERT_PATH, RAPO_KEY_PATH, RAPO_API_KEY } = process.env
const token = process.env.RAPO_NODEPROXY
const agent = new https.Agent({
cert: fs.readFileSync(RAPO_CERT_PATH, 'utf8'),
key: fs.readFileSync(RAPO_KEY_PATH, 'utf8'),
})
const getFromRapo = async (urlStart, urlEnd) => {
const { data: rapoStuffLasna } = await axios.get(urlStart)
const { data: rapoStuffLasnaMinus } = await axios.get(urlEnd)
const api = axios.create({
baseURL: 'https://gw.api.helsinki.fi/secure/rapo/report/',
headers: { 'X-Api-Key': RAPO_API_KEY },
httpsAgent: agent,
})
const rapoStudents = _.difference(
rapoStuffLasna.map(s => s.opiskelijanumero),
rapoStuffLasnaMinus.map(s => s.opiskelijanumero)
)
const main = async () => {
const facultyCode = 'H50'
const programme = 'KH50_005'
const studyrightStartDate = '2020-08-01'
const presence = 'LÄSNÄOLEVA'
return rapoStudents
}
const months = 12 // some amount of months, dunno
const year = 2020
const known_problems_in_oodikone = {
KH30_001: ['013896191'],
KH40_003: ['013200457'],
KH55_001: ['013195834', '015119708', '015138336'],
}
const known_problems_in_rapo = {
KH55_001: ['015160142', '015135575', '015119708', '015138336'],
}
const getFromOodikone = async (programme, year) => {
const isNotAmongKnownProblems = student => {
const hasProblems = known_problems_in_oodikone[programme] ? known_problems_in_oodikone[programme] : []
return !hasProblems.includes(student.studentNumber)
}
const theStudyrights = student => {
const theOne = s => s.studyright_elements.find(e => e.code === programme)
// in some rare cases student may have more than one studyright for same programme
return student.studyrights.filter(theOne)
}
const notGraduated = student => {
const graduation = theStudyrights(student).filter(r => r.graduated === 1)
return graduation.length === 0
}
const notTransferred = student => {
const transfer = student.transfers.find(t => t.targetcode === programme)
return transfer === undefined
}
const months = 12
const oodikoneQuery = {
semesters: ['FALL', 'SPRING'],
@@ -44,21 +72,292 @@ const main = async () => {
year,
}
const oodikoneStuff = await optimizedStatisticsOf(oodikoneQuery)
const oodikoneStudents = oodikoneStuff.students.map(s => s.studentNumber)
const oodikoneStuff = (await optimizedStatisticsOf(oodikoneQuery)).students
const rapoUrl = encodeURI(
`studyrights?faculty=${facultyCode}&presence=${presence}&education=${programme}&studyrightStartDate=${studyrightStartDate}`
return oodikoneStuff
.filter(notGraduated)
.filter(notTransferred)
.filter(isNotAmongKnownProblems)
.map(s => s.studentNumber)
}
const isAcuallyActive = async student => {
const currentSemester = 144
const tokenImporterApi = process.env.IMPORTER_API
const url = `https://importer.cs.helsinki.fi/api/importer/students/${student}/rapo_semester_enrollments?token=${tokenImporterApi}`
const { data } = await axios.get(url)
const terms = data.reduce((all, s) => all.concat(s.terms), []).filter(t => t.semester_code === currentSemester)
const active = terms.filter(t => t.type !== 'NEGLECTED')
const negl = terms.filter(t => t.type === 'NEGLECTED')
return active.length > 0 && negl.length > 0
}
const hasTransferred = async (student, programme) => {
const data = await Student.findByPk(student, {
include: [
{
model: Transfer,
},
],
})
const transferTo = data.transfers.find(t => t.targetcode === programme)
return transferTo !== undefined
}
const graduatedVeryRecently = async (student, programme) => {
const data = await Student.findByPk(student, {
include: [
{
model: Studyright,
include: [
{
model: StudyrightElement,
required: true,
where: {
code: programme,
},
},
],
where: {
graduated: 1,
},
},
],
})
if (!data || data.studyrights.length === 0) {
return false
}
const graduated = new Date(data.studyrights[0].enddate)
const weekAgo = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000)
return graduated > weekAgo
}
const programme_diff_year = async (programme, facultyCode, year, brief = false) => {
const oodikoneStudents = await getFromOodikone(programme, year)
const studyrightStartDate = `${year}-08-01`
const studyrightEndDate = `${year + 1}-08-01`
const rapoUrlStart = presence =>
encodeURI(
`https://importer.cs.helsinki.fi/test/rapo/${facultyCode}/${programme}/${studyrightStartDate}/${presence}?token=${token}`
)
const rapoUrlEnd = presence =>
encodeURI(
`https://importer.cs.helsinki.fi/test/rapo/${facultyCode}/${programme}/${studyrightEndDate}/${presence}?token=${token}`
)
const rapoStudentsLasna = await getFromRapo(rapoUrlStart('Läsnä'), rapoUrlEnd('Läsnä'))
const rapoStudentsPoissa = await getFromRapo(rapoUrlStart('Poissa'), rapoUrlEnd('Poissa'))
const rapoStudentPoissaLaki = await getFromRapo(
rapoUrlStart('Poissa ei kuluta opintoaikaa'),
rapoUrlEnd('Poissa ei kuluta opintoaikaa')
)
const { data: rapoStuff } = await api.get(rapoUrl)
const rapoStudents = rapoStuff.map(s => s.opiskelijanumero)
const isNotAmongKnownProblems = student => {
const hasProblems = known_problems_in_rapo[programme] ? known_problems_in_rapo[programme] : []
return !hasProblems.includes(student.studentNumber)
}
const rapoStudents = rapoStudentsLasna
.concat(rapoStudentsPoissa)
.concat(rapoStudentPoissaLaki)
.filter(isNotAmongKnownProblems)
const onlyInOodikone = _.difference(oodikoneStudents, rapoStudents)
const onlyInRapo = _.difference(rapoStudents, oodikoneStudents)
console.log('Only in Oodikone:', onlyInOodikone)
console.log('Only in Rapo:', onlyInRapo)
const wrongMissingInRapo = []
for (let student of onlyInOodikone) {
const status = await isAcuallyActive(student)
if (status) {
wrongMissingInRapo.push(student)
}
}
// these are in wrong RAPO-population
const transferred = []
for (let student of onlyInRapo) {
const isTransfer = await hasTransferred(student, programme)
if (isTransfer) {
transferred.push(student)
} else {
const isRecentGraduation = await graduatedVeryRecently(student, programme)
if (isRecentGraduation) {
transferred.push(student)
}
}
}
const checkOodikoneOnly = _.difference(onlyInOodikone, wrongMissingInRapo)
const checkRapoOnly = _.difference(onlyInRapo, transferred)
if (!brief) {
console.log('vuosi', year)
console.log('')
console.log('oodikone', oodikoneStudents.length, ' transferred', transferred.length)
console.log('rapo', rapoStudents.length, ' activeness wrong rapo', wrongMissingInRapo.length)
console.log('only rapo', checkRapoOnly.length)
console.log('only oodikone', checkOodikoneOnly.length)
console.log('')
if (checkRapoOnly.length > 0) {
console.log('Only in Rapo:')
console.log(checkRapoOnly.join('\n'))
}
if (checkOodikoneOnly.length > 0) {
console.log('Only in Oodikone:')
console.log(checkOodikoneOnly.join('\n'))
}
}
if (checkRapoOnly.length + checkOodikoneOnly.length) {
console.log('******************************', year)
if (checkRapoOnly.length > 0) {
console.log('Only in Rapo:')
console.log(checkRapoOnly.join('\n'))
}
if (checkOodikoneOnly.length > 0) {
console.log('Only in Oodikone:')
console.log(checkOodikoneOnly.join('\n'))
}
}
if (!brief) {
console.log('')
}
}
const programme_diff = async (programme, faculty, brief) => {
console.log()
console.log(programme)
console.log('--------', '\n')
await programme_diff_year(programme, faculty, 2017, brief)
await programme_diff_year(programme, faculty, 2018, brief)
await programme_diff_year(programme, faculty, 2019, brief)
await programme_diff_year(programme, faculty, 2020, brief)
await programme_diff_year(programme, faculty, 2021, brief)
}
const faculty_diff = async (faculty, programmes, brief) => {
console.log('\n')
console.log(faculty)
console.log('==')
for (let programme of programmes) {
await programme_diff(programme, faculty, brief)
}
}
// eslint-disable-next-line no-unused-vars
const msc_programme_diff_year = async (programme, facultyCode, year, brief = false) => {
const oodikoneStudents = await getFromOodikone(programme, year)
const studyrightStartDate = `${year}-08-01`
const studyrightEndDate = `${year + 1}-08-01`
const rapoUrlStart = presence =>
encodeURI(
`https://importer.cs.helsinki.fi/test/rapo/${facultyCode}/${programme}/${studyrightStartDate}/${presence}?token=${token}`
)
const rapoUrlEnd = presence =>
encodeURI(
`https://importer.cs.helsinki.fi/test/rapo/${facultyCode}/${programme}/${studyrightEndDate}/${presence}?token=${token}`
)
const rapoStudentsLasna = await getFromRapo(rapoUrlStart('Läsnä'), rapoUrlEnd('Läsnä'))
const rapoStudentsPoissa = await getFromRapo(rapoUrlStart('Poissa'), rapoUrlEnd('Poissa'))
const rapoStudentPoissaLaki = await getFromRapo(
rapoUrlStart('Poissa ei kuluta opintoaikaa'),
rapoUrlEnd('Poissa ei kuluta opintoaikaa')
)
const rapoStudents = rapoStudentsLasna.concat(rapoStudentsPoissa).concat(rapoStudentPoissaLaki)
const onlyInOodikone = _.difference(oodikoneStudents, rapoStudents)
const onlyInRapo = _.difference(rapoStudents, oodikoneStudents)
const wrongInRapo = []
for (let student of onlyInOodikone) {
const status = await isAcuallyActive(student)
if (status) {
//wrongInRapo.push(student)
}
}
// in wrong RAPO population
const transferred = []
for (let student of onlyInRapo) {
const status = await hasTransferred(student, programme)
if (status) {
transferred.push(student)
}
}
const checkOodikoneOnly = _.difference(onlyInOodikone, wrongInRapo)
const checkRapoOnly = _.difference(onlyInRapo, transferred)
const both = _.intersection(oodikoneStudents, rapoStudents)
console.log('vuosi', year)
if (!brief) {
console.log('')
console.log('oodikone', oodikoneStudents.length, ' transferred', transferred.length)
console.log('rapo', rapoStudents.length, ' activeness wrong rapo', wrongInRapo.length)
console.log('both', both.length)
console.log('only rapo', checkRapoOnly.length)
console.log('only oodikone', checkOodikoneOnly.length)
console.log('')
if (checkRapoOnly.length > 0) {
console.log('Only in Rapo:')
console.log(checkRapoOnly.join('\n'))
}
if (checkOodikoneOnly.length > 0) {
console.log('Only in Oodikone:')
console.log(checkOodikoneOnly.join('\n'))
}
}
if (checkRapoOnly.length + checkOodikoneOnly.length) {
console.log('******************************************************')
}
if (!brief) {
console.log('')
}
}
const bsc_priogrammes_of_faculties = {
H10: ['KH10_001'],
H20: ['KH20_001'],
H30: ['KH30_001', 'KH30_002'],
H40: ['KH40_001', 'KH40_002', 'KH40_003', 'KH40_004', 'KH40_005', 'KH40_006'],
H50: ['KH50_001', 'KH50_002', 'KH50_003', 'KH50_004', 'KH50_005', 'KH50_006', 'KH50_007', 'KH50_008'],
H55: ['KH55_001'],
H60: ['KH60_001'],
H70: ['KH70_001', 'KH70_002', 'KH70_003', 'KH70_004'],
H74: ['KH74_001'],
H80: ['KH80_001', 'KH80_002', 'KH80_003', 'KH80_004'],
H90: ['KH90_001'],
}
const main = async () => {
for (let faculty of Object.keys(bsc_priogrammes_of_faculties)) {
await faculty_diff(faculty, bsc_priogrammes_of_faculties[faculty], true)
}
//await programme_diff_year('KH55_001', 'H55', 2021, true)
//await programme_diff('KH60_001', 'H60', true)
}
main().then(() => process.exit(0))
@@ -1,6 +1,5 @@
const router = require('express').Router()
const MandatoryCourses = require('../services/mandatoryCourses')
const { findProgrammeTheses, createThesisCourse, deleteThesisCourse } = require('../services/thesis')
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
router.get('/elementdetails/all', async (req, res) => {
@@ -22,35 +21,4 @@ router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
}
})
router.get('/v2/studyprogrammes/:id/thesis', async (req, res) => {
const { id } = req.params
if (id) {
const thesis = await findProgrammeTheses(id)
res.json(thesis)
} else {
res.status(422).end()
}
})
router.post('/v2/studyprogrammes/:id/thesis', async (req, res) => {
const { id } = req.params
const { course, thesisType } = req.body
if (id && course && thesisType) {
const thesis = await createThesisCourse(id, course, thesisType)
res.status(201).json(thesis)
} else {
res.status(422).end()
}
})
router.delete('/v2/studyprogrammes/:id/thesis/:course', async (req, res) => {
const { id, course } = req.params
if (id && course) {
const deleted = await deleteThesisCourse(id, course)
res.status(204).json(deleted)
} else {
res.status(422).end()
}
})
module.exports = router
+36 -8
View File
@@ -8,6 +8,8 @@ const StudyrightService = require('../services/studyrights')
const TagService = require('../services/tags')
const CourseService = require('../services/courses')
const StatMergeService = require('../services/statMerger')
const { mapToProviders } = require('../util/utils')
const { ApplicationError } = require('../util/customErrors')
const filterPersonalTags = (population, userId) => {
@@ -274,9 +276,9 @@ router.get('/v3/populationstatisticsbytag', async (req, res) => {
})
router.get('/v3/populationstatisticsbycourse', async (req, res) => {
const { coursecodes, from, to, separate: sep } = req.query
const { coursecodes, from, to, separate: sep, unifyCourses } = req.query
const {
user: { id, isAdmin, studentsUserCanAccess: allStudentsUserCanAccess },
user: { id, isAdmin, studentsUserCanAccess: allStudentsUserCanAccess, rights },
} = req
const separate = sep ? JSON.parse(sep) : false
@@ -293,7 +295,13 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
}
const semesters = ['FALL', 'SPRING']
const studentnumbers = await Student.findByCourseAndSemesters(JSON.parse(coursecodes), from, to, separate)
const studentnumbers = await Student.findByCourseAndSemesters(
JSON.parse(coursecodes),
from,
to,
separate,
unifyCourses
)
const result = await Population.optimizedStatisticsOf(
{
// Useless, because studentnumbers are already filtered above by from & to.
@@ -305,10 +313,15 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
},
studentnumbers
)
let courseproviders = []
if (!isAdmin) {
courseproviders = await CourseService.getCourseProvidersForCourses(JSON.parse(coursecodes))
}
const rightsMappedToProviders = mapToProviders(rights)
const found = courseproviders.some(r => rightsMappedToProviders.includes(r))
const studentsUserCanAccess = isAdmin
? new Set(studentnumbers)
: new Set(_.intersection(studentnumbers, allStudentsUserCanAccess))
const studentsUserCanAccess =
isAdmin || found ? new Set(studentnumbers) : new Set(_.intersection(studentnumbers, allStudentsUserCanAccess))
const randomHash = crypto.randomBytes(12).toString('hex')
const obfuscateStudent = ({ studyrights, studentNumber, courses, gender_code }) => ({
@@ -381,8 +394,23 @@ router.get('/v3/populationstatistics/studyprogrammes/unfiltered', async (req, re
router.get('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
const { courseCodes } = req.query
const maxYearsToCreatePopulationFrom = await CourseService.maxYearsToCreatePopulationFrom(JSON.parse(courseCodes))
return res.json(maxYearsToCreatePopulationFrom)
const maxYearsToCreatePopulationFromOpen = await CourseService.maxYearsToCreatePopulationFrom(
JSON.parse(courseCodes),
'openStats'
)
const maxYearsToCreatePopulationFromUni = await CourseService.maxYearsToCreatePopulationFrom(
JSON.parse(courseCodes),
'reqularStats'
)
const maxYearsToCreatePopulationFromBoth = await CourseService.maxYearsToCreatePopulationFrom(
JSON.parse(courseCodes),
'unifyStats'
)
return res.json({
openCourses: maxYearsToCreatePopulationFromOpen,
uniCourses: maxYearsToCreatePopulationFromUni,
unifyCourses: maxYearsToCreatePopulationFromBoth,
})
})
module.exports = router
+56 -7
View File
@@ -3,6 +3,7 @@ const { getBasicStatsForStudytrack } = require('../services/studyprogrammeBasics
const { getCreditStatsForStudytrack } = require('../services/studyprogrammeCredits')
const { getGraduationStatsForStudytrack } = require('../services/studyprogrammeGraduations')
const { getStudytrackStatsForStudyprogramme } = require('../services/studytrackStats')
const { getStudyprogrammeCoursesForStudytrack } = require('../services/studyprogrammeCourses')
const {
getBasicStats,
setBasicStats,
@@ -32,8 +33,10 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
try {
const result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setBasicStats(result, yearType, specialGroups)
} catch (e) {
@@ -55,6 +58,7 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
let data = null
try {
data = await getCreditStats(code, yearType, specialGroups)
data = null
} catch (e) {
logger.error(`Failed to get code ${code} credit stats`)
}
@@ -62,8 +66,10 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
try {
const result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setCreditStats(result, yearType, specialGroups)
} catch (e) {
@@ -92,8 +98,10 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
try {
const result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setGraduationStats(result, yearType, specialGroups)
} catch (e) {
@@ -106,6 +114,41 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
}
})
router.get('/v2/studyprogrammes/:id/coursestats', async (req, res) => {
const code = req.params.id
const showByYear = req.query?.academicyear
// const { date: unixMillis, showByYear = false } = req.query
// const date = new Date(Number(unixMillis))
const date = new Date()
const endOfToday = new Date()
endOfToday.setHours(23, 59, 59, 999)
if (isNaN(date.getTime()) || date.getTime() > endOfToday.getTime()) {
return res.status(400).json({ error: 'Invalid date' })
}
// End of day
date.setHours(23, 59, 59, 999)
if (code) {
let data = null
try {
data = null
} catch (e) {
logger.error(`Failed to get code ${code} graduation stats`)
}
if (!data) {
try {
data = await getStudyprogrammeCoursesForStudytrack(date.getTime(), code, showByYear)
} catch (e) {
logger.error(`Failed to get code ${code} programme courses stats: ${e}`)
}
return res.json(data)
} else {
res.status(422).end()
}
}
})
router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
const code = req.params.id
const graduated = req.query?.graduated
@@ -120,7 +163,13 @@ router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
}
if (!data) {
try {
const result = await getStudytrackStatsForStudyprogramme({ studyprogramme: code, specialGroups, graduated })
const result = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setStudytrackStats(result, graduated, specialGroups)
} catch (e) {
logger.error(`Failed to update code ${code} studytrack stats: ${e}`)
@@ -1,6 +1,8 @@
const { redisClient } = require('../services/redis')
const moment = require('moment')
// Only new bachelor, masters and doctoral programmes get their data updated in redis every night, use redis for them
const isUpdatedNewProgramme = code => code.includes('KH') || code.includes('MH') || /^(T)[0-9]{6}$/.test(code)
const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STATS_${id}_${yearType}_${specialGroups}`
const createRedisKeyForCreditStats = (id, yearType, specialGroups) => `CREDIT_STATS_${id}_${yearType}_${specialGroups}`
const createRedisKeyForGraduationStats = (id, yearType, specialGroups) =>
@@ -9,6 +11,7 @@ const createRedisKeyForStudytrackStats = (id, graduated, specialGroups) =>
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
const getBasicStats = async (id, yearType, specialGroups) => {
if (!isUpdatedNewProgramme(id)) return null
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
@@ -23,12 +26,14 @@ const setBasicStats = async (data, yearType, specialGroups) => {
status: 'DONE',
lastUpdated: moment().format(),
}
if (!isUpdatedNewProgramme(id)) return dataToRedis
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getCreditStats = async (id, yearType, specialGroups) => {
if (!isUpdatedNewProgramme(id)) return null
const redisKey = createRedisKeyForCreditStats(id, yearType, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
@@ -43,12 +48,14 @@ const setCreditStats = async (data, yearType, specialGroups) => {
status: 'DONE',
lastUpdated: moment().format(),
}
if (!isUpdatedNewProgramme(id)) return dataToRedis
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getGraduationStats = async (id, yearType, specialGroups) => {
if (!isUpdatedNewProgramme(id)) return null
const redisKey = createRedisKeyForGraduationStats(id, yearType, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
@@ -63,12 +70,14 @@ const setGraduationStats = async (data, yearType, specialGroups) => {
status: 'DONE',
lastUpdated: moment().format(),
}
if (!isUpdatedNewProgramme(id)) return dataToRedis
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
}
const getStudytrackStats = async (id, graduated, specialGroups) => {
if (!isUpdatedNewProgramme(id)) return null
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
@@ -83,6 +92,7 @@ const setStudytrackStats = async (data, graduated, specialGroups) => {
status: 'DONE',
lastUpdated: moment().format(),
}
if (!isUpdatedNewProgramme(id)) return dataToRedis
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
@@ -40,18 +40,22 @@ class CourseStatsCounter {
triedOfPopulation: undefined,
passingSemesters: this.initializePassingSemesters(),
}
this.enrollments = {
enrolledNoGrade: new Set(),
semesters: this.initializePassingSemesters({}),
}
this.grades = {}
}
initializePassingSemesters() {
initializePassingSemesters(initialValue = 0) {
const passingSemesters = {
BEFORE: 0,
LATER: 0,
BEFORE: initialValue,
LATER: initialValue,
}
for (let i = 0; i < 7; i++) {
passingSemesters[fall[i]] = 0
passingSemesters[spring[i]] = 0
passingSemesters[fall[i]] = initialValue
passingSemesters[spring[i]] = initialValue
}
return passingSemesters
@@ -91,6 +95,13 @@ class CourseStatsCounter {
}
}
markEnrollment(studentnumber, state, semester) {
if (!this.enrollments[state]) this.enrollments[state] = new Set()
this.enrollments[state].add(studentnumber)
if (!this.enrollments.semesters[semester][state]) this.enrollments.semesters[semester][state] = new Set()
this.enrollments.semesters[semester][state].add(studentnumber)
}
failedBefore(studentnumber) {
return this.students.failed[studentnumber] !== undefined
}
@@ -173,6 +184,10 @@ class CourseStatsCounter {
getFinalStats() {
const stats = this.stats
const students = this.students
const allStudents = Object.keys(students.all)
const totalEnrolledNoGrade = [...(this.enrollments.ENROLLED || [])].filter(
student => !allStudents.includes(student)
).length
stats.students = lengthOf(students.all)
stats.passed = lengthOf(students.passed)
@@ -185,6 +200,9 @@ class CourseStatsCounter {
stats.triedOfPopulation = percentageOf(stats.students, this.studentsInTotal)
stats.perStudent = stats.attempts / (stats.passed + stats.failed)
stats.passingSemestersCumulative = this.getPassingSemestersCumulative()
stats.totalStudents = stats.students + totalEnrolledNoGrade
stats.totalEnrolledNoGrade = totalEnrolledNoGrade
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
return {
stats,
@@ -8,12 +8,23 @@ class CourseYearlyStatsCounter {
}
initProgramme(code, name) {
this.programmes[code] = { name, students: {}, passed: {}, credits: {} }
this.programmes[code] = {
name,
students: {},
passed: {},
credits: {},
}
}
initFacultyYear(code) {
const year = `${1949 + Number(code)}-${1950 + Number(code)}`
this.facultyStats[code] = { year, allStudents: [], allPassed: [], faculties: {}, allCredits: 0 }
this.facultyStats[code] = {
year,
allStudents: [],
allPassed: [],
faculties: {},
allCredits: 0,
}
}
initFaculty(yearcode, faculty_code, organization) {
@@ -43,8 +54,11 @@ class CourseYearlyStatsCounter {
passedEventually: [],
neverPassed: [],
},
grades: {},
studentnumbers: [],
},
enrollments: [],
allEnrollments: [],
yearcode,
}
}
@@ -97,11 +111,41 @@ class CourseYearlyStatsCounter {
})
}
markStudyProgrammesEnrollment(studentnumber, programmes, yearcode, state, enrollment_date_time) {
programmes.forEach(({ code, name, faculty_code, organization }) => {
this.markStudyProgrammeEnrollment(
code,
name,
studentnumber,
yearcode,
state,
enrollment_date_time,
faculty_code,
organization
)
})
}
markCreditToGroup(studentnumber, passed, grade, groupcode, groupname, coursecode, yearcode) {
if (!this.groups[groupcode]) {
this.initGroup(groupcode, groupname, coursecode, yearcode)
}
this.markCreditToAttempts(studentnumber, passed, grade, groupcode)
this.markBestEffortGrade(studentnumber, passed, grade, groupcode)
}
markEnrollmentToGroup(studentnumber, state, enrollment_date_time, groupcode, groupname, coursecode, yearcode) {
if (!this.groups[groupcode]) this.initGroup(groupcode, groupname, coursecode, yearcode)
const enrollment = { studentnumber, state, enrollment_date_time }
this.groups[groupcode].allEnrollments.push(enrollment)
const oldEnrollment = this.groups[groupcode].enrollments.find(e => e.studentnumber === studentnumber)
if (!oldEnrollment) return this.groups[groupcode].enrollments.push({ studentnumber, state, enrollment_date_time })
if (oldEnrollment.state === 'ENROLLED' || oldEnrollment.state === 'CONFIRMED') return
if (state !== 'ENROLLED' || state !== 'CONFIRMED') return
this.groups[groupcode].enrollments = this.groups[groupcode].enrollments
.filter(e => e.studentnumber !== studentnumber)
.concat([enrollment])
}
markCreditToAttempts(studentnumber, passed, grade, groupcode) {
@@ -118,6 +162,19 @@ class CourseYearlyStatsCounter {
}
}
markBestEffortGrade(studentnumber, passed, grade, groupcode) {
const current = this.groups[groupcode].students.grades[studentnumber]
if (!current) {
this.groups[groupcode].students.grades[studentnumber] = { grade, passed }
return
}
if (current.passed && !passed) return
if (current.passed && Number(current.grade) >= Number(grade)) return
this.groups[groupcode].students.grades[studentnumber] = { grade, passed }
}
markCreditToStudentCategories(studentnumber, passed, attainment_date, groupcode) {
if (!this.students.has(studentnumber)) {
if (passed) {
@@ -183,8 +240,17 @@ class CourseYearlyStatsCounter {
}
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
const { students } = rest
const grades = {}
Object.keys(students.grades).forEach(student => {
const { grade, passed } = students.grades[student]
const parsed = passed ? grade : '0'
if (!grades[parsed]) grades[parsed] = []
grades[parsed].push(student)
})
const normalStats = {
...rest,
students: { ...students, grades },
}
if (anonymizationSalt && normalStats.students.studentnumbers.length < 6) {
+123 -8
View File
@@ -8,12 +8,14 @@ const {
Studyright,
Semester,
Organization,
Enrollment,
} = require('../models')
const { parseCredit } = require('./parseCredits')
const Op = Sequelize.Op
const { CourseYearlyStatsCounter } = require('./course_yearly_stats_counter')
const { sortMainCode, getSortRank } = require('../util/utils')
const { parseEnrollment } = require('./parseEnrollments')
const byNameOrCode = (searchTerm, language) =>
Course.findAll({
@@ -124,6 +126,71 @@ const creditsForCourses = async (codes, anonymizationSalt, unification) => {
return parsedCredits
}
const enrollmentsForCourses = async (codes, anonymizationSalt, unification) => {
let is_open = false
if (unification === 'open') is_open = true
if (unification === 'unify') {
is_open = {
[Op.in]: [false, true],
}
}
const enrollments = await Enrollment.findAll({
include: [
{
model: Student,
attributes: ['studentnumber'],
include: {
model: StudyrightElement,
attributes: ['code', 'startdate'],
include: [
{
model: ElementDetail,
attributes: ['name', 'type'],
where: {
type: {
[Op.eq]: 20,
},
},
},
{
model: Studyright,
attributes: ['prioritycode', 'faculty_code'],
where: {
prioritycode: {
[Op.eq]: 1,
},
},
include: {
model: Organization,
},
},
],
},
},
{
model: Semester,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
},
],
where: {
course_code: {
[Op.in]: codes,
},
studentnumber: {
[Op.ne]: null,
},
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
state: ['ENROLLED', 'CONFIRMED', 'REJECTED', 'ABORTED_BY_STUDENT', 'ABORTED_BY_TEACHER'],
[Op.or]: [{ is_open }, { is_open: null }],
},
})
return enrollments.map(enrollment => parseEnrollment(enrollment, anonymizationSalt))
}
const bySearchTerm = async (term, language) => {
const formatCourse = course => ({
name: course.name[language],
@@ -175,8 +242,9 @@ const yearlyStatsOfNew = async (coursecode, separate, unification, anonymization
codes = codes.filter(course => !isOpenUniCourseCode(course))
}
const [credits, course] = await Promise.all([
const [credits, enrollments, course] = await Promise.all([
creditsForCourses(codes, anonymizationSalt, unification),
enrollmentsForCourses(codes, anonymizationSalt, unification),
Course.findOne({
where: {
code: coursecode,
@@ -231,6 +299,25 @@ const yearlyStatsOfNew = async (coursecode, separate, unification, anonymization
counter.markCreditToGroup(studentnumber, passed, grade, groupcode, groupname, coursecode, yearcode)
counter.markCreditToStudentCategories(studentnumber, passed, attainment_date, groupcode)
}
enrollments.forEach(enrollment => {
const { studentnumber, semestercode, semestername, yearcode, yearname, coursecode, state, enrollment_date_time } =
enrollment
const groupcode = separate ? semestercode : yearcode
const groupname = separate ? semestername : yearname
counter.markEnrollmentToGroup(
studentnumber,
state,
enrollment_date_time,
groupcode,
groupname,
coursecode,
yearcode
)
})
const statistics = counter.getFinalStatistics(anonymizationSalt)
return {
@@ -241,7 +328,17 @@ const yearlyStatsOfNew = async (coursecode, separate, unification, anonymization
}
}
const maxYearsToCreatePopulationFrom = async coursecodes => {
const maxYearsToCreatePopulationFrom = async (coursecodes, unifyCourses) => {
let is_open = false
if (unifyCourses === 'openStats') is_open = true
if (unifyCourses === 'unifyStats') {
is_open = {
[Op.in]: [false, true],
}
}
const maxAttainmentDate = new Date(
Math.max(
...(
@@ -256,6 +353,7 @@ const maxYearsToCreatePopulationFrom = async coursecodes => {
).map(c => new Date(c.max_attainment_date).getTime())
)
)
const attainmentThreshold = new Date(maxAttainmentDate.getFullYear(), 0, 1)
attainmentThreshold.setFullYear(attainmentThreshold.getFullYear() - 6)
@@ -267,6 +365,7 @@ const maxYearsToCreatePopulationFrom = async coursecodes => {
attainment_date: {
[Op.gt]: attainmentThreshold,
},
is_open,
},
order: [['attainment_date', 'ASC']],
})
@@ -290,14 +389,12 @@ const maxYearsToCreatePopulationFrom = async coursecodes => {
return maxYearsToCreatePopulationFrom
}
const courseYearlyStats = async (coursecodes, separate, unifyOpenUniCourses, anonymizationSalt) => {
const [unify, reqular, open] = ['unify', 'reqular', 'open']
const courseYearlyStats = async (coursecodes, separate, anonymizationSalt) => {
const statsReqular = await Promise.all(
coursecodes.map(async code => {
const unifyStats = await yearlyStatsOfNew(code, separate, unify, anonymizationSalt)
const reqularStats = await yearlyStatsOfNew(code, separate, reqular, anonymizationSalt)
const openStats = await yearlyStatsOfNew(code, separate, open, anonymizationSalt)
const unifyStats = await yearlyStatsOfNew(code, separate, 'unify', anonymizationSalt)
const reqularStats = await yearlyStatsOfNew(code, separate, 'reqular', anonymizationSalt)
const openStats = await yearlyStatsOfNew(code, separate, 'open', anonymizationSalt)
return { unifyStats, reqularStats, openStats }
})
@@ -389,6 +486,23 @@ const byNameAndOrCodeLike = async (name, code) => {
return { courses }
}
const getCourseProvidersForCourses = async codes => {
return (
await Organization.findAll({
attributes: ['code'],
include: {
model: Course,
where: {
code: {
[Op.in]: codes,
},
},
},
raw: true,
})
).map(({ code }) => code)
}
module.exports = {
byCode,
byName,
@@ -401,4 +515,5 @@ module.exports = {
unifyOpenUniversity,
allCodeAlternatives,
findOneByCode,
getCourseProvidersForCourses,
}
+2 -2
View File
@@ -3,7 +3,7 @@ const { isProduction, pateToken } = require('../conf-backend')
const { ApplicationError } = require('../util/customErrors')
const pateClient = axios.create({
baseURL: 'https://pate.toska.cs.helsinki.fi',
baseURL: 'https://importer.cs.helsinki.fi/api/pate/',
params: {
token: pateToken,
},
@@ -22,7 +22,7 @@ const baseSettings = {
const sendEmail = async (options = {}) => {
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
await pateClient.post('/', options)
return await pateClient.post('/', options)
}
const sendFeedbackToToska = async ({ feedbackContent, user }) => {
@@ -65,4 +65,4 @@ const parseCredit = (credit, anonymizationSalt) => {
}
}
module.exports = { parseCredit }
module.exports = { parseCredit, formatStudyrightElement }
@@ -0,0 +1,48 @@
const crypto = require('crypto')
const { formatStudyrightElement } = require('./parseCredits')
const parseEnrollment = (enrollment, anonymizationSalt) => {
const { student, semester, state, enrollment_date_time, course_code } = enrollment
const { studentnumber, studyright_elements } = student
const { yearcode, yearname, semestercode, name: semestername } = semester
if (anonymizationSalt) {
const anonymizedStudentNumber = crypto
.createHash('sha256')
.update(`${studentnumber}${anonymizationSalt}`)
.digest('hex')
const anonymizedStudent = {
studentnumber: anonymizedStudentNumber,
}
return {
obfuscated: true,
student: anonymizedStudent,
yearcode,
yearname,
semestercode,
semestername,
coursecode: course_code,
state,
enrollment_date_time,
studentnumber: anonymizedStudentNumber,
programmes: studyright_elements.map(formatStudyrightElement),
}
}
return {
student,
yearcode,
yearname,
semestercode,
semestername,
coursecode: course_code,
state,
enrollment_date_time,
studentnumber,
programmes: studyright_elements.map(formatStudyrightElement),
}
}
module.exports = { parseEnrollment }
+51 -58
View File
@@ -10,11 +10,13 @@ const {
Studyright,
StudyrightExtent,
ElementDetail,
Studyplan,
CourseType,
SemesterEnrollment,
Semester,
Transfer,
StudyrightElement,
Enrollment,
} = require('../models')
const {
dbConnections: { sequelize },
@@ -47,6 +49,7 @@ const formatStudentForPopulationStatistics = (
abbreviatedname,
email,
studyrights,
studyplans,
semester_enrollments,
transfers,
updatedAt,
@@ -105,6 +108,7 @@ const formatStudentForPopulationStatistics = (
starting: moment(started).isBetween(startDateMoment, endDateMoment, null, '[]'),
option,
birthdate,
studyplans,
}
}
@@ -217,7 +221,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
'enddate',
'extentcode',
'graduated',
'canceldate',
'active',
'prioritycode',
'faculty_code',
'studystartdate',
@@ -250,6 +254,11 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
},
},
},
{
model: Studyplan,
attributes: ['included_courses', 'programme_code'],
separate: true,
},
],
where: {
studentnumber: {
@@ -361,7 +370,6 @@ const studentnumbersWithAllStudyrightElements = async (
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredOutStudents,
tag,
@@ -385,9 +393,6 @@ const studentnumbersWithAllStudyrightElements = async (
if (!nondegreeStudents) {
filteredExtents.push(33, 99, 14, 13)
}
if (!cancelledStudents) {
studyrightWhere.canceldate = null
}
let studentWhere = {}
if (tag) {
@@ -405,7 +410,7 @@ const studentnumbersWithAllStudyrightElements = async (
}
const students = await Studyright.findAll({
attributes: ['student_studentnumber', 'graduated'],
attributes: ['student_studentnumber', 'graduated', 'enddate'],
include: {
model: StudyrightElement,
attributes: [],
@@ -446,7 +451,7 @@ const studentnumbersWithAllStudyrightElements = async (
raw: true,
})
const studentnumbers = [...new Set(students.map(s => s.student_studentnumber))]
let studentnumbers = [...new Set(students.map(s => s.student_studentnumber))]
// bit hacky solution, but this is used to filter out studentnumbers who have since changed studytracks
const rights = await Studyright.findAll({
@@ -493,13 +498,17 @@ const studentnumbersWithAllStudyrightElements = async (
return acc
}, {})
// Take the newest studytrack primarily by latest starting date in the track, secondarily by the latest enddate
const filteredStudentnumbers = studentnumbers.filter(studentnumber => {
const newestStudytrack = sortBy(formattedStudytracks[studentnumber], ['enddate', 'startdate']).reverse()[0]
const newestStudytrack = sortBy(formattedStudytracks[studentnumber], ['startdate', 'enddate']).reverse()[0]
if (!newestStudytrack) return false
return studyRights.includes(newestStudytrack.code)
})
let studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
// Use the filtered list, if the search includes studytracks
// Then the studyrights length is > 1, which means that there is [studyright, studytrack].
// When searching only for studyprogramme, there is [studyright]
let studentnumberlist = studyRights.length > 1 ? filteredStudentnumbers : studentnumbers
// fetch students that have transferred out of the programme and filter out these studentnumbers
if (!transferredOutStudents) {
@@ -592,13 +601,11 @@ const parseQueryParams = query => {
? `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd[semesters.find(s => s === 'SPRING')]}`
: `${year}-${semesterEnd[semesters.find(s => s === 'FALL')]}`
const exchangeStudents = studentStatuses && studentStatuses.includes('EXCHANGE')
const cancelledStudents = studentStatuses && studentStatuses.includes('CANCELLED')
const nondegreeStudents = studentStatuses && studentStatuses.includes('NONDEGREE')
const transferredStudents = studentStatuses && studentStatuses.includes('TRANSFERRED')
return {
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
// if someone passes something falsy like null as the studyright, remove it so it doesn't break
@@ -800,22 +807,13 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
if (
formattedQueryParams.studentStatuses &&
!formattedQueryParams.studentStatuses.every(
status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
)
) {
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE or TRANSFERRED' }
return { error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' }
}
const {
studyRights,
startDate,
months,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
tag,
} = parseQueryParams(formattedQueryParams)
const { studyRights, startDate, months, endDate, exchangeStudents, nondegreeStudents, transferredStudents, tag } =
parseQueryParams(formattedQueryParams)
// db startdate is formatted to utc so need to change it when querying
const formattedStartDate = new Date(moment.tz(startDate, 'Europe/Helsinki').format()).toUTCString()
@@ -827,7 +825,6 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
formattedStartDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents
)
@@ -856,12 +853,15 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
formattedQueryParams,
optionData
)
return formattedStudents
}
const findCourses = async (studentnumbers, beforeDate) => {
const findCourses = async (studentnumbers, beforeDate, courses = []) => {
const codesFilter = courses.length ? { code: courses } : {}
const res = await Course.findAll({
attributes: ['code', 'name', 'coursetypecode', 'substitutions'],
where: { ...codesFilter },
include: [
{
required: true,
@@ -881,6 +881,19 @@ const findCourses = async (studentnumbers, beforeDate) => {
model: CourseType,
required: true,
},
{
required: false,
model: Enrollment,
attributes: ['course_code', 'studentnumber', 'state', 'enrollment_date_time', 'course_code'],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
enrollment_date_time: {
[Op.lt]: beforeDate,
},
},
},
],
})
return res
@@ -920,15 +933,7 @@ const findMainCourse = async code => {
const bottlenecksOf = async (query, studentnumberlist) => {
const isValidRequest = async (query, params) => {
const {
studyRights,
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
} = params
const { studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
if (!query.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
return { error: 'Semester should be either SPRING OR FALL' }
@@ -936,10 +941,10 @@ const bottlenecksOf = async (query, studentnumberlist) => {
if (
query.studentStatuses &&
!query.studentStatuses.every(
status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
)
) {
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE or TRANSFERRED' }
return { error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' }
}
if (query.selectedStudents) {
const allStudents = await studentnumbersWithAllStudyrightElements(
@@ -947,7 +952,6 @@ const bottlenecksOf = async (query, studentnumberlist) => {
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
query.tag
@@ -961,19 +965,10 @@ const bottlenecksOf = async (query, studentnumberlist) => {
return null
}
const getStudentsAndCourses = async (selectedStudents, studentnumberlist) => {
const getStudentsAndCourses = async (selectedStudents, studentnumberlist, courseCodes) => {
if (!studentnumberlist) {
const {
months,
studyRights,
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
tag,
} = params
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents, tag } =
params
const studentnumbers =
selectedStudents ||
(await studentnumbersWithAllStudyrightElements(
@@ -981,7 +976,6 @@ const bottlenecksOf = async (query, studentnumberlist) => {
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredStudents,
tag
@@ -992,7 +986,7 @@ const bottlenecksOf = async (query, studentnumberlist) => {
return numbers
}, {})
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months))
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
return [allstudents, courses]
} else {
const allstudents = studentnumberlist.reduce((numbers, num) => {
@@ -1005,7 +999,7 @@ const bottlenecksOf = async (query, studentnumberlist) => {
}
const params = parseQueryParams(query)
const [[allstudents, courses], error] = await Promise.all([
getStudentsAndCourses(query.selectedStudents, studentnumberlist),
getStudentsAndCourses(query.selectedStudents, studentnumberlist, query.courses),
isValidRequest(query, params),
])
if (error) return error
@@ -1026,11 +1020,6 @@ const bottlenecksOf = async (query, studentnumberlist) => {
const sortedMaincourse = await findMainCourse(course.code)
if (sortedMaincourse) maincourse = sortedMaincourse
/* if (isOpenUniCourseCode) {
const nonOpen = unifyOpenUniversity(course.code)
const response = await findOneByCode(nonOpen)
*/
if (!stats[maincourse.code]) {
stats[maincourse.code] = new CourseStatsCounter(maincourse.code, maincourse.name, allstudentslength)
}
@@ -1046,6 +1035,10 @@ const bottlenecksOf = async (query, studentnumberlist) => {
const semester = getPassingSemester(startYear, date)
coursestats.markCredit(studentnumber, grade, passingGrade, failingGrade, improvedGrade, semester)
})
course.enrollments.forEach(({ studentnumber, state, enrollment_date_time }) => {
const semester = getPassingSemester(startYear, enrollment_date_time)
coursestats.markEnrollment(studentnumber, state, semester)
})
stats[maincourse.code] = coursestats
}
+3 -2
View File
@@ -1,3 +1,4 @@
const moment = require('moment')
const sequelize = require('sequelize')
const { Semester } = require('../models')
const Op = sequelize.Op
@@ -28,8 +29,8 @@ const getSemestersAndYears = async () => {
acc.years[yearcode] = {
yearcode,
yearname,
startdate: Math.min(acc.years[yearcode].startdate, startdate),
enddate: Math.max(acc.years[yearcode].enddate, enddate),
startdate: moment.min(moment(acc.years[yearcode].startdate), moment(startdate)),
enddate: moment.max(moment(acc.years[yearcode].enddate), moment(enddate)),
}
}
return acc
+54 -21
View File
@@ -12,6 +12,7 @@ const {
ElementDetail,
SemesterEnrollment,
Semester,
Studyplan,
} = require('../models')
const { TagStudent, Tag } = require('../models/models_kone')
const Op = Sequelize.Op
@@ -57,6 +58,10 @@ const byId = async id => {
{
model: SemesterEnrollment,
},
{
model: Studyplan,
attributes: ['included_courses', 'programme_code'],
},
],
}),
TagStudent.findAll({
@@ -99,28 +104,44 @@ const byId = async id => {
return student
}
const findByCourseAndSemesters = async (coursecodes, from, to, separate) =>
const getUnifyStatus = unifyCourses => {
switch (unifyCourses) {
case 'unifyStats':
return [true, false]
case 'openStats':
return [true]
case 'reqularStats':
return [false]
default:
return [true, false]
}
}
const findByCourseAndSemesters = async (coursecodes, from, to, separate, unifyCourses = 'unifyStats') =>
(
await sequelize.query(
`
SELECT
studentnumber
FROM student
INNER JOIN credit ON
student.studentnumber=credit.student_studentnumber
SELECT
studentnumber
FROM student
INNER JOIN credit ON
student.studentnumber=credit.student_studentnumber
WHERE
course_code IN (:coursecodes) AND
attainment_date
course_code IN (:coursecodes) AND
attainment_date
BETWEEN
(select startdate FROM semesters where ${
separate ? 'semestercode' : 'yearcode'
}=:minYearCode ORDER BY semestercode LIMIT 1) AND
(select enddate FROM semesters where ${
separate ? 'semestercode' : 'yearcode'
}=:maxYearCode ORDER BY semestercode DESC LIMIT 1);
`,
(select startdate FROM semesters where ${
separate ? 'semestercode' : 'yearcode'
}=:minYearCode ORDER BY semestercode LIMIT 1) AND
(select enddate FROM semesters where ${separate ? 'semestercode' : 'yearcode'}=:maxYearCode AND
is_open IN (:isOpen)
ORDER BY semestercode DESC LIMIT 1);
`,
{
replacements: { coursecodes, minYearCode: from, maxYearCode: to },
replacements: { coursecodes, minYearCode: from, maxYearCode: to, isOpen: getUnifyStatus(unifyCourses) },
type: sequelize.QueryTypes.SELECT,
raw: true,
}
@@ -140,7 +161,7 @@ const findByTag = async tag => {
).map(st => st.studentnumber)
}
const formatStudent = ({
const formatStudent = async ({
firstnames,
lastname,
studentnumber,
@@ -153,11 +174,12 @@ const formatStudent = ({
studyrights,
semester_enrollments,
transfers,
studyplans,
updatedAt,
createdAt,
tags,
}) => {
const toCourse = ({ id, grade, credits, credittypecode, attainment_date, course, isStudyModule }) => {
const toCourse = ({ id, grade, credits, credittypecode, is_open, attainment_date, course, isStudyModule }) => {
try {
course = course.get()
} catch (e) {
@@ -183,11 +205,13 @@ const formatStudent = ({
credits,
credittypecode,
isStudyModuleCredit: isStudyModule,
isOpenCourse: is_open,
}
}
studyrights = studyrights || []
semester_enrollments = semester_enrollments || []
studyplans = studyplans || []
const semesterenrollments = semester_enrollments.map(
({ semestercode, enrollmenttype, name, yearname, startYear }) => ({
yearname,
@@ -211,6 +235,13 @@ const formatStudent = ({
.map(toCourse)
.filter(c => c.course.name !== 'missing')
const hopsCourses = studyplans.map(sp => sp.included_courses).flat()
const allCourses = await sequelize.query(
'SELECT DISTINCT ON(course_code) course_code, credits FROM credit WHERE course_code IN (:hopsCourses)',
{ replacements: { hopsCourses: hopsCourses.concat('id-to-fill-array') } }
)
return {
firstnames,
lastname,
@@ -225,6 +256,8 @@ const formatStudent = ({
email,
semesterenrollments,
updatedAt: updatedAt || createdAt,
studyplans,
allCourses: allCourses[0],
tags,
}
}
@@ -232,7 +265,7 @@ const formatStudent = ({
const withId = async id => {
try {
const result = await byId(id)
return formatStudent(result)
return await formatStudent(result)
} catch (e) {
logger.error({ message: 'error when fetching single student', meta: e })
return {
@@ -285,7 +318,7 @@ const bySearchTerm = async searchterm => {
[Op.or]: [nameLike(terms), studentnumberLike(terms)],
},
})
return matches.map(formatStudent)
return await Promise.all(matches.map(async s => await formatStudent(s)))
}
const bySearchTermAndStudentNumbers = async (searchterm, studentnumbers) => {
@@ -300,7 +333,7 @@ const bySearchTermAndStudentNumbers = async (searchterm, studentnumbers) => {
},
},
})
return matches.map(formatStudent)
return await Promise.all(matches.map(async s => await formatStudent(s)))
}
const filterStudentnumbersByAccessrights = async (studentnumbers, codes) =>
@@ -1,5 +1,10 @@
const sequelize = require('sequelize')
const { Op } = sequelize
const _ = require('lodash')
const Sequelize = require('sequelize')
const {
dbConnections: { sequelize },
} = require('../database/connection')
const moment = require('moment')
const { Op } = Sequelize
const {
Credit,
Course,
@@ -117,7 +122,7 @@ const absentStudents = async (studytrack, studentnumbers) => {
return students
}
const allStudyrights = async (studytrack, since, studentnumbers) =>
const allStudyrights = async (studytrack, studentnumbers) =>
(
await Studyright.findAll({
include: [
@@ -203,6 +208,40 @@ const graduatedStudyRights = async (studytrack, since, studentnumbers) =>
})
).map(formatStudyright)
const inactiveStudyrights = async (studytrack, studentnumbers) => {
const now = moment(new Date())
const inactiveOrExpired = (
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: {
model: ElementDetail,
required: true,
where: {
code: studytrack,
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
student_studentnumber: whereStudents(studentnumbers),
graduated: 0,
},
})
)
.filter(s => s.active === 0 || (s.enddate && moment(s.enddate).isBefore(now)))
.map(formatStudyright)
return inactiveOrExpired
}
const followingStudyrights = async (since, programmes, studentnumbers) =>
(
await Studyright.findAll({
@@ -239,7 +278,7 @@ const followingStudyrights = async (since, programmes, studentnumbers) =>
})
).map(formatStudyright)
const previousStudyrights = async (since, programmes, studentnumbers) =>
const previousStudyrights = async (programmes, studentnumbers) =>
(
await Studyright.findAll({
include: [
@@ -330,8 +369,8 @@ const getProgrammesStudyrights = async studyprogramme =>
})
).map(formatStudyright)
const getCreditsForStudyProgramme = async (provider, since) =>
await Credit.findAll({
const getCreditsForStudyProgramme = async (provider, since) => {
const res = await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: {
model: Course,
@@ -360,6 +399,94 @@ const getCreditsForStudyProgramme = async (provider, since) =>
},
},
})
return res
}
/* const getStudentsForProgrammeCourses = async provider => {
console.log('provider: ', provider)
const res = await Credit.findAll({
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
include: [
{
model: Course,
attributes: ['code'],
required: true,
where: {
is_study_module: false,
},
include: {
model: Organization,
attributes: [],
// required: true,
where: {
code: provider,
},
},
},
{ model: Student, required: true, attributes: ['firstnames', 'lastname'] },
],
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.between]: ['2020-08-01 00:00:00+00', '2021-07-31 00:00:00+00'],
},
},
})
return res
} */
const getStudentsForProgrammeCourses = async (from, to, providerCode) => {
// console.log('from: ', from)
// console.log('to: ', to)
// console.log('providerCode: ', providerCode)
try {
const res = await Course.findAll({
attributes: ['code', 'id', [sequelize.fn('COUNT', sequelize.col('student_studentnumber')), 'total']],
include: [
{
model: Organization,
attributes: [],
required: true,
where: {
code: providerCode,
},
through: {
attributes: [],
},
},
{
model: Credit,
attributes: [],
// include: [{ model: Student, required: false, attributes: ['lastname'] }],
where: {
credittypecode: {
[Op.notIn]: [10, 9, 7],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.between]: [from, to],
},
},
},
],
raw: true,
group: ['course.code', 'course.id'],
// logging: console.log,
})
const result = res.map(course => ({ ...course, total: parseInt(course.total) }))
return result
} catch (e) {
// eslint-disable-next-line no-console
console.log('error: ', e)
}
}
const getTransferredCredits = async (provider, since) =>
await Credit.findAll({
@@ -423,6 +550,23 @@ const getThesisCredits = async (provider, since, thesisType, studentnumbers) =>
},
})
const getCurrentStudyYearStartDate = _.memoize(
async unixMillis =>
new Date(
(
await sequelize.query(
`
SELECT startdate FROM SEMESTERS s WHERE yearcode = (SELECT yearcode FROM SEMESTERS WHERE startdate < :a ORDER BY startdate DESC LIMIT 1) ORDER BY startdate LIMIT 1;
`,
{
type: sequelize.QueryTypes.SELECT,
replacements: { a: new Date(unixMillis) },
}
)
)[0].startdate
)
)
module.exports = {
studytrackStudents,
enrolledStudents,
@@ -430,6 +574,7 @@ module.exports = {
allStudyrights,
startedStudyrights,
graduatedStudyRights,
inactiveStudyrights,
previousStudyrights,
followingStudyrights,
transfersAway,
@@ -439,4 +584,6 @@ module.exports = {
getCreditsForStudyProgramme,
getTransferredCredits,
getThesisCredits,
getStudentsForProgrammeCourses,
getCurrentStudyYearStartDate,
}
@@ -6,14 +6,10 @@ const {
getYearsArray,
getStartDate,
tableTitles,
alltimeStartDate,
alltimeEndDate,
} = require('./studyprogrammeHelpers')
const {
allStudyrights,
graduatedStudyRights,
startedStudyrights,
transfersAway,
transfersTo,
} = require('./newStudyprogramme')
const { graduatedStudyRights, startedStudyrights, transfersAway, transfersTo } = require('./studyprogramme')
const { getYearStartAndEndDates } = require('../util/semester')
const getStartedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
@@ -43,37 +39,18 @@ const getStartedStats = async ({ studyprogramme, since, years, isAcademicYear, i
const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
await Promise.all(
years.map(async year => {
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate,
endDate,
includeAllSpecials,
})
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate: alltimeStartDate,
endDate: alltimeEndDate,
includeAllSpecials,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
})
)
return { graphStats, tableStats }
}
const getCancelledStats = async ({ studyprogramme, years, isAcademicYear }) => {
const studyrights = await allStudyrights(studyprogramme)
const { graphStats, tableStats } = getStatsBasis(years)
studyrights.forEach(({ canceldate }) => {
if (canceldate !== null && canceldate !== undefined) {
const cancelYear = defineYear(canceldate, isAcademicYear)
graphStats[indexOf(years, cancelYear)] += 1
tableStats[cancelYear] += 1
}
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
return { graphStats, tableStats }
@@ -105,28 +82,25 @@ const getTransferredToStats = async ({ studyprogramme, since, years, isAcademicY
return { graphStats, tableStats }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { includeAllSpecials, isAcademicYear } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
const started = await getStartedStats(queryParameters)
const graduated = await getGraduatedStats(queryParameters)
const cancelled = await getCancelledStats(queryParameters)
const transferredAway = await getTransferredAwayStats(queryParameters)
const transferredTo = await getTransferredToStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['basics'][specialGroups]
const titles = tableTitles['basics'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const tableStats = reversedYears.map(year =>
includeAllSpecials
? [
year,
started.tableStats[year],
graduated.tableStats[year],
cancelled.tableStats[year],
transferredAway.tableStats[year],
transferredTo.tableStats[year],
]
@@ -146,10 +120,6 @@ const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGro
name: 'Graduated',
data: graduated.graphStats,
},
{
name: 'Cancelled',
data: cancelled.graphStats,
},
{
name: 'Transferred away',
data: transferredAway.graphStats,
@@ -0,0 +1,56 @@
const _ = require('lodash')
const { getStudentsForProgrammeCourses, getCurrentStudyYearStartDate } = require('./studyprogramme')
const { mapToProviders } = require('../util/utils')
const getCurrentYearStartDate = () => {
return new Date(new Date().getFullYear(), 0, 1)
}
const makeYearlyCreditsPromises = (currentYear, years, getRange, providerCode) => {
return years.map(
year =>
new Promise(async res => {
const diff = currentYear - year
const { from, to } = getRange(diff)
const studentsByCourse = await getStudentsForProgrammeCourses(from, to, providerCode)
res(
studentsByCourse.map(c => {
c['year'] = year
return c
})
)
})
)
}
const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme, showByYear) => {
const YEAR_TO_MILLISECONDS = 31556952000
const providerCode = mapToProviders([studyprogramme])[0]
const startDate = showByYear === 'true' ? getCurrentYearStartDate() : await getCurrentStudyYearStartDate(unixMillis)
const startYear = startDate.getFullYear()
const startTime = startDate.getTime()
const yearRange = _.range(2017, startYear + 1)
const getRangeAcc = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to:
showByYear === 'true'
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
})
/* const getRangeTotal = diff => ({
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
}) */
const yearlyStudentByCoursePromises = makeYearlyCreditsPromises(startYear, yearRange, getRangeAcc, providerCode)
// console.log('yearly student by course promises: ', yearlyStudentByCoursePromises)
const [yearlyStudentByCourse] = await Promise.all([Promise.all(yearlyStudentByCoursePromises)])
return yearlyStudentByCourse
}
module.exports = { getStudyprogrammeCoursesForStudytrack }
@@ -15,7 +15,7 @@ const {
getCreditsForStudyProgramme,
getTransferredCredits,
allTransfers,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
// Fetches all the credits for the studyprogramme and divides them into major-students and non-major students credits
// Division is done on the basis that whether the student had a primary studyright to the programme on the attainment_date
@@ -51,6 +51,13 @@ const getRegularCreditStats = async ({ studyprogramme, since, years, isAcademicY
}
})
if (majors.graphStats.every(year => year === 0) && nonMajors.graphStats.every(year => year === 0)) {
return {
majors: { graphStats: [], tableStats: {} },
nonMajors: { graphStats: [], tableStats: {} },
}
}
return { majors, nonMajors }
}
@@ -66,14 +73,17 @@ const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcade
tableStats[attainmentYear] += credits || 0
})
if (graphStats.every(year => year === 0)) {
return { graphStats: [], tableStats: {} }
}
return { graphStats, tableStats }
}
// Fetches all credits for the studytrack and combines them into the statistics for the table
// and graph in the studyprogramme overview
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getCreditStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
@@ -82,7 +92,10 @@ const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGr
const transferred = await getTransferredCreditStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['credits'][specialGroups]
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const dataFound = [majors, nonMajors, transferred].some(d => d.graphStats.length)
if (!dataFound) return null
const tableStats = reversedYears.map(year =>
includeAllSpecials
@@ -101,24 +114,26 @@ const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGr
]
)
const graphStats = [
{
name: 'Major students credits',
data: majors.graphStats,
},
{
name: 'Non-major students credits',
data: nonMajors.graphStats,
},
{
name: 'Transferred credits',
data: transferred.graphStats,
},
]
return {
id: studyprogramme,
years,
tableStats,
graphStats: [
{
name: 'Major students credits',
data: majors.graphStats,
},
{
name: 'Non-major students credits',
data: nonMajors.graphStats,
},
{
name: 'Transferred credits',
data: transferred.graphStats,
},
],
graphStats,
titles,
}
}
@@ -7,12 +7,13 @@ const {
getStatsBasis,
defineYear,
getYearsObject,
getAcademicYearsObject,
getYearsArray,
getMedian,
getMean,
getStartDate,
getThesisType,
alltimeStartDate,
alltimeEndDate,
} = require('./studyprogrammeHelpers')
const {
graduatedStudyRights,
@@ -21,31 +22,26 @@ const {
previousStudyrights,
allStudyrights,
allTransfers,
} = require('./newStudyprogramme')
const { getYearStartAndEndDates } = require('../util/semester')
} = require('./studyprogramme')
const { getAllProgrammes } = require('./studyrights')
const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
await Promise.all(
years.map(async year => {
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate,
endDate,
includeAllSpecials,
})
const studentnumbers = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate: alltimeStartDate,
endDate: alltimeEndDate,
includeAllSpecials,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbers)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
studyrights.forEach(({ enddate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
graphStats[indexOf(years, graduationYear)] += 1
tableStats[graduationYear] += 1
})
})
)
return { graphStats, tableStats }
}
@@ -54,60 +50,48 @@ const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, in
const thesisType = getThesisType(studyprogramme)
const { graphStats, tableStats } = getStatsBasis(years)
await Promise.all(
years.map(async year => {
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate,
endDate,
includeAllSpecials,
})
const studentnumbers = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate: alltimeStartDate,
endDate: alltimeEndDate,
includeAllSpecials,
})
const credits = await getThesisCredits(providercode, since, thesisType, studentnumbersOfTheYear)
const credits = await getThesisCredits(providercode, since, thesisType, studentnumbers)
credits?.forEach(({ attainment_date }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
graphStats[indexOf(years, attainmentYear)] += 1
tableStats[attainmentYear] += 1
})
})
)
credits?.forEach(({ attainment_date }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
graphStats[indexOf(years, attainmentYear)] += 1
tableStats[attainmentYear] += 1
})
return { graphStats, tableStats }
}
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
let graduationAmounts = getYearsObject(years)
let graduationTimes = getYearsObject(years, true)
let totalAmounts = isAcademicYear ? getAcademicYearsObject(years) : getYearsObject(years)
let graduationAmounts = getYearsObject({ years })
let graduationTimes = getYearsObject({ years, emptyArrays: true })
await Promise.all(
years.map(async year => {
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate,
endDate,
includeAllSpecials,
})
const studentnumbers = await getCorrectStudentnumbers({
codes: [studyprogramme],
startDate: alltimeStartDate,
endDate: alltimeEndDate,
includeAllSpecials,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
totalAmounts[year] = studentnumbersOfTheYear.length
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbers)
studyrights.forEach(({ enddate, studystartdate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
graduationAmounts[graduationYear] += 1
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
})
})
)
studyrights.forEach(({ enddate, studystartdate }) => {
const graduationYear = defineYear(enddate, isAcademicYear)
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
graduationAmounts[graduationYear] += 1
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
})
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
const comparisonValue = studyprogramme.includes('KH') ? 72 : 48
const medians = getYearsObject(years, true)
const means = getYearsObject(years, true)
const medians = getYearsObject({ years, emptyArrays: true })
const means = getYearsObject({ years, emptyArrays: true })
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
years.forEach(year => {
@@ -122,7 +106,7 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
['', comparisonValue - mean],
]
})
return { medians, means, graduationAmounts, totalAmounts }
return { medians, means, graduationAmounts }
}
// Creates (studyrightid, transfer)-map out of programmes transfers
@@ -151,10 +135,10 @@ const getProgrammesBeforeStarting = async ({ studyprogramme, since, years, isAca
// Get studyrights for bachelor studyprogrammes. This excludes studytracks.
const programmes = await getAllProgrammes()
let studyrightsBeforeMasters = await previousStudyrights(since, programmes, allStudents)
let studyrightsBeforeMasters = await previousStudyrights(programmes, allStudents)
const studyprogrammeCodes = uniqBy(studyrightsBeforeMasters, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject(years) }))
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
// Map transfers to the Master's programme
const transfers = await getTransferStudyrightMap(studyprogramme, since)
@@ -192,7 +176,7 @@ const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAc
let studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject(years) }))
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
const transfers = await getTransferStudyrightMap(studyprogramme, since)
@@ -218,19 +202,23 @@ const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAc
return graphAndTableStats
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getProgrammesBeforeOrAfter = async (studyprogramme, queryParameters) => {
if (studyprogramme.includes('KH')) return await getProgrammesAfterGraduation(queryParameters)
if (studyprogramme.includes('MH')) return await getProgrammesBeforeStarting(queryParameters)
return null
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
const thesis = await getThesisStats(queryParameters)
const graduated = await getGraduatedStats(queryParameters)
const graduationTimeStats = await getGraduationTimeStats(queryParameters)
const programmesBeforeOrAfter = studyprogramme.includes('KH')
? await getProgrammesAfterGraduation(queryParameters)
: await getProgrammesBeforeStarting(queryParameters)
const programmesBeforeOrAfter = await getProgrammesBeforeOrAfter(studyprogramme, queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
@@ -256,9 +244,8 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, speci
graduationMedianTime: graduationTimeStats.medians,
graduationMeanTime: graduationTimeStats.means,
graduationAmounts: graduationTimeStats.graduationAmounts,
totalAmounts: graduationTimeStats.totalAmounts,
programmesBeforeOrAfterTableStats: programmesBeforeOrAfter.tableStats,
programmesBeforeOrAfterGraphStats: programmesBeforeOrAfter.graphStats,
programmesBeforeOrAfterTableStats: programmesBeforeOrAfter?.tableStats,
programmesBeforeOrAfterGraphStats: programmesBeforeOrAfter?.graphStats,
programmesBeforeOrAfterTitles,
}
}
@@ -6,7 +6,6 @@ const { studentnumbersWithAllStudyrightElements } = require('./populations')
const getCorrectStudentnumbers = async ({ codes, startDate, endDate, includeAllSpecials, includeGraduated = true }) => {
let studentnumbers = []
const exchangeStudents = includeAllSpecials
const cancelledStudents = includeAllSpecials
const nondegreeStudents = includeAllSpecials
const transferredOutStudents = includeAllSpecials
const transferredToStudents = !includeAllSpecials
@@ -17,7 +16,6 @@ const getCorrectStudentnumbers = async ({ codes, startDate, endDate, includeAllS
startDate,
endDate,
exchangeStudents,
cancelledStudents,
nondegreeStudents,
transferredOutStudents,
null,
@@ -41,8 +39,8 @@ const formatStudyright = studyright => {
studystartdate,
enddate,
givendate,
canceldate,
graduated,
active,
prioritycode,
extentcode,
student,
@@ -54,8 +52,8 @@ const formatStudyright = studyright => {
studystartdate: defineStartDate(studyright_elements, studystartdate),
enddate,
givendate,
canceldate,
graduated,
active,
prioritycode,
extentcode,
studentnumber: student.studentnumber,
@@ -87,8 +85,10 @@ const formatTransfer = transfer => {
}
}
const getYearsArray = (since, isAcademicYear) => {
const getYearsArray = (since, isAcademicYear, yearsCombined) => {
const years = []
const allYears = 'Total'
if (yearsCombined) years.push(allYears)
for (let i = since; i <= new Date().getFullYear(); i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
@@ -96,7 +96,7 @@ const getYearsArray = (since, isAcademicYear) => {
return years
}
const getYearsObject = (years, emptyArrays = false) => {
const getYearsObject = ({ years, emptyArrays = false }) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
@@ -104,7 +104,7 @@ const getYearsObject = (years, emptyArrays = false) => {
return yearsObject
}
const getAcademicYearsObject = (years, emptyArrays = false) => {
const getAcademicYearsObject = ({ years, emptyArrays = false }) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [`${year} - ${year + 1}`]: emptyArrays ? [] : 0 }
@@ -115,7 +115,7 @@ const getAcademicYearsObject = (years, emptyArrays = false) => {
const getStatsBasis = years => {
return {
graphStats: new Array(years.length).fill(0),
tableStats: getYearsObject(years),
tableStats: getYearsObject({ years }),
}
}
@@ -124,12 +124,10 @@ const isMajorStudentCredit = (studyright, attainment_date) =>
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
studyright.studystartdate && // The student has started studying in the programme
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
studyright.enddate >= attainment_date // Has the credit been attained before the studyright ended
const isSpecialGroupCredit = (studyright, attainment_date, transfers) => {
if (!studyright) return true // If there is no studyright matching the credit, is not a major student credit
if (studyright.canceldate) return true // Cancelled studyrights are in the special groups
if (studyright.studystartdate > attainment_date) return true // Credits before the studyright started are not major student credits
if (studyright.enddate && attainment_date > studyright.enddate) return true // Credits after studyright are not major student credits
if ([7, 9, 16, 34, 33, 99, 14, 13].includes(studyright.extentcode)) return true // Excludes non-degree studyrights and exchange students
@@ -165,6 +163,9 @@ const getStartDate = (studyprogramme, isAcademicYear) => {
return new Date('2017-01-01')
}
const alltimeStartDate = new Date('1900-01-01')
const alltimeEndDate = new Date()
// There are 9 course_unit_types
// 1. urn:code:course-unit-type:regular
// 2. urn:code:course-unit-type:bachelors-thesis
@@ -184,7 +185,7 @@ const getThesisType = studyprogramme => {
return ['urn:code:course-unit-type:bachelors-thesis']
if (/^(T)[0-9]{6}$/.test(studyprogramme))
return ['urn:code:course-unit-type:doctors-thesis', 'urn:code:course-unit-type:licentiate-thesis']
return 'thesis-type-not-defined'
return ['urn:code:course-unit-type:doctors-thesis', 'urn:code:course-unit-type:licentiate-thesis']
}
const getPercentage = (value, total) => {
@@ -217,8 +218,12 @@ const getBachelorCreditGraphStats = years => ({
name: '120-149 credits',
data: getEmptyArray(years.length),
},
mte150: {
name: 'More than 150 credits',
lte180: {
name: '150-179 credits',
data: getEmptyArray(years.length),
},
mte180: {
name: 'More than 180 credits',
data: getEmptyArray(years.length),
},
})
@@ -244,31 +249,74 @@ const getMasterCreditGraphStats = years => ({
name: '260-279 credits',
data: getEmptyArray(years.length),
},
mte280: {
name: 'More than 280 credits',
lte300: {
name: '280-299 credits',
data: getEmptyArray(years.length),
},
mte300: {
name: 'More than 300 credits',
data: getEmptyArray(years.length),
},
})
const getCreditGraphStats = (studyprogramme, years) =>
studyprogramme.includes('KH') ? getBachelorCreditGraphStats(years) : getMasterCreditGraphStats(years)
const getDoctoralCreditGraphStats = years => ({
lte50: {
name: 'Less than 50 credits',
data: getEmptyArray(years.length),
},
lte100: {
name: '50-99 credits',
data: getEmptyArray(years.length),
},
lte150: {
name: '100-149 credits',
data: getEmptyArray(years.length),
},
lte200: {
name: '150-199 credits',
data: getEmptyArray(years.length),
},
lte250: {
name: '200-249 credits',
data: getEmptyArray(years.length),
},
lte300: {
name: '250-299 credits',
data: getEmptyArray(years.length),
},
mte300: {
name: 'More than 300 credits',
data: getEmptyArray(years.length),
},
})
const bachelorCreditThresholds = ['lte30', 'lte60', 'lte90', 'lte120', 'lte150', 'mte150']
const masterCreditThresholds = ['lte200', 'lte220', 'lte240', 'lte260', 'lte280', 'mte280']
const bachelorCreditAmounts = [30, 60, 90, 120, 150, 150]
const masterCreditAmounts = [200, 220, 240, 260, 280, 280]
const getCreditGraphStats = (studyprogramme, years) => {
if (studyprogramme.includes('KH')) return getBachelorCreditGraphStats(years)
if (studyprogramme.includes('MH')) return getMasterCreditGraphStats(years)
return getDoctoralCreditGraphStats(years)
}
const bachelorCreditThresholds = ['lte30', 'lte60', 'lte90', 'lte120', 'lte150', 'lte180', 'mte180']
const masterCreditThresholds = ['lte200', 'lte220', 'lte240', 'lte260', 'lte280', 'lte300', 'mte300']
const doctoralCreditThresholds = ['lte50', 'lte100', 'lte150', 'lte200', 'lte250', 'lte300', 'mte300']
const bachelorCreditAmounts = [30, 60, 90, 120, 150, 180, 180]
const masterCreditAmounts = [200, 220, 240, 260, 280, 300, 300]
const doctoralCreditAmounts = [50, 100, 150, 200, 250, 300, 300]
const getCreditThresholds = studyprogramme => {
if (studyprogramme.includes('KH')) {
return { creditThresholdKeys: bachelorCreditThresholds, creditThresholdAmounts: bachelorCreditAmounts }
}
return { creditThresholdKeys: masterCreditThresholds, creditThresholdAmounts: masterCreditAmounts }
if (studyprogramme.includes('MH')) {
return { creditThresholdKeys: masterCreditThresholds, creditThresholdAmounts: masterCreditAmounts }
}
return { creditThresholdKeys: doctoralCreditThresholds, creditThresholdAmounts: doctoralCreditAmounts }
}
const tableTitles = {
basics: {
SPECIAL_EXCLUDED: ['', 'Started studying', 'Graduated'],
SPECIAL_INCLUDED: ['', 'Started studying', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to'],
SPECIAL_INCLUDED: ['', 'Started studying', 'Graduated', 'Transferred away', 'Transferred to'],
},
credits: {
SPECIAL_EXCLUDED: ['', 'Total', 'Major students credits', 'Transferred credits'],
@@ -283,7 +331,8 @@ const tableTitles = {
'60-89 credits',
'90-119 credits',
'120-149 credits',
'> 150 credits',
'150-179 credits',
'> 180 credits',
],
master: [
'',
@@ -293,7 +342,19 @@ const tableTitles = {
'220-239 credits',
'240-259 credits',
'260-279 credits',
'> 280 credits',
'280-299 credits',
'> 300 credits',
],
doctoral: [
'',
'All',
'< 50 credits',
'50-99 credits',
'100-149 credits',
'150-199 credits',
'200-249 credits',
'250-299 credits',
'> 300 credits',
],
},
studytracks: [
@@ -302,7 +363,7 @@ const tableTitles = {
'Started studying',
'Currently enrolled',
'Absent',
'Cancelled',
'Inactive',
'Graduated',
'Men',
'Women',
@@ -310,8 +371,11 @@ const tableTitles = {
],
}
const getCreditProgressTableTitles = studyprogramme =>
studyprogramme.includes('KH') ? tableTitles.bachelorCreditProgress : tableTitles.masterCreditProgress
const getCreditProgressTableTitles = studyprogramme => {
if (studyprogramme.includes('KH')) return tableTitles.creditProgress.bachelor
if (studyprogramme.includes('MH')) return tableTitles.creditProgress.master
return tableTitles.creditProgress.doctoral
}
module.exports = {
getCorrectStudentnumbers,
@@ -328,10 +392,13 @@ module.exports = {
getMean,
defineYear,
getStartDate,
alltimeStartDate,
alltimeEndDate,
getThesisType,
getPercentage,
getBachelorCreditGraphStats,
getMasterCreditGraphStats,
getDoctoralCreditGraphStats,
getCreditGraphStats,
getCreditThresholds,
tableTitles,
@@ -8,72 +8,103 @@ const {
setGraduationStats,
setStudytrackStats,
} = require('../services/analyticsService')
const logger = require('../util/logger')
const updateBasicView = async code => {
const yearTypes = ['ACADEMIC_YEAR', 'CALENDAR_YEAR']
const special = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED']
const specialCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialExcludedCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
yearTypes.forEach(async yearType => {
special.forEach(async specialGroups => {
const specialExcludedAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
const options = [specialCalendar, specialAcademic, specialExcludedCalendar, specialExcludedAcademic]
for (const option of options) {
try {
const basicStats = await getBasicStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setBasicStats(basicStats, yearType, specialGroups)
await setBasicStats(basicStats, option.yearType, option.specialGroups)
const creditStats = await getCreditStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setCreditStats(creditStats, yearType, specialGroups)
await setCreditStats(creditStats, option.yearType, option.specialGroups)
const graduationStats = await getGraduationStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setGraduationStats(graduationStats, yearType, specialGroups)
})
})
return 'OK'
await setGraduationStats(graduationStats, option.yearType, option.specialGroups)
} catch (e) {
logger.error(e)
}
return 'OK'
}
}
const updateStudytrackView = async code => {
const specialIncluded = 'SPECIAL_INCLUDED'
const specialExcluded = 'SPECIAL_EXCLUDED'
const graduatedIncluded = 'GRADUATED_INCLUDED'
const graduatedExcluded = 'GRADUATED_EXCLUDED'
const specialGraduated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialExcludedGraduated = {
graduated: 'GRADUATED_INCLUDED',
specialGroups: 'SPECIAL_EXCLUDED',
}
const specialGraduatedExcluded = {
graduated: 'GRADUATED_EXCLUDED',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialExcludedGraduatedExcluded = {
graduated: 'GRADUATED_EXCLUDED',
specialGroups: 'SPECIAL_EXCLUDED',
}
const statsSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsSpecialAndGraduated, graduatedIncluded, specialIncluded)
const options = [
specialGraduated,
specialExcludedGraduated,
specialGraduatedExcluded,
specialExcludedGraduatedExcluded,
]
const statsSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsSpecialNotGraduated, graduatedExcluded, specialIncluded)
const statsNotSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsNotSpecialAndGraduated, graduatedIncluded, specialExcluded)
const statsNotSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsNotSpecialNotGraduated, graduatedExcluded, specialExcluded)
for (const option of options) {
try {
const stats = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
settings: {
specialGroups: option.specialGroups === 'SPECIAL_INCLUDED',
graduated: option.graduated === 'GRADUATED_INCLUDED',
},
})
await setStudytrackStats(stats, option.graduated, option.specialGroups)
} catch (e) {
logger.error(e)
}
}
return 'OK'
}
@@ -9,20 +9,21 @@ const {
getYearsArray,
getPercentage,
getYearsObject,
getAcademicYearsObject,
getCorrectStudentnumbers,
getCreditThresholds,
getCreditGraphStats,
tableTitles,
getCreditProgressTableTitles,
} = require('./studyprogrammeHelpers')
const {
studytrackStudents,
allStudyrights,
startedStudyrights,
inactiveStudyrights,
enrolledStudents,
absentStudents,
graduatedStudyRights,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
const { getAcademicYearDates } = require('../util/semester')
const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
@@ -38,7 +39,8 @@ const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
data[thresholdKeys[2]] += creditcount >= thresholdAmounts[1] && creditcount < thresholdAmounts[2] ? 1 : 0
data[thresholdKeys[3]] += creditcount >= thresholdAmounts[2] && creditcount < thresholdAmounts[3] ? 1 : 0
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[5] ? 1 : 0
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[6] ? 1 : 0
})
return data
}
@@ -79,8 +81,8 @@ const getGraduationTimeStats = async ({
// Goes through the programme and all its studytracks for the said year and adds the wanted stats to the data objects
const getStudytrackDataForTheYear = async ({
studyprogramme,
special,
includeGraduated,
since,
settings,
studytracks,
studytrackNames,
year,
@@ -89,6 +91,9 @@ const getStudytrackDataForTheYear = async ({
creditThresholdKeys,
creditThresholdAmounts,
}) => {
const { specialGroups: includeAllSpecials, graduated: includeGraduated } = settings
const { startDate, endDate } = getAcademicYearDates(year, since)
const {
mainStatsByYear,
mainStatsByTrack,
@@ -100,7 +105,6 @@ const getStudytrackDataForTheYear = async ({
totalAmounts,
emptyTracks,
} = data
const { startDate, endDate } = getAcademicYearDates(year)
await Promise.all(
studytracks.map(async track => {
@@ -110,19 +114,19 @@ const getStudytrackDataForTheYear = async ({
codes,
startDate,
endDate,
includeAllSpecials: special,
includeAllSpecials,
includeGraduated,
})
// Get all the studyrights and students for the calculations
const all = await allStudyrights(track, null, studentnumbers)
const all = await allStudyrights(track, studentnumbers)
const students = await studytrackStudents(studentnumbers)
const studentData = getStudentData(students, creditThresholdKeys, creditThresholdAmounts)
const started = await startedStudyrights(track, startDate, studentnumbers)
const enrolled = await enrolledStudents(track, startDate, studentnumbers)
const absent = await absentStudents(track, studentnumbers)
const cancelled = all.filter(s => s.canceldate !== null && s.canceldate !== undefined)
const graduated = await graduatedStudyRights(track, null, studentnumbers)
const inactive = await inactiveStudyrights(track, studentnumbers)
const graduated = await graduatedStudyRights(track, startDate, studentnumbers)
// If the track has no stats for that year, it should be removed from the table and dropdown options
if (all.length === 0) {
@@ -134,17 +138,18 @@ const getStudytrackDataForTheYear = async ({
creditThresholdKeys.forEach(
threshold => (creditGraphStats[track][threshold].data[indexOf(years, year)] = studentData[threshold])
)
// Count stats for the credit progress table for the year per track
creditTableStats[track] = [
...creditTableStats[track],
[`${year} - ${year + 1}`, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
[year, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
]
// Count stats for the main studytrack table grouped by tracks
mainStatsByTrack[track] = [
...mainStatsByTrack[track],
[
`${year} - ${year + 1}`,
year,
all.length,
getPercentage(all.length, all.length),
started.length,
@@ -153,8 +158,8 @@ const getStudytrackDataForTheYear = async ({
getPercentage(enrolled.length, all.length),
absent.length,
getPercentage(absent.length, all.length),
cancelled.length,
getPercentage(cancelled.length, all.length),
inactive.length,
getPercentage(inactive.length, all.length),
graduated.length,
getPercentage(graduated.length, all.length),
studentData.male,
@@ -170,9 +175,7 @@ const getStudytrackDataForTheYear = async ({
mainStatsByYear[year] = [
...mainStatsByYear[year],
[
studytrackNames[track]?.name['fi']
? `${studytrackNames[track]?.name['fi']}, ${track}`
: `${year} - ${year + 1}`,
studytrackNames[track]?.name['fi'] ? `${studytrackNames[track]?.name['fi']}, ${track}` : year,
all.length,
getPercentage(all.length, all.length),
started.length,
@@ -181,8 +184,8 @@ const getStudytrackDataForTheYear = async ({
getPercentage(enrolled.length, all.length),
absent.length,
getPercentage(absent.length, all.length),
cancelled.length,
getPercentage(cancelled.length, all.length),
inactive.length,
getPercentage(inactive.length, all.length),
graduated.length,
getPercentage(graduated.length, all.length),
studentData.male,
@@ -195,9 +198,9 @@ const getStudytrackDataForTheYear = async ({
]
// Count stats for the graduation time charts grouped by year
totalAmounts[track][`${year} - ${year + 1}`] = await all.length
totalAmounts[track][year] = await all.length
await getGraduationTimeStats({
year: `${year} - ${year + 1}`,
year,
graduated,
studyprogramme,
track,
@@ -224,7 +227,7 @@ const getStudytrackOptions = (studyprogramme, studytrackNames, studytracks, empt
// Creates empty objects for each statistic type, which are then updated with the studytrack data
const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
const mainStatsByYear = getYearsObject(years, true)
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
const mainStatsByTrack = {}
const creditGraphStats = {}
const creditTableStats = {}
@@ -238,10 +241,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
mainStatsByTrack[track] = []
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years)
creditTableStats[track] = []
graduationMedianTime[track] = getAcademicYearsObject(years, true)
graduationMeanTime[track] = getAcademicYearsObject(years, true)
graduationAmounts[track] = getAcademicYearsObject(years)
totalAmounts[track] = getAcademicYearsObject(years)
graduationMedianTime[track] = getYearsObject({ years, emptyArrays: true })
graduationMeanTime[track] = getYearsObject({ years, emptyArrays: true })
graduationAmounts[track] = getYearsObject({ years })
totalAmounts[track] = getYearsObject({ years })
})
return {
@@ -258,12 +261,11 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
}
// Combines all the data for the Populations and Studytracks -view
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated, specialGroups }) => {
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, settings }) => {
const isAcademicYear = true
const special = specialGroups === 'SPECIAL_INCLUDED'
const includeGraduated = graduated === 'GRADUATED_INCLUDED'
const includeYearsCombined = true
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear())
const years = getYearsArray(since.getFullYear(), isAcademicYear, includeYearsCombined)
const associations = await getAssociations()
const studytracks = associations.programmes[studyprogramme]
@@ -279,8 +281,8 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
years.map(async year => {
return await getStudytrackDataForTheYear({
studyprogramme,
special,
includeGraduated,
since,
settings,
studytracks,
studytrackNames,
year,
@@ -296,19 +298,12 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
return {
id: studyprogramme,
years: getYearsArray(since.getFullYear(), isAcademicYear),
mainStatsByTrack: data.mainStatsByTrack,
mainStatsByYear: data.mainStatsByYear,
creditTableStats: data.creditTableStats,
creditGraphStats: data.creditGraphStats,
graduationMedianTime: data.graduationMedianTime,
graduationMeanTime: data.graduationMeanTime,
graduationAmounts: data.graduationAmounts,
totalAmounts: data.totalAmounts,
years: getYearsArray(since.getFullYear(), isAcademicYear, includeYearsCombined),
...data,
studytrackOptions,
includeGraduated,
includeGraduated: settings.graduated,
populationTitles: tableTitles['studytracks'],
creditTableTitles: tableTitles['creditProgress'][studyprogramme.includes('KH') ? 'bachelor' : 'master'],
creditTableTitles: getCreditProgressTableTitles(studyprogramme),
}
}
-29
View File
@@ -1,29 +0,0 @@
const { ThesisCourse } = require('../models/models_kone')
const createThesisCourse = (programme, course, type) =>
ThesisCourse.create({
programmeCode: programme,
courseCode: course,
thesisType: type,
})
const deleteThesisCourse = (programme, course) =>
ThesisCourse.destroy({
where: {
programmeCode: programme,
courseCode: course,
},
})
const findProgrammeTheses = programme =>
ThesisCourse.findAll({
where: {
programmeCode: programme,
},
})
module.exports = {
createThesisCourse,
deleteThesisCourse,
findProgrammeTheses,
}
+3 -1
View File
@@ -107,7 +107,9 @@ const findTopTeachers = async yearcode => {
.filter(isRegularCourse)
.map(credit => {
const { credits, course, credittypecode } = credit
const teachers = credit.teachers.map(({ id, name }) => ({ id, name }))
const teachers = credit.teachers
.filter(({ id }) => !id.includes('hy-hlo-org')) // Remove faculties from the leaderboards
.map(({ id, name }) => ({ id, name }))
const passed = Credit.passed(credit) || Credit.improved(credit)
const failed = Credit.failed(credit)
const transferred = credittypecode === 9
+11 -2
View File
@@ -84,7 +84,7 @@ const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNon
studyright.student_studentnumber studentnumber,
credit.credits credits,
CASE
WHEN studyright.canceldate IS NOT NULL AND studyright.graduated != 1 THEN 1
WHEN studyright.graduated = 0 AND (studyright.active = 0 OR studyright.enddate < NOW()) THEN 1
ELSE 0
END currently_cancelled
FROM
@@ -149,7 +149,16 @@ const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNon
studyright.extentcode = 1 -- Bachelor's
AND org.code NOT IN ('01', 'H02955')
AND studyright.prioritycode IN (1, 30) -- Primary or Graduated
AND studyright.studystartdate IN ('2017-08-01 00:00:00+00', '2018-08-01 00:00:00+00', '2019-08-01 00:00:00+00')
AND studyright.studystartdate = ANY (
SELECT MAKE_TIMESTAMPTZ(generate_series, 8, 1, 0, 0, 0)
FROM generate_series(
2017,
CASE
WHEN DATE_PART('month', NOW()) < 8 THEN DATE_PART('year', NOW())::integer - 2
ELSE DATE_PART('year', NOW())::integer - 1
END
)
)
AND element_details.type IN (20,30) -- programme
${!!codes && codes.length > 0 ? 'AND element_details.code IN (:codes)' : ''}
AND transfers.studyrightid IS NULL -- Not transferred within faculty
@@ -284,6 +284,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
})
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
startYear,
yearRange,
@@ -97,7 +97,7 @@ const calculateStatusGraduated = async (unixMillis, showByYear) => {
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
}
}
if (!acc[curr.faculty_code]['yearly'][data.year])
if (!acc[curr.faculty_code]?.['yearly'][data.year])
acc[curr.faculty_code]['yearly'][data.year] = { acc: 0, total: 0 }
// init programme
if (!acc[curr.faculty_code]['drill'][curr.code]) {
+12 -3
View File
@@ -29,10 +29,19 @@ const getPassingSemester = (startYear, date) => {
return yearCount < 6 ? `${yearCount}-${semester}` : 'LATER'
}
const getAcademicYearDates = year => {
const getAcademicYearDates = (year, since) => {
if (year === 'Total') {
return {
startDate: `${since.getFullYear()}-${semesterStart['FALL']}`,
endDate: `${moment(new Date()).add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
}
}
const startYear = typeof year === 'number' ? year : Number(year.slice(0, 4))
const endYear =
typeof year === 'number' ? moment(year, 'YYYY').add(1, 'years').format('YYYY') : Number(year.slice(7, 11))
return {
startDate: `${year}-${semesterStart['FALL']}`,
endDate: `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
startDate: `${startYear}-${semesterStart['FALL']}`,
endDate: `${endYear}-${semesterEnd['SPRING']}`,
}
}
+7 -2
View File
@@ -1,5 +1,5 @@
const mapToProviders = elementDetails =>
elementDetails.map(r => {
const mapToProviders = elementDetails => {
return elementDetails.map(r => {
const isNumber = str => !Number.isNaN(Number(str))
if (r.includes('_')) {
const [left, right] = r.split('_')
@@ -8,8 +8,13 @@ const mapToProviders = elementDetails =>
const providercode = `${prefix}0-${suffix}`
return providercode
}
if (/^(T)[0-9]{6}$/.test(r)) {
const numbers = r.substring(1)
return `7${numbers}`
}
return r
})
}
// sort substitutions so that main code is first
const newLetterBasedCode = /^[A-Za-z]/ // new letter based codes come first
+194 -194
View File
@@ -1908,26 +1908,19 @@
}
},
"@popperjs/core": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz",
"integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA=="
},
"@reduxjs/toolkit": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.6.2.tgz",
"integrity": "sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.8.0.tgz",
"integrity": "sha512-cdfHWfcvLyhBUDicoFwG1u32JqvwKDxLxDd7zSmSoFw/RhYLOygIRtmaMjPRUUHmVmmAGAvquLLsKKU/677kSQ==",
"requires": {
"immer": "^9.0.6",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
"dependencies": {
"immer": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz",
"integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ=="
}
"immer": "^9.0.7",
"redux": "^4.1.2",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5"
}
},
"@rollup/plugin-node-resolve": {
@@ -1978,13 +1971,13 @@
}
},
"@sentry/browser": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.9.0.tgz",
"integrity": "sha512-4JnEPcwoNs6JqeEd4wscBq+hxpotEJ0DJ4eOIsaNZIMyqEHXBHTXCk/gfrSsiZFrkHM4PgvUHOxaC0HcZ92oBA==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.18.2.tgz",
"integrity": "sha512-EsqKSNboi2gOiMuEwQranLucxrARi00y2vgUnaPXcqTKTlVlHDetoWHvq8/r29idA1JHGka5tDrwrmWccWIkrg==",
"requires": {
"@sentry/core": "6.9.0",
"@sentry/types": "6.9.0",
"@sentry/utils": "6.9.0",
"@sentry/core": "6.18.2",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
@@ -1996,14 +1989,14 @@
}
},
"@sentry/core": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.9.0.tgz",
"integrity": "sha512-oFX2qQcMLujCeIuCQGlhpTUIOXiU5n6V2lqDnvMXUV8gKpplBPalwdlR9bgbSi+VO8u7LjHR1IKM0RAPWgNHWw==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.18.2.tgz",
"integrity": "sha512-r5ad/gq5S/JHc9sd5CUhZQT9ojQ+f+thk/AoGeGawX/8HURZYAgIqD565d6FK0VsZEDkdRMl58z1Qon20h3y1g==",
"requires": {
"@sentry/hub": "6.9.0",
"@sentry/minimal": "6.9.0",
"@sentry/types": "6.9.0",
"@sentry/utils": "6.9.0",
"@sentry/hub": "6.18.2",
"@sentry/minimal": "6.18.2",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
@@ -2015,12 +2008,12 @@
}
},
"@sentry/hub": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.9.0.tgz",
"integrity": "sha512-5mors7ojbo7G85ZmoVPQBgFBMONAJwyZfV0LNLy14GenoaVNuxTPyvAQiJb1FYq+x6YZ3CvqGX6r74KRKQU87w==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.18.2.tgz",
"integrity": "sha512-d0AugekMkbnN12b4EXMjseJxtLPc9S20DGobCPUb4oAQT6S2oDQEj1jwP6PQ5vtgyy+GMYWxBMgqAQ4pjVYISQ==",
"requires": {
"@sentry/types": "6.9.0",
"@sentry/utils": "6.9.0",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
@@ -2032,12 +2025,12 @@
}
},
"@sentry/minimal": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.9.0.tgz",
"integrity": "sha512-GBZ6wG2Rc1wInYEl2BZTZc/t57O1Da876ifLsSPpEQAEnGWbqZWb8RLjZskH09ZIL/K4XCIDDi5ySzN8kFUWJw==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.18.2.tgz",
"integrity": "sha512-n7KYuo34W2LxE+3dnZ47of7XHuORINCnXq66XH72eoj67tf0XeWbIhEJrYGmoLRyRfoCYYrBLWiDl/uTjLzrzQ==",
"requires": {
"@sentry/hub": "6.9.0",
"@sentry/types": "6.9.0",
"@sentry/hub": "6.18.2",
"@sentry/types": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
@@ -2049,51 +2042,17 @@
}
},
"@sentry/tracing": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.11.0.tgz",
"integrity": "sha512-9VA1/SY++WeoMQI4K6n/sYgIdRtCu9NLWqmGqu/5kbOtESYFgAt1DqSyqGCr00ZjQiC2s7tkDkTNZb38K6KytQ==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-6.18.2.tgz",
"integrity": "sha512-hg6NLqrqJ5sUPTyWEQ2RqdnhQVnyLtx8II0IyWxQLDWD8UCe3Mu6G7mroDtakPWcP+lWz6OnKfMEfuhMcxR8fw==",
"requires": {
"@sentry/hub": "6.11.0",
"@sentry/minimal": "6.11.0",
"@sentry/types": "6.11.0",
"@sentry/utils": "6.11.0",
"@sentry/hub": "6.18.2",
"@sentry/minimal": "6.18.2",
"@sentry/types": "6.18.2",
"@sentry/utils": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/hub": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.11.0.tgz",
"integrity": "sha512-pT9hf+ZJfVFpoZopoC+yJmFNclr4NPqPcl2cgguqCHb69DklD1NxgBNWK8D6X05qjnNFDF991U6t1mxP9HrGuw==",
"requires": {
"@sentry/types": "6.11.0",
"@sentry/utils": "6.11.0",
"tslib": "^1.9.3"
}
},
"@sentry/minimal": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.11.0.tgz",
"integrity": "sha512-XkZ7qrdlGp4IM/gjGxf1Q575yIbl5RvPbg+WFeekpo16Ufvzx37Mr8c2xsZaWosISVyE6eyFpooORjUlzy8EDw==",
"requires": {
"@sentry/hub": "6.11.0",
"@sentry/types": "6.11.0",
"tslib": "^1.9.3"
}
},
"@sentry/types": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.11.0.tgz",
"integrity": "sha512-gm5H9eZhL6bsIy/h3T+/Fzzz2vINhHhqd92CjHle3w7uXdTdFV98i2pDpErBGNTSNzbntqOMifYEB5ENtZAvcg=="
},
"@sentry/utils": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.11.0.tgz",
"integrity": "sha512-IOvyFHcnbRQxa++jO+ZUzRvFHEJ1cZjrBIQaNVc0IYF0twUOB5PTP6joTcix38ldaLeapaPZ9LGfudbvYvxkdg==",
"requires": {
"@sentry/types": "6.11.0",
"tslib": "^1.9.3"
}
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -2102,16 +2061,16 @@
}
},
"@sentry/types": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.9.0.tgz",
"integrity": "sha512-v52HJqLoLapEnqS2NdVtUXPvT+aezQgNXQkp8hiQ3RUdTm5cffwBVG7wlbpE6OsOOIZxd6p1zKylFkwCypiIIA=="
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.18.2.tgz",
"integrity": "sha512-WzpJf/Q5aORTzrSwer/As1NlO90dBAQpaHV2ikDDKqOyMWEgjKb5/4gh59p9gH8JMMnLetP1AvQel0fOj5UnUw=="
},
"@sentry/utils": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.9.0.tgz",
"integrity": "sha512-PimDr6KAi4cCp5hQZ8Az2/pDcdfhTu7WAU30Dd9MZwknpHSTmD4G6QvkdrB5er6kMMnNQOC7rMo6w/Do3m6X3w==",
"version": "6.18.2",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.18.2.tgz",
"integrity": "sha512-EC619jesknyu4xpwud5WC/5odYLz6JUy7OSFy5405PpdGeh/m8XUvuJAx4zDx0Iz/Mlk0S1Md+ZcQwqkv39dkw==",
"requires": {
"@sentry/types": "6.9.0",
"@sentry/types": "6.18.2",
"tslib": "^1.9.3"
},
"dependencies": {
@@ -2330,9 +2289,9 @@
}
},
"@types/hast": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.2.tgz",
"integrity": "sha512-Op5W7jYgZI7AWKY5wQ0/QNMzQM7dGQPyW1rXKNiymVCy5iTfdPuGu4HhYNOM2sIv8gUfIuIdcYlXmAepwaowow==",
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
"integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==",
"requires": {
"@types/unist": "*"
}
@@ -2378,9 +2337,9 @@
"integrity": "sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg=="
},
"@types/mdast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.4.tgz",
"integrity": "sha512-gIdhbLDFlspL53xzol2hVzrXAbzt71erJHoOwQZWssjaiouOotf03lNtMmFm9VfFkvnLWccSVjUAZGQ5Kqw+jA==",
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
"integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==",
"requires": {
"@types/unist": "*"
}
@@ -2485,9 +2444,9 @@
}
},
"@types/unist": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.5.tgz",
"integrity": "sha512-wnra4Vw9dopnuybR6HBywJ/URYpYrKLoepBTEtgfJup8Ahoi2zJECPP2cwiXp7btTvOT2CULv87aQRA4eZSP6g=="
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="
},
"@types/webpack": {
"version": "4.41.30",
@@ -3160,9 +3119,9 @@
"integrity": "sha512-99FZt8qS/xukgxU/8daV8WT7wAakqBzt6lF3XCweO6pwcf50/NgxxBj6ZC7/ejR+F4PWeFpkb9lAMH3y2quBXA=="
},
"axios": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
@@ -4087,13 +4046,35 @@
"integrity": "sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ=="
},
"cfb": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.0.tgz",
"integrity": "sha512-sXMvHsKCICVR3Naq+J556K+ExBo9n50iKl6LGarlnvuA2035uMlGA/qVrc0wQtow5P1vJEw9UyrKLCbtIKz+TQ==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.1.tgz",
"integrity": "sha512-wT2ScPAFGSVy7CY+aauMezZBnNrfnaLSrxHUHdea+Td/86vrk6ZquggV+ssBR88zNs0OnBkL2+lf9q0K+zVGzQ==",
"requires": {
"adler-32": "~1.2.0",
"adler-32": "~1.3.0",
"crc-32": "~1.2.0",
"printj": "~1.1.2"
"printj": "~1.3.0"
},
"dependencies": {
"adler-32": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.0.tgz",
"integrity": "sha512-f5nltvjl+PRUh6YNfUstRaXwJxtfnKEWhAWWlmKvh+Y3J2+98a0KKVYDEhz6NdKGqswLhjNGznxfSsZGOvOd9g==",
"requires": {
"printj": "~1.2.2"
},
"dependencies": {
"printj": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.2.3.tgz",
"integrity": "sha512-sanczS6xOJOg7IKDvi4sGOUOe7c1tsEzjwlLFH/zgwx/uyImVM9/rgBkc8AfiQa/Vg54nRd8mkm9yI7WV/O+WA=="
}
}
},
"printj": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.3.1.tgz",
"integrity": "sha512-GA3TdL8szPK4AQ2YnOe/b+Y1jUFwmmGMMK/qbY7VcE3Z7FU8JstbKiKRzO6CIiAKPhTO8m01NoQ0V5f3jc4OGg=="
}
}
},
"chalk": {
@@ -4152,6 +4133,11 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
},
"chroma-js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
"integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
},
"chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@@ -4299,20 +4285,9 @@
}
},
"codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha1-jL4lSBMjVZ19MHVxsP/5HnodL5k=",
"requires": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"dependencies": {
"commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw=="
}
}
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="
},
"collect-v8-coverage": {
"version": "1.0.1",
@@ -4617,12 +4592,19 @@
}
},
"crc-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz",
"integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.1.tgz",
"integrity": "sha512-Dn/xm/1vFFgs3nfrpEVScHoIslO9NZRITWGz/1E/St6u4xw99vfZzVkW0OSnzx2h9egej9xwMCEut6sqwokM/w==",
"requires": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
"printj": "~1.3.1"
},
"dependencies": {
"printj": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.3.1.tgz",
"integrity": "sha512-GA3TdL8szPK4AQ2YnOe/b+Y1jUFwmmGMMK/qbY7VcE3Z7FU8JstbKiKRzO6CIiAKPhTO8m01NoQ0V5f3jc4OGg=="
}
}
},
"create-ecdh": {
@@ -6657,11 +6639,6 @@
"bser": "2.1.1"
}
},
"fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A=="
},
"figgy-pudding": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
@@ -7355,9 +7332,9 @@
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
},
"highcharts": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-9.1.2.tgz",
"integrity": "sha512-RwCnJnxAUCGG4R/WDG+QP/4VJNtOpkQTkXcuf58DjDvm9ZILplSMdZvrbMRC9Y9ecuJQCjjZsiifvyS5E2IBvQ=="
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-9.3.3.tgz",
"integrity": "sha512-QeOvm6cifeZYYdTLm4IxZsXcOE9c4xqfs0z0OJJ0z7hhA9WG0rmcVAyuIp5HBl/znjA/ayYHmpYjBYD/9PG4Fg=="
},
"highcharts-custom-events": {
"version": "3.0.10",
@@ -7741,9 +7718,9 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg=="
},
"immer": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz",
"integrity": "sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA=="
"version": "9.0.12",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz",
"integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA=="
},
"import-cwd": {
"version": "2.1.0",
@@ -10412,9 +10389,9 @@
"optional": true
},
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz",
"integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA=="
},
"nanomatch": {
"version": "1.2.13",
@@ -12351,13 +12328,13 @@
}
},
"prop-types": {
"version": "15.7.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.8.1"
"react-is": "^16.13.1"
},
"dependencies": {
"react-is": {
@@ -12455,9 +12432,9 @@
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
},
"qs": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
"integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
"integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
"requires": {
"side-channel": "^1.0.4"
}
@@ -12565,9 +12542,9 @@
}
},
"react-datetime": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-3.0.4.tgz",
"integrity": "sha512-v6MVwCve+DRaLN2f22LTO5TlrPpkUXumPkp1zfrbhaFtSYGl2grZ2JtwJfLxRj/T4ACyePAV4srCR6cMSiQ/Iw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-3.1.1.tgz",
"integrity": "sha512-gHCTjAniCcMb6jdXpz+MpVe/uCeaHNDOofg+l41nLlJI3uBLBMV40CQbGB2TCTUpCzGT1mCs4vQzKGMjXO/WWQ==",
"requires": {
"prop-types": "^15.5.7"
}
@@ -12734,15 +12711,15 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"react-markdown": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.2.tgz",
"integrity": "sha512-Et2AjXAsbmPP1nLQQRqmVgcqzfwcz8uQJ8VAdADs8Nk/aaUA0YeU9RDLuCtD+GwajCnm/+Iiu2KPmXzmD/M3vA==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.3.tgz",
"integrity": "sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==",
"requires": {
"@types/hast": "^2.0.0",
"@types/unist": "^2.0.3",
"comma-separated-tokens": "^1.0.0",
"prop-types": "^15.7.2",
"property-information": "^5.0.0",
"property-information": "^5.3.0",
"react-is": "^17.0.0",
"remark-parse": "^9.0.0",
"remark-rehype": "^8.0.0",
@@ -12772,6 +12749,11 @@
}
}
},
"react-range": {
"version": "1.8.12",
"resolved": "https://registry.npmjs.org/react-range/-/react-range-1.8.12.tgz",
"integrity": "sha512-vY2eCNDvKlXtEpl8xx5AQVIBeE4ZZ46dLVL+82f4FcS9XrPqz4+XagPHmq3IPqYuVQ2H4b8UArXzvVAsd/B03Q=="
},
"react-redux": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",
@@ -12800,12 +12782,17 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
"integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg=="
},
"react-reverse-portal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.1.0.tgz",
"integrity": "sha512-ck1igNI4tUFozJ89X7qqFtWy1wSL7WEd/5iZbjBjyf+6vjZ3leg4JJxdVPbFt9+zwMLcQSPsIkw9xGu9E+VqCQ=="
},
"react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",
"integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"requires": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
@@ -12817,6 +12804,14 @@
"tiny-warning": "^1.0.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
@@ -12838,17 +12833,27 @@
}
},
"react-router-dom": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz",
"integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"requires": {
"@babel/runtime": "^7.1.2",
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.0",
"react-router": "5.2.1",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
},
"react-scripts": {
@@ -13086,9 +13091,9 @@
}
},
"redux-thunk": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz",
"integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA=="
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
"integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q=="
},
"regenerate": {
"version": "1.4.2",
@@ -13261,9 +13266,9 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"reselect": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.2.tgz",
"integrity": "sha512-wg60ebcPOtxcptIUfrr7Jt3h4BR86cCW3R7y4qt65lnNb4yz4QgrXcbSioVsIOYguyz42+XTHIyJ5TEruzkFgQ=="
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.5.tgz",
"integrity": "sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ=="
},
"resolve": {
"version": "1.18.1",
@@ -13837,9 +13842,9 @@
}
},
"semantic-ui-react": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.0.3.tgz",
"integrity": "sha512-a0hGN6XXw64sRSKwWqMCKSI/AGLohxNeWuErS39eswvBbUnLjBij8ZoEdiqDiz/PuWpwYIRjgmQVrut+7h3b2g==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.2.tgz",
"integrity": "sha512-NG7qMUi2Ab2gPWzOnGyBO9Ao2tjf9MZjjMMPbmg8uEUcEFm+z1QPqCKhW+eYMiZekH8L9azw9M+hzoTIB1lZgQ==",
"requires": {
"@babel/runtime": "^7.10.5",
"@fluentui/react-component-event-listener": "~0.51.6",
@@ -13848,8 +13853,8 @@
"@semantic-ui-react/event-stack": "^3.1.2",
"clsx": "^1.1.1",
"keyboard-key": "^1.1.0",
"lodash": "^4.17.19",
"lodash-es": "^4.17.15",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"prop-types": "^15.7.2",
"react-is": "^16.8.6 || ^17.0.0",
"react-popper": "^2.2.4",
@@ -15343,9 +15348,9 @@
}
},
"unfuck-spa-shibboleth-session": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/unfuck-spa-shibboleth-session/-/unfuck-spa-shibboleth-session-3.1.3.tgz",
"integrity": "sha512-ZchOl94XvUz0eTLfZtx1iuqoKcNFN7WePB9Qz+6vPAkHO+F+H1zBatJo0lLgEdZGm1wqCl7gArrRuilcnsUp/A==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/unfuck-spa-shibboleth-session/-/unfuck-spa-shibboleth-session-3.1.4.tgz",
"integrity": "sha512-eh20O3yTDRlRYmV8DQhELvnwFS78I8mdMUjetdvvIpO/tGSO5mzDooCrF9ssOUxGtCA6VtlcoVBs3FjFSm1Wng==",
"requires": {
"axios": "^0.21.1"
}
@@ -15375,9 +15380,9 @@
"integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg=="
},
"unified": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/unified/-/unified-9.2.1.tgz",
"integrity": "sha512-juWjuI8Z4xFg8pJbnEZ41b5xjGUWGHqXALmBZ3FC3WX0PIx1CZBIIJ6mXbYMcf6Yw4Fi0rFUTA1cdz/BglbOhA==",
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz",
"integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==",
"requires": {
"bail": "^1.0.0",
"extend": "^3.0.0",
@@ -15616,9 +15621,9 @@
}
},
"url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -15629,6 +15634,11 @@
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"use-context-selector": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.3.9.tgz",
"integrity": "sha512-YgzRyeFjoJXwFn2qLVAuIbV6EQ8DOuzu3SS/eiCxyAyvBhcn02jYSz8c5v22QQU3LW6Ez/Iyo62kKvS7Kdqt3A=="
},
"util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -17189,27 +17199,17 @@
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
},
"xlsx": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.17.0.tgz",
"integrity": "sha512-bZ36FSACiAyjoldey1+7it50PMlDp1pcAJrZKcVZHzKd8BC/z6TQ/QAN8onuqcepifqSznR6uKnjPhaGt6ig9A==",
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.17.5.tgz",
"integrity": "sha512-lXNU0TuYsvElzvtI6O7WIVb9Zar1XYw7Xb3VAx2wn8N/n0whBYrCnHMxtFyIiUU1Wjf09WzmLALDfBO5PqTb1g==",
"requires": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"dependencies": {
"commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg=="
}
}
},
"xml-name-validator": {
+18 -14
View File
@@ -6,37 +6,41 @@
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
},
"dependencies": {
"@reduxjs/toolkit": "^1.6.2",
"@sentry/browser": "^6.9.0",
"@sentry/tracing": "^6.11.0",
"axios": "^0.21.2",
"@reduxjs/toolkit": "^1.8.0",
"@sentry/browser": "^6.18.2",
"@sentry/tracing": "^6.18.2",
"axios": "^0.21.4",
"chroma-js": "^2.4.2",
"formik": "^2.2.9",
"highcharts": "^9.1.2",
"highcharts": "^9.3.3",
"highcharts-custom-events": "^3.0.10",
"highcharts-exporting": "^0.1.7",
"history": "^4.10.1",
"immer": "^9.0.7",
"immer": "^9.0.12",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"path-to-regexp": "^0.1.7",
"piwik-react-router": "^0.12.1",
"prop-types": "^15.7.2",
"qs": "^6.10.1",
"prop-types": "^15.8.1",
"qs": "^6.10.3",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-datetime": "^3.0.4",
"react-datetime": "^3.1.1",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.2",
"react-markdown": "^6.0.3",
"react-range": "^1.8.12",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-reverse-portal": "^2.1.0",
"react-router-dom": "^5.3.0",
"react-scripts": "4.0.3",
"react-scroll-to-component": "^1.0.2",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3",
"unfuck-spa-shibboleth-session": "^3.1.3",
"semantic-ui-react": "^2.1.2",
"unfuck-spa-shibboleth-session": "^3.1.4",
"use-context-selector": "^1.3.9",
"uuid": "^3.4.0",
"xlsx": "^0.17.0"
"xlsx": "^0.17.5"
},
"proxy": "http://backend:8080",
"browserslist": {
+1 -1
View File
@@ -107,7 +107,7 @@ export const handleRequest = store => next => async action => {
// All tags used for invalidating cache must be defined here
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: ['StudyGuidanceGroups'],
tagTypes: ['StudyGuidanceGroups', 'Semester'],
baseQuery: fetchBaseQuery({
baseUrl: apiBasePath,
prepareHeaders: headers => {
@@ -6,9 +6,9 @@ export default {
**4 vuoden tahdissa** olevat opiskelijat 45op × ((tämä päivä - aloituspäivä) / 365) ≤ opiskelijan opintopisteet,
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - peruutettu).
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - passiivinen).
**peruutettu**, jos opinto-oikeus merkattu perutuksi
**passiivinen**, jos opinto-oikeus on päättynyt tai opiskelija on laiminlyönyt ilmoittautumisen tälle lukukaudelle, eikä opiskelija ole vielä valmistunut.
**Include only at least once enrolled students** suodattaa pois opiskelijat jotka eivät ole ollenkaan ilmoittautunut.
Vakiona opiskelijat jotka eivät ole ilmoittautunut millekkään lukuvuodelle (läsnä tai poissa olevaksi)
@@ -24,9 +24,9 @@ export default {
**4 vuoden tahdissa** olevat opiskelijat 45op × ((tämä päivä - aloituspäivä) / 365) ≤ opiskelijan opintopisteet,
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - peruutettu).
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - passiivinen).
**peruutettu**, jos opinto-oikeus merkattu perutuksi
**passiivinen**, jos opinto-oikeus on päättynyt tai opiskelija on laiminlyönyt ilmoittautumisen tälle lukukaudelle, eikä opiskelija ole vielä valmistunut.
**Include only at least once enrolled students** suodattaa pois opiskelijat,
jotka eivät ole ollenkaan ilmoittautunut. Vakiona opiskelijat, jotka eivät ole ilmoittautunut millekkään lukuvuodelle (läsnä tai poissa olevaksi) on sisällytetty laskuihin.
@@ -41,11 +41,11 @@ export default {
**4 vuoden tahdissa** olevat opiskelijat 45op × ((tarkastuspäivä - aloituspäivä) / 365) ≤ opiskelijan opintopisteet,
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - peruutettu).
**ei tahdissa** = (kaikki opiskelijat - 3v - 4v - passiivinen).
**Tarkastuspäivät** ovat 31.7, 30.11 ja 1.4 joka vuodelle.
**peruutettu**, jos opinto-oikeus merkattu perutuksi
**passiivinen**, jos opinto-oikeus on päättynyt tai opiskelija on laiminlyönyt ilmoittautumisen tälle lukukaudelle, eikä opiskelija ole vielä valmistunut.
**Include attainments attained before the studyright start** laskee mukaan opiskelijan ennen opinto-oikeuden alkua saadut opintopisteet.
Vakiona opintopisteet ennen opinto-oikeutta suodatetaan pois.
@@ -4,6 +4,11 @@ export default {
short:
'Suodata opiskelijoita sen mukaan ovatko he ilmoittautuneet läsnä- tai poissaoleviksi kyseiselle lukukaudelle',
},
studyrightStatus: {
label: 'Rajaa opiskelijoita opinto-oikeuden statuksen mukaan',
short:
'Opinto-oikeus on aktiivinen, mikäli opiskelija on ilmoittautunut läsnä- tai poissaolevaksi tälle lukukaudelle ja opinto-oikeus ei ole vielä päättynyt. \n Opinto-oikeus on passiivinen, jos opiskelija ei ole ilmoittautunut läsnä- tai poissaolevaksi tälle lukukaudelle tai opinto-oikeus on vanhentunut. Valmistuneet opiskelijat on suodatettu pois molemmista kategorioista.',
},
transferred: {
label: 'Include and exclude students from this program',
short:
@@ -1,7 +1,7 @@
export default {
Advanced: `**Semesters:** Jos haluaa tarkastella vain syyslukukaudella tai kevätlukukaudella ilmoittautuneita, haluttu ajanjakso valitaan tästä.
**Include:** Koulutusohjelmapopulaatiosta on oletusarvoisesti suodatettu pois vaihto-opiskelijat, tutkinnonsuoritusoikeudesta luopuneet ja erillisopinto-oikeudella opiskelevat. Näitä ryhmiä voi sisällyttää mukaan lisäämällä ruksin haluamaansa kohtaan.`,
**Include:** Koulutusohjelmapopulaatiosta on oletusarvoisesti suodatettu pois vaihto-opiskelijat ja erillisopinto-oikeudella opiskelevat. Näitä ryhmiä voi sisällyttää mukaan lisäämällä ruksin haluamaansa kohtaan.`,
QueryCard: `**Valitun koulutusohjelmavuosikurssin perustiedot.**
**Sample size** luku sisältää läsnä- ja poissaolevaksi ilmoittautuneet opiskelijat, muttei "excludes"-ryhmiä, jotka voi määritellä "advanced settings" -kohdasta.
@@ -41,6 +41,12 @@ export default {
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
**Distribution Development**
Pylväsdiagrammi kertoo opiskelijoiden jakauman opintopistehaarukoihin kalenterivuosittain, lukuvuosittain tai lukukausittain eriteltynä. Valmistuneita opiskelijoita ei sisällytetä valmistumisen jälkeisiin pylväisiin.
**Cumulative**-valinnan ollessa käytössä kunkin opiskelijan kaikki aiemmat opintopisteet lasketaan mukaan opintopistemäärään. Muutoin vain pylvästä vastaavalla aikavälillä kirjatut opintopisteet otetaan huomioon.
`,
CoursesOfPopulation: `**Courses of population**
@@ -60,6 +66,7 @@ export default {
**Taulukot**
- **pass/fail** - oletusnäkymä, joka sisältää kurssisuoritukset, hylätyt sekä yritykset. Taulukon tiedot voi järjestää sarakkeita (n, after retry, percentage, many times, per student, passed ja attempted) klikkaamalla.
- **pass/fail w/ enrollments** - näkymä, joka sisältää suoritustiedot ilmoittautumisten kanssa. Huom.! ilmoittautumistiedot ovat saatavilla vain Sisun käyttöönoton jälkeiseltä ajalta.
- **grades** - arvosanajakauma.
- **when passed** - kurssisuorituksen ajankohta. Huom.! *Passed*-sarakkeen kokonaissumma ei välttämättä täsmää puolivuosittaisten lukumäärien summan kanssa johtuen tuplakirjauksista Oodissa.
`,
@@ -4,11 +4,11 @@ export default {
Calendar year: Näyttää tilastot kalenterivuosille jaoteltuna, esimerkiksi 1.1.2019-31.12.2019
`,
StudentToggle: `
All studyrights included: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat, opintonsa keskeyttäneet opiskelijat, ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
All studyrights: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat ja ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
Special studyrights excluded: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
`,
GradToggle: `
GraduatedToggle: `
Graduated included: Valinnassa ovat mukana ohjelmasta jo valmistuneet opiskelijat.
Graduated excluded: Valinnasta on poistettu ohjelmasta jo valmistuneet opiskelijat.
@@ -18,9 +18,7 @@ export default {
**Started studying**: Sisältää kyseisenä vuonna aloittaneet opiskelijat (aloitetut opinto-oikeudet) riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut myöhemmin keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opiskelijat (opinto-oikeudet). Sama opiskelija voi esiintyä tilastoissa useana vuonna, mikäli hän on siirtynyt ohjelmaan ja siitä pois useasti.
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opiskelijat (opinto-oikeudet).
@@ -61,8 +59,10 @@ export default {
HUOM!
**All** Sisältää kaikki opiskelijat, joiden opinto-oikeudella on aloituspäivä kyseisenä vuonna, riippumatta siitä, onko opiskelija aloittanut opiskelemaan ko. vuonna.
**Started studying** Sisältää kaikki opiskelijat, jotka ovat aloittaneet opiskelemaan koulutusohjelmassa kyseisenä lukuvuonna.
**Inactive** Sisältää kaikki opinto-oikeudet, jotka ovat alkaneet kyseisenä vuonna, mutta jotka ovat sittemmin vanhentuneet ilman, että opiskelija on valmistunut ohjelmasta. Sisältää myös opinto-oikeudet,
joissa opiskelija on laiminlyönyt ilmoittautumisen tälle lukukaudelle.
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, tutkinnonsuoritusoikeudesta luopuneet ja erillisopinto-oikeudella opiskelevat.
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, ohjelmaan ja siitä pois siirtyneet opiskelijat sekä erillisopinto-oikeudella opiskelevat.
Taulukko näyttää myös näistä opiskelijoista miesten, naisten ja suomalaisten osuudet sekä valmistuneiden määrän.
Yläosan valikosta on mahdollista valita tarkasteluun yhden opintosuunnan opiskelijat. Sekä opiskelijoiden yleistiedot että edistyminen opintopisteittäin
@@ -70,7 +70,10 @@ export default {
`,
StudytrackProgress: `
Kuvaa koulutusohjelman kyseiseen ohjelmaan opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2020-2021 aloittaneiden opintopistejakaumaa.
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, tutkinnonsuoritusoikeudesta luopuneet ja erillisopinto-oikeudella opiskelevat.
HUOM! Mukana ovat myös muuhun kuin valittuun koulutusohjelmaan tai opintosuuntaan tehdyt opintopisteet.
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, erillisopinto-oikeudella opiskelevat ja ohjelmaan siirtyneet.
Mikäli yläosan valikosta valittuna on jokin tietty opintosuunta, myös nämä graafit ja taulukot kuvaavat kyseisen opintosuunnan opiskelijoita.
`,
+49
View File
@@ -271,6 +271,55 @@ export const useLanguage = () => {
return language
}
export const useDebounce = (value, timeout, onChange) => {
const [innerValue, setInnerValue] = useState(value)
const [dirty, setDirty] = useState(false)
const timeoutRef = useRef(null)
useEffect(() => {
if (!isEqual(value, innerValue)) {
setInnerValue(value)
setDirty(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [value])
const setValue = useCallback(
value => {
setInnerValue(value)
setDirty(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
timeoutRef.current = setTimeout(() => {
onChange(value)
setDirty(false)
}, timeout)
},
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
)
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onChange(innerValue)
setDirty(false)
}, [timeoutRef, onChange, innerValue])
return [innerValue, setValue, flush, dirty]
}
// From: https://www.joshwcomeau.com/snippets/react-hooks/use-toggle/
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
+41 -6
View File
@@ -101,16 +101,46 @@ export const getTextIn = (texts, language) => {
return null
}
export const getUnifyTextIn = unify => {
switch (unify) {
export const getTextInWithOpen = (texts, language, isOpenCourse) => {
const prefixes = ['Avoin', 'Öppna', 'Open']
if (texts) {
const lanText = texts[language] || texts.fi || texts.en || texts.sv || Object.values(texts)[0]
const splitText = lanText.split(':')
if (prefixes.some(word => splitText[0].startsWith(word))) {
splitText.shift()
}
const newText = [...splitText].join()
if (isOpenCourse) {
switch (language) {
case 'fi':
return `${prefixes[0]} yo: ${newText}`
case 'sv':
return `${prefixes[1]} uni: ${newText}`
case 'en':
return `${prefixes[2]} uni: ${newText}`
default:
return `${prefixes[0]} yo: ${newText}`
}
}
return newText
}
return null
}
export const getUnifyTextIn = unifyCourses => {
switch (unifyCourses) {
case 'reqularStats':
return ''
return '(normal)'
case 'openStats':
return 'Open University: '
return '(open)'
case 'unifyStats':
return 'Unified with open uni: '
return '(open and normal)'
default:
return ''
@@ -176,7 +206,12 @@ export const getNewestProgramme = (studyrights, studentNumber, studentToTargetCo
elementDetails[srE.code] &&
elementDetails[srE.code].type === 20 &&
(studentToTargetCourseDateMap && studentNumber
? moment(studentToTargetCourseDateMap[studentNumber]).isBetween(srE.startdate, srE.enddate, 'day', '[]')
? moment(studentToTargetCourseDateMap[studentNumber]).isBetween(
srE.startdate,
srE.enddate || moment(),
'day',
'[]'
)
: true)
)
if (studyrightElements.length > 0) {
@@ -11,12 +11,17 @@ import TSA from '../../common/tsa'
import InfoToolTips from '../../common/InfoToolTips'
import { getProtoC, getProtoCProgramme } from '../../redux/coolDataScience'
import ReactMarkdown from 'react-markdown'
import moment from 'moment'
HighchartsCustomEvents(Highcharts)
const ANALYTICS_CATEGORY = 'Trends'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const upToYear = moment().isBefore({ day: 1, month: 7 })
? moment().year() - 2
: moment().year() - 1
const defaultConfig = (pointer = true) => {
return {
chart: {
@@ -70,9 +75,8 @@ const defaultConfig = (pointer = true) => {
function round(x) {
return Math.round(x * 100) / 100
}
return `${point.index + 1}, ${point.category}, ${point.y}, ${round(point.percentage)}%, ${
point.series.name
}`
return `${point.index + 1}, ${point.category}, ${point.y}, ${round(point.percentage)}%, ${point.series.name
}`
}
}
}
@@ -124,7 +128,7 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
const series = [
{
color: '#7f8c8d',
name: 'tällä hetkellä peruutettu',
name: 'tällä hetkellä passiivinen',
data: sortedData.map(data => ({
y: data.currentlyCancelled,
// pass % of total as z so we can display it in the tooltip
@@ -266,9 +270,8 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
},
yAxis: {
title: {
text: ` ${
org ? `2017-2019 aloittaneet uudet kandiopiskelijat<br/>${org.name}` : '% tiedekunnan opiskelijoista'
}`
text: ` ${org ? `2017-{upToYear} aloittaneet uudet kandiopiskelijat<br/>${org.name}` : '% tiedekunnan opiskelijoista'
}`
}
},
series
@@ -311,7 +314,7 @@ const makeNonClickableChartConfig = programme => {
const series = [
{
color: '#7f8c8d',
name: 'tällä hetkellä peruutettu',
name: 'tällä hetkellä passiivinen',
data: programme.studytracks.map(p => ({
y: p.currentlyCancelled,
// pass % of total as z so we can display it in the tooltip
@@ -347,7 +350,7 @@ const makeNonClickableChartConfig = programme => {
return Highcharts.merge(defaultConfig(false), {
title: {
text: `2017-2019 aloittaneet uudet kandiopiskelijat<br/>${programme.name}`
text: `2017-{upToYear} aloittaneet uudet kandiopiskelijat<br/>${programme.name}`
},
xAxis: {
categories: programme.studytracks.map(data => data.name),
@@ -450,7 +453,7 @@ const sorters = {
'ei tahdissa': (a, b) =>
(countNotInTarget(a) + a.currentlyCancelled) / a.totalStudents -
(countNotInTarget(b) + b.currentlyCancelled) / b.totalStudents,
peruutettu: (a, b) => a.currentlyCancelled / a.totalStudents - b.currentlyCancelled / b.totalStudents
passiivinen: (a, b) => a.currentlyCancelled / a.totalStudents - b.currentlyCancelled / b.totalStudents
}
const OrgChart = React.memo(({ orgs, onOrgClicked }) => {
@@ -606,7 +609,7 @@ const ProtoC = ({
<Icon style={{ marginLeft: '10px', color: '#6ab04c' }} name="circle" size="small" /> 3v tahdissa
<Icon style={{ marginLeft: '10px', color: '#f9ca24' }} name="circle" size="small" /> 4v tahdissa
<Icon style={{ marginLeft: '10px', color: '#ff7979' }} name="circle" size="small" /> ei tahdissa
<Icon style={{ marginLeft: '10px', color: '#7f8c8d' }} name="circle" size="small" /> tällä hetkellä peruutettu
<Icon style={{ marginLeft: '10px', color: '#7f8c8d' }} name="circle" size="small" /> tällä hetkellä passiivinen
</span>
</div>
<div align="center">
@@ -649,7 +652,7 @@ const ProtoC = ({
return (
<Segment>
<div align="center">
<h2>Kandiohjelmat: Suhteellinen tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
<h2>Kandiohjelmat: Suhteellinen tavoiteaikaerittely, 2017-{upToYear} aloittaneet</h2>
</div>
<DrilldownMessage />
<SorterButtons />
@@ -7,6 +7,7 @@ import { Segment, Loader, Dimmer, Checkbox, Button, Message } from 'semantic-ui-
import ReactMarkdown from 'react-markdown'
import HighchartsCustomEvents from 'highcharts-custom-events'
import moment from 'moment'
import TSA from '../../common/tsa'
import InfoToolTips from '../../common/InfoToolTips'
import { getProtoC, getProtoCProgramme } from '../../redux/coolDataScience'
@@ -16,6 +17,8 @@ HighchartsCustomEvents(Highcharts)
const ANALYTICS_CATEGORY = 'Trends'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const upToYear = moment().isBefore({ day: 1, month: 7 }) ? moment().year() - 2 : moment().year() - 1
const defaultConfig = () => {
return {
chart: {
@@ -111,7 +114,7 @@ const makeConfig = (data, sorter, type = 'column', clickHandler) => {
const orgSeries = [
{
color: '#7f8c8d',
name: 'tällä hetkellä peruutettu',
name: 'tällä hetkellä passiivinen',
data: data.map(entry => ({
y: entry.currentlyCancelled,
// pass % of total as z so we can display it in the tooltip
@@ -264,7 +267,7 @@ const sorters = {
'4v tahti': (a, b) => a.students4y - b.students4y,
'3v tahti': (a, b) => a.students3y - b.students3y,
'ei tahdissa': (a, b) => countNotInTarget(a) + a.currentlyCancelled - (countNotInTarget(b) + b.currentlyCancelled),
peruutettu: (a, b) => a.currentlyCancelled - b.currentlyCancelled,
passiivinen: (a, b) => a.currentlyCancelled - b.currentlyCancelled,
}
const ClickableChart = React.memo(({ data, sorter, isSideways, clickHandler }) => {
@@ -423,7 +426,7 @@ const ProtoC = ({
return (
<Segment>
<div align="center">
<h2>Prototyyppi: Tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
<h2>Prototyyppi: Tavoiteaikaerittely, 2017-{upToYear} aloittaneet</h2>
</div>
<div align="center" style={{ marginTop: '10px' }}>
<Button.Group>
@@ -479,7 +482,7 @@ const ProtoC = ({
return (
<Segment>
<div align="center">
<h2>Kandiohjelmat: Tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
<h2>Kandiohjelmat: Tavoiteaikaerittely, 2017-{upToYear} aloittaneet</h2>
</div>
<DrilldownMessage />
<div align="center" style={{ marginTop: '10px' }}>
@@ -22,7 +22,7 @@ const settingDefinitions = _.map(
{
key: 'showByYear',
type: 'checkbox',
defaultValue: false,
defaultValue: true,
},
{
key: 'showYearlyValues',
@@ -10,7 +10,7 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
const [courseGrades, setCourseGrades] = useState([])
useEffect(() => {
if (samples && singleCourseStats.alternatives) {
if (samples && singleCourseStats?.unifyStats?.alternatives) {
const filteredGradeArray = []
const grades = {}
@@ -10,7 +10,7 @@ import { getCoursePopulation } from '../../redux/populations'
import { getSingleCourseStats } from '../../redux/singleCourseStats'
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
import { getFaculties } from '../../redux/faculties'
import { getSemesters } from '../../redux/semesters'
import { useGetSemestersQuery } from '../../redux/semesters'
import { getElementDetails } from '../../redux/elementdetails'
import PopulationStudents from '../PopulationStudents'
import CoursePopulationGradeDist from './CoursePopulationGradeDist'
@@ -36,6 +36,12 @@ import FilterView from '../FilterView'
import useLanguage from '../LanguagePicker/useLanguage'
import { queryParamsFromUrl } from '../../common/query'
const NO_PROGRAMME = {
code: '00000',
name: { en: 'No programme', fi: 'Ei ohjelmaa' },
startdate: '',
}
const CoursePopulation = ({
getCoursePopulationDispatch,
getSingleCourseStatsDispatch,
@@ -44,10 +50,8 @@ const CoursePopulation = ({
pending,
history,
courseData,
getSemestersDispatch,
semesters,
getFacultiesDispatch,
unifyOpen,
unifyCourses,
}) => {
const { language } = useLanguage()
const [codes, setCodes] = useState([])
@@ -75,9 +79,10 @@ const CoursePopulation = ({
studentNumbers: studentData.students ? studentData.students.map(student => student.studentNumber) : [],
})
const { data: semesters = {} } = useGetSemestersQuery()
useEffect(() => {
getElementDetails()
getSemestersDispatch()
}, [])
useEffect(() => {
@@ -100,9 +105,9 @@ const CoursePopulation = ({
useEffect(() => {
if (semesters.years && semesters.semesters) {
const { coursecodes, from, to, years, years2, separate } = queryParamsFromUrl(history.location)
const { coursecodes, from, to, years, years2, separate, unifyCourses } = queryParamsFromUrl(history.location)
const parsedCourseCodes = JSON.parse(coursecodes)
getCoursePopulationDispatch({ coursecodes, from, to, onProgress, separate })
getCoursePopulationDispatch({ coursecodes, from, to, onProgress, separate, unifyCourses })
getSingleCourseStatsDispatch({
fromYear: from,
toYear: to,
@@ -124,8 +129,8 @@ const CoursePopulation = ({
}
}, [semesters])
const avoin = getUnifyTextIn(unifyOpen)
const header = courseData ? `${avoin} ${getTextIn(courseData.name, language)} ${headerYears}` : null
const avoin = getUnifyTextIn(unifyCourses)
const header = courseData ? `${getTextIn(courseData.name, language)} ${headerYears} ${avoin}` : null
const subHeader = codes.join(', ')
@@ -302,7 +307,7 @@ const CoursePopulation = ({
.max()
.value()
return moment(date).isBetween(sre.startdate, sre.enddate, 'day', '[]')
return sre.code === NO_PROGRAMME.code || moment(date).isBetween(sre.startdate, sre.enddate, 'day', '[]')
}
return (
@@ -365,13 +370,12 @@ const CustomPopulationCoursesWrapper = props => {
return <CustomPopulationCourses {...props} courses={courseStatistics} />
}
const mapStateToProps = ({ singleCourseStats, populations, semesters, courseSearch }) => {
const mapStateToProps = ({ singleCourseStats, populations, courseSearch }) => {
return {
studentData: populations.data,
pending: populations.pending,
courseData: singleCourseStats.stats?.[courseSearch.openOrReqular],
unifyOpen: courseSearch.openOrReqular,
semesters: semesters.data,
unifyCourses: courseSearch.openOrReqular,
elementDetails: populations?.data?.elementdetails?.data,
}
}
@@ -380,7 +384,6 @@ export default withRouter(
connect(mapStateToProps, {
getCoursePopulationDispatch: getCoursePopulation,
getSingleCourseStatsDispatch: getSingleCourseStats,
getSemestersDispatch: getSemesters,
getFacultiesDispatch: getFaculties,
getElementDetails,
})(CoursePopulation)
@@ -84,11 +84,13 @@ const CourseTable = ({ course, courseInstance, language, selectedYear }) => {
const FacultyLevelStatistics = () => {
const { language: activeLanguage } = useLanguage()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const { courseStats, language } = useSelector(({ courseStats }) => ({
courseStats: courseStats.data,
language: activeLanguage,
}))
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c.facultyStats))))
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c[openOrReqular].facultyStats))))
.sort()
.reverse()
@@ -103,13 +105,13 @@ const FacultyLevelStatistics = () => {
const yearsCourseStats = Object.values(courseStats)
.map(course => ({
course,
courseInstance: course.facultyStats[selectedYear],
courseInstance: course[openOrReqular].facultyStats[selectedYear],
}))
.sort((a, b) => (a.courseInstance === undefined) - (b.courseInstance === undefined))
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
<CourseTable
course={course}
course={course[openOrReqular]}
courseInstance={courseInstance}
language={language}
key={course.coursecode}
@@ -5,18 +5,11 @@ import { Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { Header, Icon, Item } from 'semantic-ui-react'
import { row } from 'components/SortableTable'
import SortableTable from '../../../../SortableTable'
import { defineCellColor, getGradeSpread, getThesisGradeSpread, isThesisGrades, THESIS_GRADE_KEYS } from '../util'
import { getGradeSpread, getThesisGradeSpread, isThesisGrades, resolveGrades, getSortableColumn } from '../util'
const getSortableColumn = (key, title, getRowVal, getRowContent) => ({
key,
title,
getRowVal,
getRowContent,
getCellProps: s => defineCellColor(s),
})
const getTableData = (stats, notThesisGrades, isRelative) =>
const getTableData = (stats, useThesisGrades, isRelative) =>
stats.map(stat => {
const {
name,
@@ -27,52 +20,44 @@ const getTableData = (stats, notThesisGrades, isRelative) =>
} = stat
const attempts = Object.values(grades).reduce((cur, acc) => acc + cur, 0)
const gradeSpread = notThesisGrades
? getGradeSpread([grades], isRelative)
: getThesisGradeSpread([grades], isRelative)
const gradeSpread = useThesisGrades
? getThesisGradeSpread([grades], isRelative)
: getGradeSpread([grades], isRelative)
return {
const mapped = {
name,
code,
coursecode,
passed: stat.attempts.categories.passed,
failed: stat.attempts.categories.failed,
enrollmentsByState: stat.attempts.enrollmentsByState,
totalEnrollments: stat.attempts.totalEnrollments,
passRate: stat.attempts.passRate,
attempts,
rowObfuscated,
..._.mapValues(gradeSpread, x => x[0]),
}
if (mapped.name === 'Total') {
return row(mapped, { ignoreFilters: true })
}
return mapped
})
const includesHTOrTT = stats =>
stats.some(({ attempts }) => ['HT', 'TT'].some(grade => Object.keys(attempts.grades).includes(grade)))
const getGradeColumns = (notThesisGrades, addHTAndTT) => {
if (!notThesisGrades) return THESIS_GRADE_KEYS.map(k => getSortableColumn(k, k, s => (s.rowObfuscated ? 'NA' : s[k])))
const columns = [
getSortableColumn('0', '0', s => (s.rowObfuscated ? 'NA' : s['0'])),
getSortableColumn('1', '1', s => (s.rowObfuscated ? 'NA' : s['1'])),
getSortableColumn('2', '2', s => (s.rowObfuscated ? 'NA' : s['2'])),
getSortableColumn('3', '3', s => (s.rowObfuscated ? 'NA' : s['3'])),
getSortableColumn('4', '4', s => (s.rowObfuscated ? 'NA' : s['4'])),
getSortableColumn('5', '5', s => (s.rowObfuscated ? 'NA' : s['5'])),
getSortableColumn('OTHER_PASSED', 'Other passed', s => (s.rowObfuscated ? 'NA' : s['Hyv.'])),
]
if (addHTAndTT)
columns.splice(
6,
0,
getSortableColumn('HT', 'HT', s => (s.rowObfuscated ? 'NA' : s.HT)),
getSortableColumn('TT', 'TT', s => (s.rowObfuscated ? 'NA' : s.TT))
)
return columns
}
const getGradeColumns = grades =>
grades.map(({ key, title }) =>
getSortableColumn({
key,
title,
getRowVal: s => (s.rowObfuscated ? 'NA' : s[key] || 0),
})
)
const AttemptsTable = ({
data: { stats, name },
settings: { showGrades },
settings: { showGrades, showEnrollments, separate },
alternatives,
separate,
isRelative,
userHasAccessToAllStats,
headerVisible = false,
@@ -80,7 +65,7 @@ const AttemptsTable = ({
const {
attempts: { grades },
} = stats[0]
const notThesisGrades = !isThesisGrades(grades)
const useThesisGrades = isThesisGrades(grades)
const showPopulation = (yearcode, years) => {
const queryObject = {
@@ -95,46 +80,105 @@ const AttemptsTable = ({
}
const timeColumn = {
...getSortableColumn(
'TIME',
'Time',
s => s.code,
s => (
<div>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
{s.name !== 'Total' && userHasAccessToAllStats && (
key: 'TIME-PARENT',
merge: true,
mergeHeader: true,
children: [
getSortableColumn({
key: 'TIME',
title: 'Time',
filterType: 'range',
cellProps: {},
getRowVal: s => s.code + (2011 - 62),
getRowExportVal: s => s.name,
getRowContent: s => (
<div>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
</div>
),
}),
{
key: 'TIME-ICON',
export: false,
getRowContent: s =>
s.name !== 'Total' &&
userHasAccessToAllStats && (
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
<Icon name="level up alternate" />
</Item>
)}
</div>
)
),
),
},
],
}
let columns = [
timeColumn,
getSortableColumn('ATTEMPTS', 'Total attempts', s => (s.rowObfuscated ? '5 or less students' : s.attempts)),
getSortableColumn('PASSED', 'Passed', s => (s.rowObfuscated ? 'NA' : s.passed)),
getSortableColumn('FAILED', 'Failed', s => (s.rowObfuscated ? 'NA' : s.failed)),
getSortableColumn(
'PASSRATE',
'Pass rate',
s => (s.rowObfuscated ? 'NA' : s.passRate),
s => (s.rowObfuscated ? 'NA' : `${Number(s.passRate || 0).toFixed(2)} %`)
),
getSortableColumn({
key: 'ATTEMPTS',
title: 'Total attempts',
getRowVal: s => (s.rowObfuscated ? '5 or less students' : s.attempts),
}),
getSortableColumn({
key: 'PASSED',
title: 'Passed',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passed),
}),
getSortableColumn({
key: 'FAILED',
title: 'Failed',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.failed),
}),
getSortableColumn({
key: 'PASSRATE',
title: 'Pass rate',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passRate),
getRowContent: s => (s.rowObfuscated ? 'NA' : `${Number(s.passRate || 0).toFixed(2)} %`),
}),
]
if (showGrades) {
if (showGrades)
columns = [
timeColumn,
getSortableColumn('ATTEMPTS', 'Total attempts', s => (s.rowObfuscated ? '5 or less students' : s.attempts)),
...getGradeColumns(notThesisGrades, includesHTOrTT(stats)),
getSortableColumn({
key: 'ATTEMPTS',
title: 'Total attempts',
getRowVal: s => (s.rowObfuscated ? '5 or less students' : s.attempts),
}),
...getGradeColumns(resolveGrades(stats)),
]
if (showEnrollments) {
columns = [
...columns,
getSortableColumn({
key: 'TOTAL_ENROLLMENTS',
title: 'Total enrollments',
helpText: 'All enrollments, including all rejected and aborted states.',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.totalEnrollments),
}),
getSortableColumn({
key: 'ENROLLMENTS_ENROLLED',
title: 'Enrolled',
helpText: 'All enrollments with enrolled or confirmed state.',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.enrollmentsByState.ENROLLED),
}),
getSortableColumn({
key: 'ENROLLMENTS_REJECTED',
title: 'Rejected',
helpText: 'All enrollments with rejected state.',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.enrollmentsByState.REJECTED),
}),
getSortableColumn({
key: 'ENROLLMENTS_ABORTED',
title: 'Aborted',
helpText: 'All enrollments with aborted by student or teacher state.',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.enrollmentsByState.ABORTED),
}),
]
}
const data = getTableData(stats, notThesisGrades, isRelative)
const data = getTableData(stats, useThesisGrades, isRelative)
return (
<div>
@@ -144,9 +188,8 @@ const AttemptsTable = ({
</Header>
)}
<SortableTable
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, style: { width: 'auto' } }}
title={`Yearly attempt statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
columns={columns}
data={data}
/>
@@ -5,12 +5,32 @@ import { Header, Icon, Item } from 'semantic-ui-react'
import { connect } from 'react-redux'
import { uniq } from 'lodash'
import { string, object, arrayOf, bool } from 'prop-types'
import { row } from 'components/SortableTable'
import SortableTable from '../../../../SortableTable'
import { defineCellColor } from '../util'
import { defineCellColor, resolveGrades, getSortableColumn } from '../util'
const formatPercentage = p => `${(p * 100).toFixed(2)} %`
const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate) => {
const getGradeColumns = grades =>
grades.map(({ key, title }) =>
getSortableColumn({
key,
title,
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.grades[key] || 0),
onlyInGradeView: true,
})
)
const getColumns = (
stats,
showDetails,
showEnrollments,
showGrades,
userHasAccessToAllStats,
alternatives,
separate,
unifyCourses
) => {
const showPopulation = (yearcode, years) => {
const queryObject = {
from: yearcode,
@@ -18,6 +38,7 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
coursecodes: JSON.stringify(uniq(alternatives)),
years,
separate,
unifyCourses,
}
const searchString = qs.stringify(queryObject)
return `/coursepopulation?${searchString}`
@@ -25,84 +46,206 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
const columns = [
{
key: 'TIME',
title: 'Time',
getRowVal: s => s.code,
getRowContent: s => (
<div style={{ whiteSpace: 'nowrap' }}>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
{s.name !== 'Total' && userHasAccessToAllStats && (
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
<Icon name="level up alternate" />
</Item>
)}
</div>
),
getCellProps: s => defineCellColor(s),
key: 'TIME-PARENT',
merge: true,
mergeHeader: true,
children: [
{
key: 'TIME',
title: 'Time',
filterType: 'range',
getRowVal: s => s.code + (2011 - 62),
getRowExportVal: s => s.name,
getRowContent: s => (
<div style={{ whiteSpace: 'nowrap' }}>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
</div>
),
},
{
key: 'TIME-ICON',
export: false,
getRowContent: s => (
<>
{s.name !== 'Total' && userHasAccessToAllStats && (
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
<Icon name="level up alternate" />
</Item>
)}
</>
),
},
],
},
{
key: 'TOTAL',
title: 'Total Students',
getRowVal: s => (s.rowObfuscated ? '5 or less students' : s.students.total),
title: showEnrollments ? 'Total Students' : 'Graded students',
helpText: showEnrollments ? 'Total count of students, including enrolled students with no grade.' : null,
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
filterType: 'range',
getRowVal: s => {
if (s.rowObfuscated) return 5
if (showEnrollments) return s.students.withEnrollments.total
return s.students.total
},
getRowContent: s => {
if (s.rowObfuscated) return '5 or less students'
if (showEnrollments) return s.students.withEnrollments.total
return s.students.total
},
getCellProps: s => defineCellColor(s),
},
{
key: 'TOTAL_PASSED',
title: 'Passed',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalPassed || 0),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
hideWhenGradesVisible: true,
},
{
key: 'TOTAL_FAILED',
title: 'Failed',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalFailed || 0),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
hideWhenGradesVisible: true,
},
...getGradeColumns(resolveGrades(stats)),
{
key: 'ENROLLMENTS_MISSING_GRADE',
title: 'Enrolled no grade',
filterType: 'range',
helpText: 'Total count of students with a valid enrollment and no passing or failing grade.',
getRowVal: s => (s.rowObfuscated ? 5 : s.students.enrolledStudentsWithNoGrade),
getRowContent: s => (s.rowObfuscated ? '5 or less students' : s.students.enrolledStudentsWithNoGrade),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
onlyInEnrollmentView: true,
},
{
key: 'PASS_RATE',
title: 'Pass-%',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.passRate),
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.passRate || 0)),
getCellProps: s => defineCellColor(s),
getRowVal: s => {
if (s.rowObfuscated) return 0
if (showEnrollments) return s.students.withEnrollments.passRate * 100
return s.students.passRate * 100
},
getRowContent: s => {
if (s.rowObfuscated) return '5 or less students'
if (showEnrollments) return formatPercentage(s.students.withEnrollments.passRate)
return formatPercentage(s.students.passRate)
},
filterType: 'range',
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
},
{
key: 'PASS_FIRST',
title: 'On First Attempt',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedFirst || 0),
getCellProps: s => defineCellColor(s),
headerProps: { style: { borderLeft: '0' } },
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
onlyInDetailedView: true,
},
{
key: 'PASS_EVENTUALLY',
title: 'Eventually',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedEventually || 0),
getCellProps: s => defineCellColor(s),
headerProps: { style: { borderLeft: '0' } },
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
onlyInDetailedView: true,
},
{
key: 'FAIL_RATE',
title: 'Fail-%',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.failRate),
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : (s.students.failRate || 0) * 100),
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.failRate || 0)),
getCellProps: s => defineCellColor(s),
},
{
key: 'NEVER PASSED',
title: 'Yet to Pass',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.neverPassed || 0),
getCellProps: s => defineCellColor(s),
headerProps: { style: { borderLeft: '0' } },
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
onlyInDetailedView: true,
},
]
return columns.filter(column => showDetails || !column.onlyInDetailedView)
return columns.filter(column => {
if (showDetails && column.onlyInDetailedView) return true
if (showEnrollments && column.onlyInEnrollmentView) return true
if (showGrades && column.onlyInGradeView) return true
if (showGrades && column.hideWhenGradesVisible) return false
return !column.onlyInDetailedView && !column.onlyInEnrollmentView && !column.onlyInGradeView
})
}
const StudentTable = ({
data: { name, stats },
settings: { showDetails },
settings: { showDetails, showEnrollments, separate, showGrades },
alternatives,
separate,
unifyCourses,
userHasAccessToAllStats,
headerVisible = false,
}) => {
const columns = useMemo(
() => getColumns(showDetails, userHasAccessToAllStats, alternatives, separate),
[showDetails, userHasAccessToAllStats, alternatives, separate]
() =>
getColumns(
stats,
showDetails,
showEnrollments,
showGrades,
userHasAccessToAllStats,
alternatives,
separate,
unifyCourses
),
[stats, showDetails, showEnrollments, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses]
)
const data = stats.map(stats => {
if (stats.name === 'Total') {
return row(stats, { ignoreFilters: true })
}
return stats
})
return (
<div>
{headerVisible && (
@@ -111,11 +254,13 @@ const StudentTable = ({
</Header>
)}
<SortableTable
title={`Student statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, fixed: true, style: { width: 'auto' } }}
tableProps={{ celled: true, fixed: true }}
columns={columns}
data={stats}
data={data}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or less are NOT included in the total</span>
@@ -1,21 +1,15 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Menu, Radio, Form } from 'semantic-ui-react'
import { useSelector } from 'react-redux'
import { Menu, Radio } from 'semantic-ui-react'
import { viewModeNames } from './util'
import HelpButton from '../HelpButton'
import StudentTable from './Tables/student'
import AttemptsTable from './Tables/attempts'
import { toggleOpenAndReqularCourses } from '../../../../redux/coursesearch'
import selectors from '../../../../selectors/courseStats'
import UnifyRadioButtons from '../UnifyRadioButtons'
export const TablesSettings = ({ value, onChange }) => {
const { viewMode, showDetails, showGrades } = value
const dispatch = useDispatch()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const toggleUnifyRadioValue = (event, { value }) => {
dispatch(toggleOpenAndReqularCourses(value))
}
export const TablesSettings = ({ value, onChange, availableStats }) => {
const { viewMode, showDetails, showGrades, showEnrollments } = value
return (
<div>
@@ -48,55 +42,25 @@ export const TablesSettings = ({ value, onChange }) => {
toggle
label="Show grades"
data-cy="gradeToggle"
disabled={viewMode !== 'ATTEMPTS'}
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Show enrollments"
data-cy="enrollmentToggle"
checked={showEnrollments}
onChange={() => onChange({ ...value, showEnrollments: !showEnrollments })}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode={viewMode} />
</Menu.Item>
</Menu>
<Menu secondary style={{ marginLeft: '0.5rem' }}>
<Form>
<Form.Group>
<Form.Field>
<b>Provider organization:</b>
</Form.Field>
<Form.Field>
<Radio
label="university"
name="radioGroup"
value="reqularStats"
checked={openOrReqular === 'reqularStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_reqular"
/>
</Form.Field>
<Form.Field>
<Radio
label="open university"
name="radioGroup"
value="openStats"
checked={openOrReqular === 'openStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_open"
/>
</Form.Field>
<Form.Field>
<Radio
label="unify"
name="radioGroup"
value="unifyStats"
checked={openOrReqular === 'unifyStats'}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_unify"
/>
</Form.Field>
</Form.Group>
</Form>
</Menu>
<UnifyRadioButtons availableStats={availableStats} />
</div>
)
}
@@ -105,6 +69,7 @@ export const Tables = props => {
const alternatives = useSelector(selectors.getCourseAlternatives)
const viewModes = { ATTEMPTS: AttemptsTable, STUDENT: StudentTable }
const Content = viewModes[props.settings.viewMode]
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
return <Content {...props} alternatives={alternatives} />
return <Content {...props} alternatives={alternatives} unifyCourses={openOrReqular} />
}
@@ -1,4 +1,23 @@
import { arrayOf, number, oneOfType, shape, string, oneOf } from 'prop-types'
import { flatten } from 'lodash'
const gradesMap = {
0: 0,
1: 10,
2: 20,
3: 30,
4: 40,
5: 50,
'Hyv.': 60,
I: 0,
A: 1,
LUB: 2,
NSLA: 3,
CL: 4,
MCLA: 5,
ECLA: 6,
L: 7,
}
export const viewModeNames = {
STUDENT: 'Students',
@@ -23,12 +42,55 @@ export const viewModeType = oneOf(Object.values(viewModeNames))
export const THESIS_GRADE_KEYS = ['I', 'A', 'NSLA', 'LUB', 'CL', 'MCLA', 'ECLA', 'L']
export const sortGrades = (a, b) => gradesMap[a] - gradesMap[b]
export const isThesisGrades = grades => Object.keys(grades).some(k => THESIS_GRADE_KEYS.includes(k))
export const isThesisSeries = series => series && series.some(s => isThesisGrades(s))
export const absoluteToRelative = all => (p, i) => p / all[i] || 0
export const resolveGrades = stats => {
const failedGrades = ['eisa', 'hyl.', 'hyl', '0', 'luop']
const otherPassedGrades = ['hyv.', 'hyv']
const allGrades = [
'0',
...flatten(
stats.map(({ students }) =>
[...Object.keys(students.grades)].map(grade => {
const parsedGrade = Number(grade) ? Math.round(Number(grade)).toString() : grade
if (failedGrades.includes(parsedGrade.toLowerCase())) return '0'
if (parsedGrade === 'LA') return 'LUB' // merge LA and LUB grades
return parsedGrade
})
)
),
]
// If any of grades 1-5 is present, make sure that full the grade scale is present
if (allGrades.filter(grade => ['1', '2', '3', '4', '5'].includes(grade)).length)
allGrades.push(...['1', '2', '3', '4', '5'])
const grades = [...new Set(allGrades)]
return grades.sort(sortGrades).map(grade => {
if (grade === '0') return { key: grade, title: 'Failed' }
if (otherPassedGrades.includes(grade.toLowerCase())) return { key: grade, title: 'Other passed' }
return { key: grade, title: grade.charAt(0).toUpperCase() + grade.slice(1) }
})
}
export const getSortableColumn = opts => ({
filterType: 'range',
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
...opts,
})
export const getThesisGradeSpread = (series, isRelative) => {
const thesisGradeAccumulator = {
L: [],
@@ -39,13 +101,20 @@ export const getThesisGradeSpread = (series, isRelative) => {
NSLA: [],
A: [],
I: [],
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
}
const newSeries = series.reduce(
(acc, cur, i) => {
const currentEntries = Object.entries(cur)
currentEntries.forEach(([k, v]) => {
acc[k].push(v)
const merged = k === 'LA' ? 'LUB' : k
acc[merged].push(v)
})
Object.entries(acc).forEach(([k, v]) => {
@@ -94,7 +163,8 @@ export const getGradeSpread = (series, isRelative) => {
if (failedKeys.includes(k.toLowerCase())) {
failed += v
} else {
acc[k].push(v)
const parsedGrade = Number(k) ? Math.round(Number(k)) : k
acc[parsedGrade].push(v)
}
})
acc[0].push(failed)
@@ -0,0 +1,60 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Menu, Radio, Form } from 'semantic-ui-react'
import { toggleOpenAndReqularCourses } from '../../../redux/coursesearch'
const UnifyRadioButtons = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const toggleUnifyRadioValue = (event, { value }) => {
dispatch(toggleOpenAndReqularCourses(value))
}
return (
<Menu secondary style={{ marginLeft: '0.5rem' }}>
<Form>
<Form.Group>
<Form.Field>
<b>Provider organization:</b>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.university ? 'university' : 'university (no data)'}
name="radioGroup"
value="reqularStats"
checked={openOrReqular === 'reqularStats'}
disabled={!availableStats.university}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_reqular"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.open ? 'open' : 'open (no data)'}
name="radioGroup"
value="openStats"
checked={openOrReqular === 'openStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.open}
data-cy="unify_radio_open"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.unify ? 'both' : 'both (no data)'}
name="radioGroup"
value="unifyStats"
checked={openOrReqular === 'unifyStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.unify}
data-cy="unify_radio_unify"
/>
</Form.Field>
</Form.Group>
</Form>
</Menu>
)
}
export default UnifyRadioButtons
@@ -12,15 +12,22 @@ import TSA from '../../../common/tsa'
const ANALYTICS_CATEGORY = 'Course Statistics'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const PaneContent = ({ component: Component, settings: SettingsComponent, initialSettings, datasets, ...rest }) => {
const PaneContent = ({
component: Component,
settings: SettingsComponent,
initialSettings,
datasets,
availableStats,
...rest
}) => {
const [settings, setSettings] = useState(initialSettings)
const [splitDirection, setSplitDirection] = useState('column')
const [splitDirection, setSplitDirection] = useState('row')
return (
<Tab.Pane>
<Segment basic>
<div style={{ display: 'flex', marginBottom: '2em' }}>
<SettingsComponent value={settings} onChange={setSettings} />
<SettingsComponent value={settings} onChange={setSettings} availableStats={availableStats} />
<div style={{ flexGrow: 1 }} />
{datasets.filter(i => i).length > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1em' }}>
@@ -40,7 +47,7 @@ const PaneContent = ({ component: Component, settings: SettingsComponent, initia
{datasets
.filter(i => i)
.map(data => (
<div style={{ flexGrow: 1, flexBasis: 1 }}>
<div style={{ flexGrow: 1, flexBasis: 1, width: '100%' }}>
<h3>{data.name}</h3>
<Component data={data} settings={settings} {...rest} />
</div>
@@ -51,7 +58,7 @@ const PaneContent = ({ component: Component, settings: SettingsComponent, initia
)
}
const ResultTabs = ({ primary, comparison, history }) => {
const ResultTabs = ({ primary, comparison, history, separate, availableStats }) => {
const [tab, setTab] = useTabs('cs_tab', 0, history)
const { userHasAccessToAllStats } = primary
@@ -66,14 +73,14 @@ const ResultTabs = ({ primary, comparison, history }) => {
{
label: 'Tables',
icon: 'table',
initialSettings: { showDetails: false, viewMode: 'STUDENT' },
initialSettings: { showDetails: false, showEnrollments: false, viewMode: 'STUDENT', separate },
settings: TablesSettings,
component: Tables,
},
{
label: 'Pass rate chart',
icon: 'balance',
initialSettings: { viewMode: 'STUDENT' },
initialSettings: { viewMode: 'STUDENT', separate },
settings: PassRateSettings,
component: PassRate,
},
@@ -96,6 +103,7 @@ const ResultTabs = ({ primary, comparison, history }) => {
userHasAccessToAllStats={userHasAccessToAllStats}
initialSettings={initialSettings}
datasets={[primary, comparison]}
availableStats={availableStats}
/>
),
})
@@ -1,3 +1,9 @@
const parseGrade = grade => {
const parsedGrade = Number(grade) ? Math.round(Number(grade)).toString() : grade
if (parsedGrade === 'LA') return 'LUB' // merge LA and LUB grades
return parsedGrade
}
const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
const totals = formattedStats.reduce(
(acc, curr) => {
@@ -8,18 +14,26 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
let { passed } = acc.attempts.categories
let { failed } = acc.attempts.categories
const cgrades = acc.attempts.grades
const bestEffortGrades = acc.students.grades
Object.keys(curr.attempts.grades).forEach(grade => {
if (!cgrades[grade]) cgrades[grade] = 0
cgrades[grade] += curr.attempts.grades[grade]
const parsedGrade = parseGrade(grade)
if (!cgrades[parsedGrade]) cgrades[parsedGrade] = 0
cgrades[parsedGrade] += curr.attempts.grades[grade]
if (['EISA', 'Hyl.', 'HYL', '0', 'LUOP'].includes(grade)) {
if (['eisa', 'hyl.', 'hyl', '0', 'luop'].includes(parsedGrade.toLowerCase())) {
failed += curr.attempts.grades[grade]
} else {
passed += curr.attempts.grades[grade]
}
})
Object.keys(curr.students.grades).forEach(grade => {
const parsedGrade = parseGrade(grade)
if (!bestEffortGrades[parsedGrade]) bestEffortGrades[parsedGrade] = 0
bestEffortGrades[parsedGrade] += curr.students.grades[grade]
})
const { passedFirst, passedEventually, neverPassed } = curr.students.categories
const newPassedFirst = passedFirst
@@ -34,16 +48,42 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
? acc.students.categories.neverPassed + neverPassed
: acc.students.categories.neverPassed
Object.keys(curr.students.enrollmentsByState).forEach(k => {
if (acc.students.enrollmentsByState[k] === undefined) acc.students.enrollmentsByState[k] = 0
acc.students.enrollmentsByState[k] += curr.students.enrollmentsByState[k] || 0
})
Object.keys(curr.attempts.enrollmentsByState).forEach(k => {
if (acc.attempts.enrollmentsByState[k] === undefined) acc.attempts.enrollmentsByState[k] = 0
acc.attempts.enrollmentsByState[k] += curr.attempts.enrollmentsByState[k] || 0
})
return {
...acc,
coursecode: curr.coursecode,
attempts: { categories: { passed, failed }, grades: cgrades },
attempts: {
categories: { passed, failed },
grades: cgrades,
enrollmentsByState: { ...acc.attempts.enrollmentsByState },
totalEnrollments: acc.students.totalEnrollments + (curr.students.totalEnrollments || 0),
},
students: {
totalEnrollments: acc.students.totalEnrollments + (curr.students.totalEnrollments || 0),
totalPassed: acc.students.totalPassed + curr.students.totalPassed,
totalFailed: acc.students.totalFailed + curr.students.totalFailed,
enrolledStudentsWithNoGrade:
acc.students.enrolledStudentsWithNoGrade + (curr.students.enrolledStudentsWithNoGrade || 0),
categories: {
passedFirst: newPassedFirst,
passedEventually: newPassedEventually,
neverPassed: newNeverPassed,
},
grades: bestEffortGrades,
enrollmentsByState: { ...acc.students.enrollmentsByState },
withEnrollments: {
total: acc.students.withEnrollments.total + curr.students.withEnrollments.total,
totalFailed: acc.students.withEnrollments.totalFailed + curr.students.withEnrollments.totalFailed,
},
},
}
},
@@ -59,6 +99,12 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
},
passRate: 0,
grades: {},
totalEnrollments: 0,
enrollmentsByState: {
ENROLLED: 0,
REJECTED: 0,
ABORTED: 0,
},
},
students: {
categories: {
@@ -66,9 +112,23 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
passedEventually: 0,
neverPassed: 0,
},
grades: {},
passRate: 0,
failRate: 0,
total: 0,
totalPassed: 0,
totalFailed: 0,
totalEnrollments: 0,
enrolledStudentsWithNoGrade: 0,
enrollmentsByState: {
ENROLLED: 0,
REJECTED: 0,
ABORTED: 0,
},
withEnrollments: {
total: 0,
totalFailed: 0,
},
},
studentnumbers: [],
}
@@ -76,11 +136,16 @@ const countTotalStats = (formattedStats, userHasAccessToAllStats) => {
// Count pass- and failrates also for "Total"-lines
const { passedFirst = 0, passedEventually = 0, neverPassed = 0 } = totals.students.categories
const { enrolledStudentsWithNoGrade = 0 } = totals.students
const total = passedFirst + passedEventually + neverPassed
totals.students.total = total
totals.students.passRate = (passedFirst + passedEventually) / total
totals.students.failRate = neverPassed / total
totals.students.withEnrollments.passRate = (passedFirst + passedEventually) / (total + enrolledStudentsWithNoGrade)
totals.students.withEnrollments.failRate =
(neverPassed + enrolledStudentsWithNoGrade) / (total + enrolledStudentsWithNoGrade)
const { failed, passed } = totals.attempts.categories
totals.attempts.passRate = (100 * passed) / (passed + failed)
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useMemo, useEffect } from 'react'
import { Segment, Header, Form, Grid, Button, Popup } from 'semantic-ui-react'
import { shape, string, arrayOf, objectOf, oneOfType, number, func, bool } from 'prop-types'
import { connect } from 'react-redux'
@@ -15,7 +15,7 @@ import ProgrammeDropdown from '../ProgrammeDropdown'
import selectors, { ALL } from '../../../selectors/courseStats'
import YearFilter from '../SearchForm/YearFilter'
import { getTextIn } from '../../../common'
import { getSemesters } from '../../../redux/semesters'
import { useGetSemestersQuery } from '../../../redux/semesters'
import TSA from '../../../common/tsa'
import useLanguage from '../../LanguagePicker/useLanguage'
import countTotalStats from './countTotalStats'
@@ -34,14 +34,12 @@ const countFilteredStudents = (stat, filter) =>
const SingleCourseStats = ({
stats,
availableStats,
setSelectedCourse,
clearSelectedCourse,
history,
location,
stats: { coursecode },
getSemesters,
years,
semesters,
programmes,
maxYearsToCreatePopulationFrom,
getMaxYearsToCreatePopulationFrom,
@@ -54,6 +52,28 @@ const SingleCourseStats = ({
const [toYear, setToYear] = useState(0)
const [separate, setSeparate] = useState(null)
const { data: semesterData } = useGetSemestersQuery()
const { semesters, years } = useMemo(() => {
const semesters = Object.values(semesterData?.semesters ?? [])
.map(({ semestercode, name, yearcode }) => ({
key: semestercode,
texts: Object.values(name),
value: yearcode,
}))
.reverse()
const years = Object.values(semesterData?.years ?? [])
.map(({ yearcode, yearname }) => ({
key: yearcode,
text: yearname,
value: yearcode,
}))
.reverse()
return { semesters, years }
}, [semesterData])
const parseQueryFromUrl = () => {
const { separate } = qs.parse(location.search)
return {
@@ -62,7 +82,6 @@ const SingleCourseStats = ({
}
useEffect(() => {
if (years.length === 0 || semesters.length === 0) getSemesters()
if (location.search) {
const { separate } = parseQueryFromUrl()
setSeparate(separate)
@@ -170,19 +189,76 @@ const SingleCourseStats = ({
}
}
const countStudentStats = (allstudents, filter) => {
const countStudentStats = (allstudents, enrolledNoGrade = 0, filter) => {
const categories = countFilteredStudents(allstudents.categories, filter)
const grades = countFilteredStudents(allstudents.grades, filter)
const { passedFirst = 0, passedEventually = 0, neverPassed = 0 } = categories
const total = passedFirst + passedEventually + neverPassed
const passRate = (passedFirst + passedEventually) / total
const failRate = neverPassed / total
const withEnrollments = {
total: total + enrolledNoGrade,
passRate: (passedFirst + passedEventually) / (total + enrolledNoGrade),
failRate: (neverPassed + enrolledNoGrade) / (total + enrolledNoGrade),
}
return {
totalPassed: passedFirst + passedEventually,
totalFailed: neverPassed,
categories,
passRate,
failRate,
total,
withEnrollments,
grades,
}
}
const countEnrollmentStates = filteredEnrollments => {
const combined = { CONFIRMED: 'ENROLLED', ABORTED_BY_TEACHER: 'ABORTED', ABORTED_BY_STUDENT: 'ABORTED' }
return filteredEnrollments.reduce(
(acc, enrollment) => {
const state = combined[enrollment.state] || enrollment.state
if (acc[state] === undefined) acc[state] = 0
acc[state] += 1
return acc
},
{
ENROLLED: 0,
REJECTED: 0,
ABORTED: 0,
}
)
}
const countAttemptEnrollmentStats = (filteredEnrollments, displayEnrollments) => {
const enrollmentsByState = countEnrollmentStates(filteredEnrollments)
const totalEnrollments = displayEnrollments ? filteredEnrollments.length : undefined
if (!displayEnrollments)
Object.keys(enrollmentsByState).forEach(k => {
enrollmentsByState[k] = undefined
})
return { enrollmentsByState, totalEnrollments }
}
const countStudentEnrollmentStats = (allAttempts, filteredEnrollments, displayEnrollments) => {
const enrolledStudentsWithNoGrade = filteredEnrollments.filter(({ studentnumber, state }) => {
if (state !== 'ENROLLED') return false
const hasFailed = allAttempts.categories.failed ? allAttempts.categories.failed.includes(studentnumber) : false
const hasPassed = allAttempts.categories.passed ? allAttempts.categories.passed.includes(studentnumber) : false
return !hasFailed && !hasPassed
})
const enrollmentsByState = countEnrollmentStates(filteredEnrollments)
if (!displayEnrollments) {
Object.keys(enrollmentsByState).forEach(k => {
enrollmentsByState[k] = undefined
})
return { enrolledStudentsWithNoGrade: undefined, enrollmentsByState, totalEnrollments: undefined }
}
return {
enrolledStudentsWithNoGrade: enrolledStudentsWithNoGrade.length,
enrollmentsByState,
totalEnrollments: filteredEnrollments.length,
}
}
@@ -191,20 +267,40 @@ const SingleCourseStats = ({
const filter = belongsToAtLeastOneProgramme(progCodes)
const formattedStats = statistics
.filter(isStatInYearRange)
.map(({ code, name, students: allStudents, attempts: allAttempts, coursecode, obfuscated }) => {
const attempts = countAttemptStats(allAttempts, filter)
const students = countStudentStats(allStudents, filter)
const parsedName = separate ? getTextIn(name, language) : name
return {
.map(
({
code,
name: parsedName,
attempts,
students,
name,
students: allStudents,
attempts: allAttempts,
coursecode,
rowObfuscated: obfuscated,
userHasAccessToAllStats,
obfuscated,
enrollments = [],
allEnrollments = [],
yearcode,
}) => {
const displayEnrollments = yearcode >= 72 // Display enrollments only for Sisu era
const filteredEnrollments = enrollments.filter(({ studentnumber }) => filter(studentnumber))
const filteredAllEnrollments = allEnrollments.filter(({ studentnumber }) => filter(studentnumber))
const attempts = countAttemptStats(allAttempts, filter)
const attemptsEnrollments = countAttemptEnrollmentStats(filteredAllEnrollments, displayEnrollments)
const studentsEnrollments = countStudentEnrollmentStats(allAttempts, filteredEnrollments, displayEnrollments)
const students = countStudentStats(allStudents, studentsEnrollments.enrolledStudentsWithNoGrade, filter)
const parsedName = separate ? getTextIn(name, language) : name
return {
name: parsedName,
students: { ...students, ...studentsEnrollments },
attempts: { ...attempts, ...attemptsEnrollments },
enrollments: filteredEnrollments,
code,
coursecode,
rowObfuscated: obfuscated,
userHasAccessToAllStats,
}
}
})
)
const totals = countTotalStats(formattedStats, userHasAccessToAllStats)
@@ -247,7 +343,6 @@ const SingleCourseStats = ({
const filteredProgrammeStatistics = () => {
const filter = p => validProgCode(p)
const excludedProgrammes = getExcluded()
const primaryProgrammes = primary
@@ -266,6 +361,7 @@ const SingleCourseStats = ({
comparisonProgrammes.length === 1 ? getProgrammeName(comparisonProgrammes[0]) : 'Comparison'
)
: undefined
return {
primary: pstats || undefined,
comparison: cstats || undefined,
@@ -324,6 +420,7 @@ const SingleCourseStats = ({
return { ...e, students: [...students], size: students.size }
})
.filter(e => e.size > 0)
if (stats.statistics.length < 1) return <Segment>No data for selected course</Segment>
const options = filteredProgrammes
@@ -389,7 +486,12 @@ const SingleCourseStats = ({
</Form>
</Segment>
)}
<ResultTabs separate={separate} primary={statistics.primary} comparison={statistics.comparison} />
<ResultTabs
separate={separate}
primary={statistics.primary}
comparison={statistics.comparison}
availableStats={availableStats}
/>
</div>
)
}
@@ -419,46 +521,27 @@ SingleCourseStats.propTypes = {
coursecode: string,
}).isRequired,
programmes: arrayOf(shape({})).isRequired,
years: arrayOf(shape({})).isRequired,
semesters: arrayOf(shape({})).isRequired,
location: shape({}).isRequired,
setSelectedCourse: func.isRequired,
clearSelectedCourse: func.isRequired,
getSemesters: func.isRequired,
history: shape({
push: func,
}).isRequired,
getMaxYearsToCreatePopulationFrom: func.isRequired,
getMaxYearsToCreatePopulationFrom: Number.isRequired,
maxYearsToCreatePopulationFrom: number.isRequired,
userHasAccessToAllStats: bool.isRequired,
}
const mapStateToProps = state => {
const { semesters = [], years = [] } = state.semesters.data
return {
programmes: selectors.getAllStudyProgrammes(state),
years: Object.values(years)
.map(({ yearcode, yearname }) => ({
key: yearcode,
text: yearname,
value: yearcode,
}))
.reverse(),
semesters: Object.values(semesters)
.map(({ semestercode, name, yearcode }) => ({
key: semestercode,
texts: Object.values(name),
value: yearcode,
}))
.reverse(),
maxYearsToCreatePopulationFrom: state.singleCourseStats.maxYearsToCreatePopulationFrom,
maxYearsToCreatePopulationFrom: selectors.getMaxYearsToCreatePopulationFrom(state),
}
}
const mapDispatchToProps = {
setSelectedCourse,
clearSelectedCourse,
getSemesters,
getMaxYearsToCreatePopulationFrom,
}
@@ -11,6 +11,7 @@ const SingleCourseTab = ({ selected, userHasAccessToAllStats }) => {
const [selection, setSelection] = useState(selected)
const { language } = useLanguage()
const stats = useSelector(selectors.getCourseStats)
const availableStats = useSelector(selectors.getAvailableStats)
const courses = useSelector(selectors.getCourses).map(({ code, name }) => ({
key: code,
value: code,
@@ -23,6 +24,7 @@ const SingleCourseTab = ({ selected, userHasAccessToAllStats }) => {
}, [selected])
if (!stats[selection]) return null
return (
<div>
<Segment>
@@ -51,7 +53,13 @@ const SingleCourseTab = ({ selected, userHasAccessToAllStats }) => {
</Label.Group>
</Form>
</Segment>
{selection && <SingleCourseStats stats={stats[selection]} userHasAccessToAllStats={userHasAccessToAllStats} />}
{selection && (
<SingleCourseStats
stats={stats[selection]}
userHasAccessToAllStats={userHasAccessToAllStats}
availableStats={availableStats[selected]}
/>
)}
</div>
)
}
@@ -4,11 +4,11 @@ import { useSelector, useDispatch } from 'react-redux'
import { Segment, Header, Accordion, Message } from 'semantic-ui-react'
import _ from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import semestersApi from 'redux/semesters'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import { getCustomPopulationSearches } from '../../redux/customPopulationSearch'
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
import { getSemesters } from '../../redux/semesters'
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
import PopulationStudents from '../PopulationStudents'
import CustomPopulationCourses from './CustomPopulationCourses'
@@ -34,10 +34,10 @@ import CustomPopulationSearch from './CustomPopulationSearch'
import UnihowDataExport from './UnihowDataExport'
const selectCustomPopulationData = createSelector(
state => state.semesters.data,
semestersApi.endpoints.getSemesters.select(),
state => state.populations.data,
(semesters, populations) => ({
allSemesters: semesters?.semesters ?? [],
allSemesters: semesters?.data?.semesters ?? [],
custompop: populations?.students ?? [],
})
)
@@ -55,7 +55,6 @@ const CustomPopulation = () => {
useTitle('Custom population')
useEffect(() => {
dispatch(getSemesters())
dispatch(getCustomPopulationSearches())
}, [])
@@ -0,0 +1,30 @@
import React from 'react'
import { Form } from 'semantic-ui-react'
import DateTimeSelector from 'components/DateTimeSelector'
const DateRangeSelector = ({ value, onChange, ...rest }) => {
return (
<Form style={{}}>
<Form.Field>
<label>Beginning:</label>
<DateTimeSelector
value={value?.[0]}
before={value?.[1]}
onChange={date => onChange([date, value?.[1]])}
{...rest}
/>
</Form.Field>
<Form.Field>
<label>Ending:</label>
<DateTimeSelector
value={value?.[1]}
after={value?.[0]}
onChange={date => onChange([value?.[0], date])}
{...rest}
/>
</Form.Field>
</Form>
)
}
export default DateRangeSelector
@@ -0,0 +1,121 @@
import React, { useRef } from 'react'
import Datetime from 'react-datetime'
import 'moment/locale/fi'
import { Icon, Button } from 'semantic-ui-react'
import moment from 'moment'
import { useGetSemestersQuery } from 'redux/semesters'
import { getTextIn } from '../../common'
import './style.css'
const semesterListStyles = {
maxHeight: '10em',
overflowY: 'auto',
padding: 0,
margin: '0 -4px -4px -4px',
borderTop: '1px solid #f9f9f9',
}
const DateTime = ({ value, onChange, before, after, showSemesters }) => {
const datetimeRef = useRef()
const semesterRequest = useGetSemestersQuery()
const allSemesters = semesterRequest.data?.semesters ?? []
return (
<Datetime
ref={datetimeRef}
value={value}
onChange={onChange}
timeFormat={false}
locale="fi"
closeOnSelect
isValidDate={date => (!before || date.isBefore(before)) && (!after || date.isAfter(after))}
renderView={(mode, renderDefault) => {
const createDateButton = (date, label) => (
<button
type="button"
onClick={() => {
datetimeRef.current.setViewDate(moment(date))
onChange(moment(date))
}}
style={{
height: '1.75em',
background: value && value.isSame(date, 'day') ? '#428bca' : undefined,
color: value && value.isSame(date, 'day') ? 'white' : 'inherit',
}}
className="date-picker-semester-button"
>
{label}
</button>
)
return (
<>
{renderDefault()}
{showSemesters && (
<>
<div
style={{
...semesterListStyles,
height: '1.75em',
lineHeight: '1.75em',
marginBottom: 0,
paddingLeft: '0.3em',
fontWeight: 'bold',
}}
>
Semesters:
</div>
<ul style={semesterListStyles}>
{allSemesters &&
Object.values(allSemesters)
.filter(({ startdate, enddate }) => {
const start = moment(startdate)
const end = moment(enddate)
return start.isBefore() && (!before || start.isBefore(before)) && (!after || end.isAfter(after))
})
.sort((a, b) => new Date(b.startdate) - new Date(a.startdate))
.map(({ name, startdate, enddate }) => (
<li style={{ padding: '0.2em 0.3em', display: 'flex', alignItems: 'center' }}>
<span style={{ flexGrow: 1 }}>{getTextIn(name)}</span>
{createDateButton(startdate, 'Start')}
{createDateButton(moment(enddate).subtract(1, 'days'), 'End')}
</li>
))}
</ul>
</>
)}
</>
)
}}
renderInput={(_, open) => (
<Button
icon={value !== null}
labelPosition={value !== null && 'right'}
onClick={open}
className="credit-date-filter-input"
size="mini"
style={{
whiteSpace: 'nowrap',
paddingRight: value ? '3.5em !important' : undefined,
paddingLeft: value ? '1em !important' : undefined,
}}
>
{value === null ? 'Select Date' : moment(value).format('DD.MM.YYYY')}
{value !== null && (
<Icon
name="x"
onClick={evt => {
evt.stopPropagation()
onChange(null)
}}
/>
)}
</Button>
)}
/>
)
}
export default DateTime
@@ -0,0 +1,3 @@
.date-picker-semester-button:hover {
background-color: red;
}
@@ -0,0 +1,122 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'
import { Icon, Card, Dropdown, Portal, Popup } from 'semantic-ui-react'
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'
import './styles.css'
const FigureContext = createContext(null)
const Header = ({ children, actions, contextItems }) => {
const { isFullscreen, setFullscreen } = useContext(FigureContext)
return (
<Card.Content style={{ flexGrow: 0, display: 'flex', alignItems: 'center', padding: '1em', height: '3.25em' }}>
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>{children}</div>
<div style={{ flexGrow: 1 }} />
<div style={{ marginRight: '1em' }}>{actions}</div>
<Popup
trigger={
<Icon
name={isFullscreen ? 'compress' : 'expand'}
onClick={() => setFullscreen(!isFullscreen)}
style={{ cursor: 'pointer', fontSize: '1.1rem', marginRight: '0.5em', position: 'relative', top: '-1px' }}
/>
}
on="hover"
position="bottom"
>
Toggle Fullscreen
</Popup>
{contextItems && (
<div>
<Dropdown position="bottom center" direction="left" icon="ellipsis vertical">
<Dropdown.Menu>
{contextItems.map(({ label, onClick }) => (
<Dropdown.Item onClick={onClick}>{label}</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
)}
</Card.Content>
)
}
const Content = ({ children, ...rest }) => {
const { isFullscreen } = useContext(FigureContext)
const style = {
...rest.style,
overflow: 'auto',
}
if (isFullscreen) {
style.maxHeight = 'initial'
}
return (
<Card.Content {...rest} style={style}>
{children}
</Card.Content>
)
}
const FigureContainer = ({ children, style }) => {
const [isFullscreen, setFullscreen] = useState(false)
const portalNode = useMemo(
() =>
createHtmlPortalNode({
attributes: {
class: 'ui card fluid',
},
}),
[]
)
useEffect(() => {
if (isFullscreen) {
document.body.classList.add('figure-fullscreen')
portalNode.element.style = `
position: sticky;
margin: 0;
inset: 0;
height: 100vh;
border: none;
border-radius: 0;
`
} else {
document.body.classList.remove('figure-fullscreen')
portalNode.element.style = `
overflow: hidden;
maxHeight: 100%;
`
Object.assign(portalNode.element.style, style)
}
return () => document.body.classList.remove('figure-fullscreen')
}, [isFullscreen, portalNode])
return (
<FigureContext.Provider value={{ isFullscreen, setFullscreen }}>
<InPortal node={portalNode}>{children}</InPortal>
{!isFullscreen && <OutPortal node={portalNode} />}
<Portal
open={isFullscreen}
style={{
width: '100% !important',
height: '100% !important',
}}
>
<div style={{ inset: 0, position: 'absolute', zIndex: 900, height: '100000vh' }}>
{isFullscreen && <OutPortal node={portalNode} />}
</div>
</Portal>
</FigureContext.Provider>
)
}
FigureContainer.Header = Header
FigureContainer.Content = Content
export default FigureContainer
@@ -0,0 +1,3 @@
body.figure-fullscreen {
overflow: hidden;
}
@@ -4,7 +4,7 @@ import { Icon } from 'semantic-ui-react'
import './filterToggleIcon.css'
const FilterToggleIcon = ({ onClick, isActive }) => (
<span className={`flexIcon ${isActive ? 'active' : ''}`}>
<span className={`flexIcon ${isActive ? 'active' : ''}`} style={{ cursor: 'pointer' }}>
<Icon onClick={onClick} name="filter" />
<Icon onClick={onClick} name={isActive ? 'remove' : 'add'} size="tiny" />
</span>
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Form, Label, Input } from 'semantic-ui-react'
import React, { useMemo, useCallback } from 'react'
import RangeSelector from 'components/RangeSelector'
import { useDebounce } from 'common/hooks'
import _ from 'lodash'
import createFilter from './createFilter'
export const contextKey = 'ageFilter'
@@ -15,79 +17,27 @@ const getAge = toDate => {
return age
}
const AgeFilterCard = ({ options, onOptionsChange }) => {
const [currentValue, setCurrentValue] = useState(options)
const [updatedAt, setUpdatedAt] = useState(null)
const AgeFilterCard = ({ options, onOptionsChange, bounds }) => {
const { min, max } = bounds
// const { currentValue, setCurrentValue } = useAgeFilter()
// const { addFilter, removeFilter, activeFilters } = useFilters()
const onChange = useCallback(
([min, max]) => {
onOptionsChange({ min, max })
},
[onOptionsChange]
)
const labels = { min: 'At Least', max: 'Less Than' }
const value = useMemo(() => [options.min ?? min, options.max ?? max], [options.min, options.max, min, max])
const now = () => new Date().getTime()
const updateFilters = () => {
const { min, max } = currentValue
onOptionsChange({
min: min === '' ? null : parseInt(min, 10),
max: max === '' ? null : parseInt(max, 10),
})
}
useEffect(() => {
const { min, max } = options
setCurrentValue({
min: min !== null ? min : currentValue.min,
max: max !== null ? max : currentValue.max,
})
}, [options.min, options.max])
// Update filters automatically 2 sec after value change.
useEffect(() => {
const timer = setTimeout(() => {
if (updatedAt !== null && now() - updatedAt > 1900) {
updateFilters()
setUpdatedAt(null)
}
}, 2000)
return () => clearTimeout(timer)
}, [updatedAt])
const onChange =
key =>
(_, { value }) => {
setCurrentValue(prev => ({ ...prev, [key]: value }))
setUpdatedAt(now())
}
const onKeyDown = event => {
if (event.keyCode === 13) {
event.preventDefault()
setUpdatedAt(null)
updateFilters()
}
}
const [range, setRange] = useDebounce(value, 1000, onChange)
return (
<Form>
<div>
<p>Valitse ikähaitari, jolle asettuvat opiskelijat näytetään:</p>
<div className="card-content">
{Object.keys(currentValue).map(key => (
<Form.Field key={`total-age-filter-${key}`}>
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
<Input
size="mini"
onChange={onChange(key)}
value={currentValue[key] ?? ''}
onKeyDown={onKeyDown}
data-cy={`ageFilter-${key}`}
style={{ width: '100px' }}
/>
</Form.Field>
))}
{min < max && <RangeSelector min={min} max={max} onChange={setRange} value={range} />}
</div>
</Form>
</div>
)
}
@@ -101,6 +51,15 @@ export default createFilter({
isActive: ({ min, max }) => min !== null || max !== null,
precompute: ({ students }) => {
const ages = students.map(s => getAge(s.birthdate)).filter(age => !Number.isNaN(age))
return {
min: _.min(ages),
max: _.max(ages),
}
},
filter: (student, { min, max }) => {
const age = getAge(student.birthdate)
@@ -108,12 +67,12 @@ export default createFilter({
return false
}
if (max !== null && max <= age) {
if (max !== null && max < age) {
return false
}
return true
},
component: AgeFilterCard,
render: (props, { precomputed }) => <AgeFilterCard {...props} bounds={precomputed} />,
})
@@ -1,86 +1,30 @@
import React, { useState, useEffect } from 'react'
import { Form, Input, Label } from 'semantic-ui-react'
import RangeSelector from 'components/RangeSelector'
import React, { useMemo } from 'react'
import _ from 'lodash'
import { useDebounce } from 'common/hooks'
import { getStudentTotalCredits } from '../../../common'
import createFilter from './createFilter'
export const contextKey = 'creditFilter'
const labels = { min: 'At Least', max: 'Less Than' }
const CreditsEarnedFilterCard = ({ options, onOptionsChange }) => {
const [localOptions, setLocalOptions] = useState(options)
const { min, max } = localOptions
const [updatedAt, setUpdatedAt] = useState(null)
const CreditsEarnedFilterCard = ({ options, onOptionsChange, bounds }) => {
const { min, max } = bounds
const now = () => new Date().getTime()
const updateFilters = () => {
onOptionsChange({
min: min === '' ? null : min,
max: max === '' ? null : max,
})
const onChange = ([min, max]) => {
onOptionsChange({ min, max })
}
// Update filters automatically 2 sec after value change.
useEffect(() => {
const timer = setTimeout(() => {
if (updatedAt && now() - updatedAt > 1900) {
updateFilters()
setUpdatedAt(null)
}
}, 2000)
const value = useMemo(() => [options.min ?? min, options.max ?? max], [options.min, options.max, min, max])
return () => clearTimeout(timer)
}, [updatedAt])
// Listen for external filtering requests (from credit stats table rows).
// TODO: Reimplement using something else
/* useEffect(() => {
Object.keys(currentValue).forEach(key => {
const newValue = requestedValue[key] === null ? '' : requestedValue[key]
const name = names[key]
setCurrentValue({ [key]: String(newValue) })
if (newValue === '') {
removeFilter(name)
} else {
addFilter(name, filterFunctions(newValue)[key])
}
})
}, [requestedValue]) */
const onChange =
key =>
(_, { value }) => {
setLocalOptions({ ...localOptions, [key]: value })
setUpdatedAt(now())
}
const onKeyDown = event => {
if (event.keyCode === 13) {
event.preventDefault()
setUpdatedAt(null)
updateFilters()
}
}
const [range, setRange] = useDebounce(value, 1000, onChange)
return (
<Form>
<div>
<p>Valitse opintopistehaitari, jolle asettuvat opiskelijat näytetään:</p>
<div className="card-content">
{['min', 'max'].map(key => (
<Form.Field key={`total-credits-filter-${key}`}>
<Label style={{ marginBottom: '0.5rem' }}>{labels[key]}</Label>
<Input
size="mini"
onChange={onChange(key)}
value={localOptions[key]}
onKeyDown={onKeyDown}
data-cy={`credit-filter-${key}`}
style={{ width: '100px' }}
/>
</Form.Field>
))}
{min < max && <RangeSelector min={min} max={max} onChange={setRange} value={range} />}
</div>
</Form>
</div>
)
}
@@ -96,6 +40,15 @@ export default createFilter({
isActive: ({ min, max }) => min !== null || max !== null,
precompute: ({ students }) => {
const credits = students.map(getStudentTotalCredits).filter(n => !Number.isNaN(n))
return {
max: _.max(credits),
min: _.min(credits),
}
},
filter(student, { min, max }) {
const credits = getStudentTotalCredits(student)
@@ -103,12 +56,12 @@ export default createFilter({
return false
}
if (max !== null && credits >= max) {
if (max !== null && credits > max) {
return false
}
return true
},
component: CreditsEarnedFilterCard,
render: (props, { precomputed }) => <CreditsEarnedFilterCard {...props} bounds={precomputed} />,
})
@@ -1,45 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import Datetime from 'react-datetime'
import 'moment/locale/fi'
import { Icon, Button } from 'semantic-ui-react'
import moment from 'moment'
const DateTime = ({ value, onChange }) => {
return (
<Datetime
value={value}
onChange={onChange}
timeFormat={false}
locale="fi"
closeOnSelect
renderInput={(_, open) => (
<Button
icon={value !== null}
labelPosition={value !== null && 'right'}
onClick={open}
className="credit-date-filter-input"
style={{ whiteSpace: 'nowrap' }}
>
{value === null ? 'Select Date' : moment(value).format('DD.MM.YYYY')}
{value !== null && (
<Icon
name="x"
onClick={evt => {
evt.stopPropagation()
onChange(null)
}}
/>
)}
</Button>
)}
/>
)
}
DateTime.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]).isRequired,
onChange: PropTypes.func.isRequired,
}
export default DateTime
@@ -1,7 +1,6 @@
import React from 'react'
import { Form } from 'semantic-ui-react'
import 'moment/locale/fi'
import DateTime from './DateTime'
import DateRangeSelector from 'components/DateRangeSelector'
import createFilter from '../createFilter'
import filterInfo from '../../../../common/InfoToolTips/filters'
@@ -16,32 +15,16 @@ const CreditDateFilterCard = ({ options, onOptionsChange }) => {
return (
<>
<div className="card-content" style={{ marginTop: '0.5rem' }}>
<Form>
<Form.Field>
<label>Start Date:</label>
<DateTime
value={startDate}
onChange={date =>
onOptionsChange({
...options,
startDate: date,
})
}
/>
</Form.Field>
<Form.Field>
<label>End Date:</label>
<DateTime
value={endDate}
onChange={date =>
onOptionsChange({
...options,
endDate: date,
})
}
/>
</Form.Field>
</Form>
<DateRangeSelector
showSemesters
value={[startDate, endDate]}
onChange={([startDate, endDate]) =>
onOptionsChange({
startDate,
endDate,
})
}
/>
</div>
</>
)
@@ -65,11 +48,11 @@ export default createFilter({
filter(student, { startDate, endDate }) {
student.courses = student.courses.filter(course => {
if (startDate && startDate.isAfter(course.date)) {
if (startDate && startDate.isAfter(course.date, 'day')) {
return false
}
if (endDate && endDate.isBefore(course.date)) {
if (endDate && endDate.isBefore(course.date, 'day')) {
return false
}
@@ -12,6 +12,7 @@ import tags from './tags'
import date from './date'
import enrollmentStatus from './enrollmentStatus'
import studyTrack from './studyTrack'
import studyrightStatus from './studyrightStatus'
export const genderFilter = gender
export const ageFilter = age
@@ -27,3 +28,4 @@ export const tagsFilter = tags
export const creditDateFilter = date
export const enrollmentStatusFilter = enrollmentStatus
export const studyTrackFilter = studyTrack
export const studyrightStatusFilter = studyrightStatus
@@ -7,6 +7,12 @@ import { getTextIn } from '../../../common'
import useLanguage from '../../LanguagePicker/useLanguage'
import createFilter from './createFilter'
const NO_PROGRAMME = {
code: '00000',
name: { en: 'No programme', fi: 'Ei ohjelmaa' },
startdate: '',
}
const ProgrammeFilterCard = ({
options,
onOptionsChange,
@@ -45,20 +51,23 @@ const ProgrammeFilterCard = ({
const dropdownOptions = useMemo(
() =>
_.chain(visibleProgrammes)
.concat(selectedProgrammes.map(code => programmes.find(p => p.code === code)))
.map(program => ({
key: `programme-filter-value-${program.code}`,
text: getTextIn(program.name, language),
value: program.code,
content: (
<>
{getTextIn(program.name, language)}{' '}
<span style={{ color: 'rgb(136, 136, 136)', whiteSpace: 'nowrap' }}>
({program.studentCount} students)
</span>
</>
),
}))
.concat(selectedProgrammes.map(code => programmes.find(p => p && p.code === code)))
.map(program => {
const code = program?.code ?? NO_PROGRAMME.code
const name = program?.name ?? NO_PROGRAMME.name
const studentCount = program?.studentCount ?? -1
return {
key: `programme-filter-value-${code}`,
text: getTextIn(name, language),
value: code,
content: (
<>
{getTextIn(name, language)}{' '}
<span style={{ color: 'rgb(136, 136, 136)', whiteSpace: 'nowrap' }}>({studentCount} students)</span>
</>
),
}
})
.uniqBy('value')
.sort((a, b) => a.text.localeCompare(b.text))
.value(),
@@ -162,7 +171,10 @@ const createStudentToProgrammeMap = (students, studyRightPredicate) => {
const studentProgrammePairs = []
students.forEach(student => {
getStudentProgrammes(student).forEach(programme => {
const studentProgrammes = getStudentProgrammes(student)
if (!studentProgrammes.length)
studentProgrammePairs.push({ student, programme: { ...NO_PROGRAMME, element_detail: { ...NO_PROGRAMME } } })
studentProgrammes.forEach(programme => {
studentProgrammePairs.push({ student, programme })
})
})
@@ -180,7 +192,7 @@ const createStudentToProgrammeMap = (students, studyRightPredicate) => {
const MODE_PREDICATES = {
any: () => true,
active: (_, sre) => moment().isBetween(sre.startdate, sre.enddate, 'day', '[]'),
active: (_, sre) => sre.code === NO_PROGRAMME.code || moment().isBetween(sre.startdate, sre.enddate, 'day', '[]'),
}
const filter = createFilter({
@@ -5,15 +5,21 @@ import moment from 'moment'
import _ from 'lodash'
import createFilter from './createFilter'
const StudyTrackFilterCard = ({ options, onOptionsChange, withoutSelf, activeAt }) => {
const StudyTrackFilterCard = ({ options, onOptionsChange, withoutSelf, activeAt, code }) => {
const { selected } = options
const activeAtMoment = activeAt ? moment(activeAt) : moment()
const activeAtMoment = activeAt && moment(activeAt)
const dropdownOptions = _.chain(withoutSelf())
.flatMap(student => student.studyrights)
.flatMap(sr => sr.studyright_elements)
.filter(sre => sre.element_detail.type === 30 && activeAtMoment.isBetween(sre.startdate, sre.enddate, 'day', '[]'))
.map(sr => sr.studyright_elements)
.filter(sre => sre.some(e => e.code === code))
.flatMap()
.filter(
sre =>
sre.element_detail?.type === 30 &&
(activeAtMoment ? activeAtMoment.isBetween(sre.startdate, sre.enddate, 'day', '[]') : true)
)
.map(sre => sre.element_detail)
.keyBy('code')
.values()
@@ -81,5 +87,7 @@ export default createFilter({
.some(code => selected.includes(code))
},
render: (props, { args }) => <StudyTrackFilterCard {...props} activeAt={_.get(args, 'activeAt')} />,
render: (props, { args }) => (
<StudyTrackFilterCard {...props} code={_.get(args, 'code')} activeAt={_.get(args, 'activeAt')} />
),
})
@@ -0,0 +1,81 @@
import React from 'react'
import { Form, Radio } from 'semantic-ui-react'
import _ from 'lodash'
import createFilter from './createFilter'
import filterInfo from '../../../common/InfoToolTips/filters'
const moment = require('moment')
const StudyrightStatusFilterCard = ({ options, onOptionsChange }) => {
const { active } = options
const toggle = buttonValue => () =>
onOptionsChange({
active: active === buttonValue ? null : buttonValue,
})
return (
<Form>
<div className="card-content">
<Form.Field style={{ display: 'flex', flexDirection: 'column' }}>
<Radio
label="All"
name="radioGroup"
checked={active === null}
onChange={toggle(null)}
data-cy="option-activity-status-all"
/>
<Radio
label="Active studyright"
name="radioGroup"
checked={active === true}
onChange={toggle(true)}
data-cy="option-active"
style={{ margin: '0.5rem 0' }}
/>
<Radio
label="Inactive studyright"
name="radioGroup"
checked={active === false}
onChange={toggle(false)}
data-cy="option-inactive"
/>
</Form.Field>
</div>
</Form>
)
}
export default createFilter({
key: 'studyrightStatusFilter',
title: 'Studyright status',
info: filterInfo.studyrightStatus,
defaultOptions: {
active: null,
},
isActive: ({ active }) => active !== null,
filter: (student, { active }, { args }) => {
const { code } = args
const now = moment(new Date())
const status = s => {
if (active === true) return s.active === 1 && ((s.enddate && moment(s.enddate).isAfter(now)) || !s.enddate) // Studyright is active if the student has enrolled (absent or present) for this semester and the studyright has not yet ended
if (active === false) return !s.graduated && (s.active === 0 || (s.enddate && moment(s.enddate).isBefore(now))) // Studyright is inactive if the student has not enrolled for this semester or the studyright has expired
return true
}
return student.studyrights.some(s => {
const correctStatus = status(s)
return correctStatus && s.studyright_elements.some(s => s.code === code)
})
},
component: StudyrightStatusFilterCard,
render: (props, { args }) => <StudyrightStatusFilterCard {...props} code={_.get(args, 'code')} />,
})
@@ -1,6 +1,7 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import React, { createContext, useState, useCallback, useContext, useEffect } from 'react'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { LANGUAGE_CODES } from '../../constants'
import { getTextIn } from '../../common'
import { callApi } from '../../apiConnection'
const LanguageContext = createContext([[], () => {}])
@@ -26,6 +27,9 @@ export const LanguageProvider = ({ children }) => {
export default () => {
const [state, setState] = useContext(LanguageContext)
const getTextInWrapped = useCallback(text => getTextIn(text, state), [state])
const setLanguage = newLanguage => {
if (!LANGUAGE_CODES.includes(newLanguage)) {
throw new Error('Illegal language code passed to useLanguage hook!')
@@ -35,5 +39,9 @@ export default () => {
callApi('/language', 'post', { language: newLanguage })
}
return { language: state, setLanguage }
return {
language: state,
setLanguage,
getTextIn: getTextInWrapped,
}
}
@@ -0,0 +1,40 @@
import React from 'react'
import { Popup } from 'semantic-ui-react'
import { useSelector } from 'react-redux'
import { isCourseSelected, toggleCourseSelection } from 'components/FilterView/filters/courses'
import FilterToggleIcon from 'components/FilterToggleIcon'
import useFilters from 'components/FilterView/useFilters'
import { getTextIn } from '../../common'
const CourseFilterToggle = ({ course }) => {
const { language } = useSelector(({ settings }) => settings)
const { useFilterSelector, filterDispatch } = useFilters()
const isActive = useFilterSelector(isCourseSelected(course.code))
return (
<Popup
trigger={
<FilterToggleIcon
style={{ cursor: 'pointer' }}
isActive={isActive}
onClick={() => filterDispatch(toggleCourseSelection(course.code))}
/>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
)
}
export default CourseFilterToggle
@@ -1,24 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import GradeDistributionHeader from './GradeDistributionHeader'
import GradeDistributionRow from './GradeDistributionRow'
const gradeTypes = [1, 2, 3, 4, 5]
const GradeDistributionFlat = () => {
const { courseStatistics } = UsePopulationCourseContext()
return (
<Table celled sortable className="fixed-header">
<GradeDistributionHeader gradeTypes={gradeTypes} />
<Table.Body>
{courseStatistics.map(stats => (
<GradeDistributionRow key={stats.course.code} courseStatistics={stats} gradeTypes={gradeTypes} />
))}
</Table.Body>
</Table>
)
}
export default GradeDistributionFlat
@@ -1,39 +0,0 @@
import React from 'react'
import { arrayOf, number } from 'prop-types'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import SortableHeaderCell from '../SortableHeaderCell'
const GradeDistributionHeader = ({ gradeTypes }) => {
const { onSortableColumnHeaderClick, filterInput, reversed, sortCriteria, tableColumnNames } =
UsePopulationCourseContext()
return (
<Table.Header>
<Table.Row>
{filterInput('nameFilter', 'Name', '3')}
{filterInput('codeFilter', 'Code')}
<SortableHeaderCell
content="Attempts"
columnName={tableColumnNames.STUDENTS}
onClickFn={onSortableColumnHeaderClick}
activeSortColumn={sortCriteria}
reversed={reversed}
/>
<Table.HeaderCell>0</Table.HeaderCell>
{gradeTypes.map(g => (
<Table.HeaderCell content={g} key={g} />
))}
<Table.HeaderCell content="Other passed" />
</Table.Row>
</Table.Header>
)
}
GradeDistributionHeader.propTypes = {
gradeTypes: arrayOf(number).isRequired,
}
export default GradeDistributionHeader
@@ -1,122 +0,0 @@
import React from 'react'
import { Table, Popup, Item, Icon } from 'semantic-ui-react'
import { replace, sortBy, omit } from 'lodash'
import { Link } from 'react-router-dom'
import { shape, string, number, arrayOf } from 'prop-types'
import { getTextIn } from '../../../common'
import FilterToggleIcon from '../../FilterToggleIcon'
import useFilters from '../../FilterView/useFilters'
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import { useLanguage } from '../../../common/hooks'
const formatGradeDistribution = grades =>
replace(
JSON.stringify(
sortBy(
Object.entries(grades).map(([key, value]) => ({ [key]: value.count })),
o => -Object.keys(o)
),
null,
1
),
/\[\n|{\n*|{\s|}|\s*}|]|"|,/g,
''
)
const CourseRow = ({ courseStatistics, gradeTypes }) => {
const language = useLanguage()
const { useFilterSelector, filterDispatch } = useFilters()
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { course, grades } = courseStatistics
const { name, code } = course
const isActive = useFilterSelector(isCourseSelected(course.code))
let attempts = 0
let failedGrades = 0
let otherPassed = 0
if (grades) {
const countSumReducer = (acc, cur) => acc + cur.count
const gradeValues = grades ? Object.values(grades) : null
attempts = gradeValues.reduce(countSumReducer, 0)
failedGrades = gradeValues.filter(g => g.status.failingGrade).reduce(countSumReducer, 0)
otherPassed = Object.values(omit(grades, gradeTypes))
.filter(g => g.status.passingGrade || g.status.improvedGrade)
.reduce(countSumReducer, 0)
}
return (
<Table.Row active={isActive}>
<Popup
trigger={
<Table.Cell className="filterCell clickableCell">
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
</Table.Cell>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
<Table.Cell className="iconCell">
<p>
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
</Item>
</p>
</Table.Cell>
<Table.Cell content={code} />
<Table.Cell content={attempts} />
<Table.Cell content={failedGrades} />
{gradeTypes.map(g => (
<Table.Cell content={grades[g] ? grades[g].count || 0 : 0} key={code + g} />
))}
<Table.Cell content={otherPassed} />
</Table.Row>
)
}
const GradeDistributionRow = ({ courseStatistics, gradeTypes }) => {
const { course, grades } = courseStatistics
const { code } = course
return (
<Popup
key={code}
flowing
hoverable
inverted
position="top right"
hideOnScroll
content={grades ? <pre>{formatGradeDistribution(grades)}</pre> : 'Nothing to see here'}
trigger={<CourseRow courseStatistics={courseStatistics} gradeTypes={gradeTypes} />}
/>
)
}
CourseRow.propTypes = {
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
gradeTypes: arrayOf(number).isRequired,
}
GradeDistributionRow.propTypes = {
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
gradeTypes: arrayOf(number).isRequired,
}
export default GradeDistributionRow
@@ -1,49 +1,152 @@
import React from 'react'
import { Table, Icon } from 'semantic-ui-react'
import { instanceOf, func } from 'prop-types'
import { useSelector } from 'react-redux'
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Icon, Item } from 'semantic-ui-react'
import SortableTable, { group } from 'components/SortableTable'
import _ from 'lodash'
import { getTextIn } from '../../../common'
import CourseFilterToggle from '../CourseFilterToggle'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import GradeDistributionHeader from './GradeDistributionHeader'
import GradeDistributionRow from './GradeDistributionRow'
const gradeTypes = [1, 2, 3, 4, 5]
const mapCourseData = course => ({
name: course.course.name,
code: course.course.code,
attempts: _.chain(course.grades).values().map('count').sum().value(),
otherPassed: _.chain(course.grades)
.omit(_.range(0, 6))
.filter(g => g.status.passingGrade || g.status.improvedGrade)
.map('count')
.sum()
.value(),
grades: {
...course.grades,
0: {
count: _.chain(course.grades)
.filter(g => g.status.failingGrade)
.map('count')
.sum()
.value(),
},
},
})
const GradeDistribution = ({ expandedGroups, toggleGroupExpansion }) => {
const { modules } = UsePopulationCourseContext()
const GradeDistribution = ({ flat }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { language } = useSelector(({ settings }) => settings)
const columns = useMemo(
() => [
{
key: 'name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Name',
getRowVal: row => getTextIn(row.name),
cellProps: {
style: {
maxWidth: '20em',
whiteSpace: 'normal',
overflow: 'hidden',
},
},
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'code',
title: 'Code',
getRowVal: row => row.code,
},
{
key: 'stats',
noHeader: true,
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
children: [
{
key: 'attempts',
title: 'Attempts',
cellProps: { style: { textAlign: 'right' } },
filterType: 'range',
getRowVal: row => row.attempts,
},
..._.range(0, 6).map(grade => ({
key: `grade-${grade}`,
title: `${grade}`,
cellProps: { style: { textAlign: 'right' } },
filterType: 'range',
getRowVal: row => row.grades?.[grade]?.count ?? 0,
})),
{
key: 'other-passed',
title: 'Other Passed',
cellProps: { style: { textAlign: 'right' } },
filterType: 'range',
getRowVal: row => row.otherPassed,
},
],
},
],
[onGoToCourseStatisticsClick]
)
const data = useMemo(() => {
if (flat) {
return courseStatistics.map(mapCourseData)
}
return modules.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
module,
headerRowData: ({ definition: { module }, children }) => ({
name: module.name,
code: module.code,
attempts: _.chain(children).map('attempts').sum().value(),
otherPassed: _.chain(children).map('otherPassed').sum().value(),
grades: _.range(0, 6).map(grade => ({
count: _.chain(children)
.map(course => course.grades[grade]?.count ?? 0)
.sum()
.value(),
})),
}),
},
courses.map(mapCourseData)
)
)
}, [modules])
return (
<Table celled sortable className="fixed-header">
<GradeDistributionHeader gradeTypes={gradeTypes} />
<Table.Body>
{modules.map(({ module, courses }) => (
<React.Fragment key={module.code}>
<Table.Row>
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
<b>{getTextIn(module.name, language)}</b>
</Table.Cell>
<Table.Cell>
<b>{module.code}</b>
</Table.Cell>
<Table.Cell colSpan="8" />
</Table.Row>
{expandedGroups.has(module.code) &&
courses.map(course => (
<GradeDistributionRow key={course.course.code} courseStatistics={course} gradeTypes={gradeTypes} />
))}
</React.Fragment>
))}
</Table.Body>
</Table>
<>
<SortableTable title="Grade distribution of courses" data={data} columns={columns} />
</>
)
}
GradeDistribution.propTypes = {
expandedGroups: instanceOf(Set).isRequired,
toggleGroupExpansion: func.isRequired,
}
export default GradeDistribution
@@ -1,22 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import PassFailHeader from './PassFailHeader'
import PassFailRow from './PassFailRow'
const PassFail = () => {
const { courseStatistics } = UsePopulationCourseContext()
return (
<Table className="fixed-header" celled sortable>
<PassFailHeader />
<Table.Body>
{courseStatistics.map(stats => (
<PassFailRow key={stats.course.code} courseStats={stats} />
))}
</Table.Body>
</Table>
)
}
export default PassFail
@@ -1,48 +0,0 @@
import React from 'react'
import { Table } from 'semantic-ui-react'
import SortableHeaderCell from '../SortableHeaderCell'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
const PassFailHeader = () => {
const { onSortableColumnHeaderClick, filterInput, tableColumnNames, sortCriteria, reversed } =
UsePopulationCourseContext()
const getSortableHeaderCell = (label, columnName, rowSpan = 1) => (
<SortableHeaderCell
content={label}
columnName={columnName}
onClickFn={onSortableColumnHeaderClick}
activeSortColumn={sortCriteria}
reversed={reversed}
rowSpan={rowSpan}
/>
)
return (
<Table.Header>
<Table.Row>
<Table.HeaderCell colSpan="4" content="Course" />
{getSortableHeaderCell('Students', tableColumnNames.STUDENTS, 2)}
<Table.HeaderCell colSpan="3" content="Passed" />
<Table.HeaderCell colSpan="2" content="Failed" />
<Table.HeaderCell colSpan="2" content="Attempts" />
<Table.HeaderCell colSpan="2" content="percentage of population" />
</Table.Row>
<Table.Row>
{filterInput('nameFilter', 'Name', '3')}
{filterInput('codeFilter', 'Code')}
{getSortableHeaderCell('n', tableColumnNames.PASSED)}
{getSortableHeaderCell('after retry', tableColumnNames.RETRY_PASSED)}
{getSortableHeaderCell('percentage', tableColumnNames.PERCENTAGE)}
{getSortableHeaderCell('n', tableColumnNames.FAILED)}
{getSortableHeaderCell('many times', tableColumnNames.FAILED_MANY)}
{getSortableHeaderCell('n', tableColumnNames.ATTEMPTS)}
{getSortableHeaderCell('per student', tableColumnNames.PER_STUDENT)}
{getSortableHeaderCell('Passed', tableColumnNames.PASSED_OF_POPULATION)}
{getSortableHeaderCell('attempted', tableColumnNames.TRIED_OF_POPULATION)}
</Table.Row>
</Table.Header>
)
}
export default PassFailHeader
@@ -1,84 +0,0 @@
import React from 'react'
import { Table, Icon, Popup, Item } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { shape, string } from 'prop-types'
import { getTextIn } from '../../../common'
import FilterToggleIcon from '../../FilterToggleIcon'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import useLanguage from '../../LanguagePicker/useLanguage'
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
import useFilters from '../../FilterView/useFilters'
const PassFailRow = ({ courseStats }) => {
const { language } = useLanguage()
const { useFilterSelector, filterDispatch } = useFilters()
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { course, stats } = courseStats
const { code, name } = course
const {
failed,
passed,
retryPassed,
failedMany,
attempts,
percentage,
perStudent,
passedOfPopulation,
triedOfPopulation,
} = stats
const isActive = useFilterSelector(isCourseSelected(course.code))
return (
<Table.Row key={code} active={isActive} data-cy={name.fi}>
<Popup
trigger={
<Table.Cell className="filterCell clickableCell">
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
</Table.Cell>
}
content={
isActive ? (
<span>
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
) : (
<span>
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
</span>
)
}
position="top right"
/>
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
<Table.Cell className="iconCell clickableCell">
<p>
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
code
)}"]&separate=false&unifyOpenUniCourses=false`}
data-cy={`coursestats-link-${code}`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
</Item>
</p>
</Table.Cell>
<Table.Cell content={code} />
<Table.Cell content={passed + failed} />
<Table.Cell content={passed} />
<Table.Cell content={retryPassed} />
<Table.Cell content={`${percentage.toFixed(2)} %`} />
<Table.Cell content={failed} />
<Table.Cell content={failedMany} />
<Table.Cell content={attempts} />
<Table.Cell content={perStudent.toFixed(2)} />
<Table.Cell content={`${passedOfPopulation.toFixed(2)} %`} />
<Table.Cell content={`${triedOfPopulation.toFixed(2)} %`} />
</Table.Row>
)
}
PassFailRow.propTypes = {
courseStats: shape({ course: shape({ code: string }) }).isRequired,
}
export default PassFailRow

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