Compare commits

...
384 Commits
Author SHA1 Message Date
joonas-a 25b6de49c0 Delete dead code 2025-08-15 18:37:54 +03:00
joonas-a c6903ebe25 [Faculties][By starting year] Fix excel export not connecting data with correct year 2025-08-15 18:23:37 +03:00
joonas-a 2d2c25b763 [Backend][Faculties] minor readability improvement 2025-08-15 18:21:50 +03:00
Santeri Rajamäki d232312365 [Frontend] Unify GeneralTab 2025-08-15 12:50:27 +03:00
joonas-a f26f114b1d [Faculties] typing 2025-08-15 12:43:55 +03:00
Santeri Rajamäki 7f8a1bdb0d [Populations] Replace default expanded in accordions with expanded 2025-08-15 10:27:45 +03:00
Santeri Rajamäki 8d0dcc5e1a [Populations] Add default expanded to accordions with content 2025-08-14 13:32:34 +03:00
joonas-a c7173b34ec [Credit stats] Bring types to students 2025-08-14 12:11:07 +03:00
Santeri Rajamäki 361af09dc7 [SGG] Let group tags be undefined in formatting 2025-08-14 11:59:45 +03:00
Santeri Rajamäki f6db93ee67 [Frontend] Course population link QoL 2025-08-14 11:23:17 +03:00
Santeri Rajamäki dee45df9b1 [Backend] Custom population students may not have the correct studyright
This feature wasn't tested widely enough. The studyright *should* always be correct for class populations.
This does not gurantee that the correct studyright is found for anyone in the custom populations.
2025-08-13 16:51:47 +03:00
joonas-a 9d53e3343e [Lint] fix 2025-08-13 16:41:31 +03:00
joonas-a 86083ecc0b [Student statistics] Update tooltip 2025-08-13 16:33:29 +03:00
joonas-a 693de4a505 [Student statistics] Add absent to status col 2025-08-13 16:29:16 +03:00
Santeri Rajamäki 8ad4be048d [Ooditable] Make pagination sizes consistent 2025-08-13 16:16:06 +03:00
Santeri Rajamäki 293c104f49 [RTK] Explicitly use react versions of RTK hooks 2025-08-13 15:34:12 +03:00
joonas-a 6e6fa1bad0 [Student statistics] Type tags table 2025-08-13 14:05:59 +03:00
joonas-a 59772a0dad [Student statistics] Type rtkapi and the view 2025-08-13 13:52:32 +03:00
joonas-a 15a64c8727 [General tab] Fix studyright status col showing inactive in place of active 2025-08-12 17:10:36 +03:00
Santeri Rajamäki fc6bd6f866 [CourseStatistics] Remove courseStatisticsUtils in favour of util 2025-08-12 17:07:54 +03:00
Santeri Rajamäki 11566d95c3 [E2E][CourseStatistics] Let users search as much as they want 2025-08-12 16:51:31 +03:00
Santeri Rajamäki f155979cb8 [Frontend] Move CourseStatistics utility functions to ./util 2025-08-12 16:42:11 +03:00
joonas-a 667a403c35 [Studyguidancegroups] Typing and make semester enrollments start at given year if exists 2025-08-12 16:27:49 +03:00
Santeri Rajamäki 34aaba9444 [LINT] Remove unused imports 2025-08-12 16:17:17 +03:00
Santeri Rajamäki 38c84a0d6c [CourseStatistics] Diffuse selectors 2025-08-12 16:04:36 +03:00
Santeri Rajamäki 130da21892 [Backend] Improve population statistics by course max year calculations 2025-08-12 16:04:36 +03:00
joonas-a c160aa85ad [Backend/redux] add type to studyguidancegroups 2025-08-12 15:47:03 +03:00
joonas-a 8247c55545 Update comment 2025-08-12 14:56:12 +03:00
joonas-a 3c6cb9aaac [Semester enrollments fns] Fix for class stats + close to grad 2025-08-12 14:47:39 +03:00
joonas-a 313a050688 [General tab] Revisited column order 2025-08-12 12:06:45 +03:00
joonas-a d12de74d1b [General tab] simplified credits since column text 2025-08-12 11:56:25 +03:00
joonas-a 1d10107e0d [Study programmes] Fix search only working on programme code 2025-08-12 11:51:06 +03:00
joonas-a da5cee8e66 [Population search form] default to latest year instead of 2017 2025-08-12 10:55:47 +03:00
joonas-a d0ff477d13 [General tab] Increase pagination row counts 2025-08-12 10:49:39 +03:00
Santeri Rajamäki 5da6949a8a [E2E][Filters] Skip describe that is flaky 2025-08-12 09:25:32 +03:00
Santeri Rajamäki 025f0bc954 [E2E][Filters] Skip CI failiing test 2025-08-12 09:18:19 +03:00
joonas-a 8208f1ade5 Lint fix 2025-08-12 09:17:26 +03:00
joonas-a 648f11ea88 [Population search] make 2017 lower limit for year selector 2025-08-11 14:59:02 +03:00
joonas-a f1435aac7f [General tab] Fix custom pop & show absent 2025-08-11 12:07:23 +03:00
joonas-a f356d5cb7d [General tab] Show absent (sgg) 2025-08-08 14:50:04 +03:00
joonas-a 3bb2c8e88c [General tab] Show absent in status col (class statistics) 2025-08-08 14:40:53 +03:00
Joonas AhovalliandGitHub e38b0bb0ae Merge pull request #4851 from UniversityOfHelsinkiCS/eslint-improvement
Attempt to gain more value from linter
2025-08-07 16:26:16 +03:00
joonas-a 77a84f479c Revert type:module on main package json 2025-08-07 15:54:45 +03:00
joonas-a 9b55cf5455 [Cypress] config fix 2025-08-07 15:29:02 +03:00
joonas-a 47dc180145 [Frontend] Fix tsc errors 2025-08-07 15:17:08 +03:00
joonas-a f0e161359e [Eslint] cypress lint 2025-08-07 15:06:45 +03:00
joonas-a f4bf29c717 [Eslint] fixes and frontend lint 2025-08-07 15:05:22 +03:00
joonas-a 50a21143e8 [Eslint] backend 2025-08-07 15:05:22 +03:00
joonas-a d208012f07 [Eslint] run --fix + fix shared 2025-08-07 15:05:22 +03:00
joonas-a 40c3cf7ca1 [tsconfig] revert composite+references for eslint 2025-08-07 15:05:22 +03:00
joonas-a b432e1b359 [Eslint] better config 2025-08-07 15:05:22 +03:00
joonas-a 7762b81cb8 [tsconfigs] try one main config with references 2025-08-07 15:05:22 +03:00
joonas-a 55c2d94b3f [Meta] use type:module in main package.json 2025-08-07 15:05:22 +03:00
joonas-a b7d3d6580d [Eslint] cleanup services/** 2025-08-07 15:05:22 +03:00
joonas-a f9525d7757 [Eslint/frontend] silence no-unused-modules in js/jsx 2025-08-07 15:05:22 +03:00
joonas-a bf28e8e013 [Vite] include conf file in tsconfig 2025-08-07 15:05:22 +03:00
joonas-a 5bd0d291a6 [Eslint] Replace eslint-plugin-import 2025-08-07 15:05:22 +03:00
joonas-a 499b459807 [Eslint] Rework configuration 2025-08-07 15:05:22 +03:00
Santeri Rajamäki 811641e0c3 [E2E] Redefine init visit 2025-08-05 14:36:56 +03:00
Santeri Rajamäki add0074205 [E2E] Move getEmptyYears to the only file using the function 2025-08-05 14:29:39 +03:00
Santeri Rajamäki 586c297ada [E2E] Mark some flaky tests 2025-08-05 14:12:10 +03:00
Santeri Rajamäki 99984f5aa0 [E2E] Fix uncaught error in range filter checking function 2025-08-05 13:38:07 +03:00
Santeri Rajamäki 262c5b253c [E2E] Get headers once instead on each request after visit 2025-08-05 13:35:06 +03:00
Santeri Rajamäki 329899b089 [Backend] Added more details to unauthorized request errors 2025-08-05 13:03:56 +03:00
Santeri Rajamäki c9774a80d4 [E2E][Filters] Convert range selector function to Cypress command 2025-08-05 12:54:23 +03:00
Santeri Rajamäki 40e675b147 [E2E] Apply init on all urls and method within localhost 2025-08-05 12:46:17 +03:00
Santeri Rajamäki 4c73a1dbed [E2E][Filters] Remove unused functions 2025-08-05 12:09:01 +03:00
Santeri Rajamäki 21d6187c38 [E2E] Temp remove wait from initURL 2025-08-04 19:40:41 +03:00
Santeri Rajamäki 8c18bbc942 [Frontend] Make a Backdrop material component 2025-08-04 19:34:30 +03:00
Santeri Rajamäki c02fe5af2a [Filters] Add label tag to FilterSelect InputLabel 2025-08-04 19:28:57 +03:00
Santeri Rajamäki 32b0c8a3db [E2E][Filters] Add multiselect switch to FilterSelect select function 2025-08-04 19:26:56 +03:00
Santeri Rajamäki ca4bb11d12 [E2E][Filters] Skip incorrectly working "not included" check 2025-08-04 19:25:52 +03:00
Santeri Rajamäki fed71d4435 [E2E][Filters] Use Mocked date correctly 2025-08-04 19:25:15 +03:00
Santeri Rajamäki d23a0e67a4 [E2E] Replace the only instance of non-initiated .init param 2025-08-04 14:49:58 +03:00
Santeri Rajamäki 6d37b116cf [E2E] Force init to act on all localhost urls 2025-08-04 14:43:46 +03:00
Santeri Rajamäki 774b883513 [E2E][Filters] Use setSystemTime instead of setting clock on init 2025-08-03 03:38:35 +03:00
Santeri Rajamäki 18aa9ded2d [E2E][Uni] Disabled failing unexplained test with fixed data 2025-08-03 03:24:54 +03:00
Santeri Rajamäki f423b48a7b [E2E] Force wait init page load before continuing tests 2025-08-03 03:10:28 +03:00
Santeri Rajamäki 97a0725740 [Students] Fix graph absence dates 2025-08-01 18:41:57 +03:00
Santeri Rajamäki 9789fe79e4 [Frontend] Fix CreditAccumulationGraphHighCharts button size issue 2025-08-01 18:22:53 +03:00
Santeri Rajamäki 3d6cce2593 [E2E][SGG] Skip table checks 2025-08-01 18:22:15 +03:00
Santeri Rajamäki 4b9e1850b1 [E2E][Population statistics] Fix Graduation check 2025-08-01 18:21:29 +03:00
Santeri Rajamäki 9ad7452334 [Students] Fix graph start date 2025-08-01 18:20:46 +03:00
Santeri Rajamäki 2c9bf88270 [E2E][Filters] Rebuild the whole suite and fix minor bugs in filter cards 2025-08-01 17:33:26 +03:00
Santeri Rajamäki bc36f0da91 [GeneralTab] Fix case where there is no primaryProgramme 2025-07-31 22:14:04 +03:00
Santeri Rajamäki a3187dcc53 [Frontend] Use standard timeout for all useDebounce calls 2025-07-31 22:08:15 +03:00
Santeri Rajamäki 757d2ad730 [Frontend] Tightened filter logic flow 2025-07-31 22:06:50 +03:00
Santeri Rajamäki 1c16621124 [Frontend] Separate auth loading from application state 2025-07-31 20:26:35 +03:00
Santeri Rajamäki 45cbcd58ad [Fronend] Remove RTKApi DevTools in non-development environments 2025-07-31 18:20:36 +03:00
Santeri Rajamäki 92f394b230 [Ooditable] Remove explicit any from ColumnDef 2025-07-31 15:14:39 +03:00
Santeri Rajamäki ac71464055 [Ooditable] Remove debug options from base config 2025-07-31 14:53:58 +03:00
Santeri Rajamäki 94af09d0d4 [E2E][SGG] Replace CS matching with inlined contains 2025-07-31 14:50:22 +03:00
joonas-a 99684923dc [Meta] bump dependencies 2025-07-31 11:40:18 +03:00
joonas-a ab63820736 [Updater] Fix cjs/esm compatibility on migrations 2025-07-30 15:55:14 +03:00
santeri0200andGitHub 7cd61602df Merge pull request #4848 from UniversityOfHelsinkiCS/sortabletablekiller
Replace MRT in GeneralTab
2025-07-29 19:44:39 +03:00
Santeri Rajamäki dacce9764e Fix more eslint errors 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 1c6176830c Run eslint --fix 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 23a600477f [Ooditable] extract columnDefinitions hook from useMemo per eslint/react rules 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 0127d41381 Move baseColumns to columnDefinitions 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 4a4a6efb0f Remove unused GeneralTab files 2025-07-29 19:35:14 +03:00
Santeri Rajamäki efc773141b Fix lint error in semestersPresent 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 89aa2b6931 [Ooditable] Replace MRT as GeneralTab implementation 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 7f01fb688b [Ooditable] Returned ability to associate credits since 2025-07-29 19:35:14 +03:00
Santeri Rajamäki ccca69ce82 [Ooditable] Let state fall trough to make React happy with its' hooks 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 10f09577a0 Fix eslint unresolvable errors 2025-07-29 19:35:14 +03:00
Santeri Rajamäki bf38fe33ab Change degreeProgrammeType to the real implementation 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 427d2cdd4d Run eslint --fix again 2025-07-29 19:35:13 +03:00
Santeri Rajamäki a61876657f Fix eslint errors 2025-07-29 19:35:13 +03:00
Santeri Rajamäki f74f319c89 Rename format function per react/eslint rules 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 6ebbf32e1f Run prettier on frontend 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 395eab9b58 [Ooditable] Add excel export 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d99ff8ee50 [Ooditable] Cleanup 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 94042a7ea6 [Ooditable] Add support for Studyguidancegroup populations 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d9c869e13f [Ooditable] Rework CustomPopulation formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 8a718554ea [Ooditable] Rework CoursePopulation formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki c8036892c6 [Ooditable] Reorganize PopulationDetails columns and formatting fields 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d664a19c10 [Ooditable] Rework PopulationDetails formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 28e2316f04 [Ooditable] Add support for Custom population 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 0e48735748 [Ooditable] Add support for Course populations 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 67787daccf [Ooditable] Remove unnecessary conditional statement 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d7829a0cfc [Ooditable] Fixup combinedProgrammes column visibility 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 2424b115f5 [Ooditable] Minor tweaks to DOM tree and variable names 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 3ebe68998b [Ooditable] Pass columns and formatting to PopulationStudents 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 6947d1b858 [GeneralTab] Redo column definitions 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 629cec7560 [GeneralTab] Replace formatting file 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 094f625eb5 [MRT] Make formatting better 2025-07-29 19:35:12 +03:00
Santeri Rajamäki b7720f620c [Ooditable] Rename baseColumns to a use hook 2025-07-29 19:35:12 +03:00
Santeri Rajamäki 9e5acd3145 [OodiTable] Modularize 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 704265963c [OodiTable] Add page selector to bottom 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki b4bfcd1292 [OodiTable] Sticky headers, viewport based height and scrolling 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 302f0b3b33 [OodiTable] sticky studentnumber column 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 2ba108a34e OT: style sorting arrows 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 86d656dc4c OT: sorting 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 2e8c9c2a7e OT: two-story columns 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 730ac94c20 [OodiTable] Progress 2025-07-29 19:35:11 +03:00
joonas-aandSanteri Rajamäki 44584d1f7d [OodiTable] Skeleton 2025-07-29 19:35:11 +03:00
joonas-aandSanteri Rajamäki d93b708029 Initial commit 2025-07-29 19:35:11 +03:00
Santeri Rajamäki dcbc7a916e [Backend] Rewrite enrollment unwrapper 2025-07-29 19:33:45 +03:00
Santeri Rajamäki a7e3e45690 [Shared] Clarify that objects typed FormattedStudent may be obfuscated 2025-07-29 19:07:46 +03:00
Santeri Rajamäki 2404d9a42c [Backend] Readd student enrollments 2025-07-29 19:05:31 +03:00
Santeri Rajamäki 340912b5a1 [E2E] Fix eslint errors 2025-07-29 15:31:28 +03:00
joonas-a 7aaf584eaa [Cypress] Fix population statistics 2025-07-29 12:20:10 +03:00
joonas-a 57de5435bd [Cypress] Fix population statistics credit stats tests 2025-07-29 12:02:49 +03:00
joonas-a 1804bccefb [Cypress] Fix wrong statistics in course stats test 2025-07-29 10:44:57 +03:00
joonas-a 6461aaff4e [Frontend] Acually fix highest grade of course computation for ht/tt 2025-07-29 10:42:08 +03:00
joonas-a b32a0fb3fe [Frontend] Fix highest grade of course computation 2025-07-28 12:11:37 +03:00
joonas-a ad0ccbc98b [Lint] Fix enum comparison in general tab 2025-07-28 10:23:30 +03:00
joonas-a a1f3cb8fa8 [Frontend] Convert common.js to typescript 2025-07-25 17:20:37 +03:00
joonas-a b074b90553 [Filters] Rewrite gradefilter precompute 2025-07-25 14:42:00 +03:00
Santeri Rajamäki ce7a34e1d7 [Student statistics] Acually render enrollments correctly 2025-07-24 17:00:52 +03:00
joonas-a 314dcc7215 [Frontend] import images from assets instead of common 2025-07-24 15:53:00 +03:00
joonas-a 7c9c5508d0 [Credit statistics] Material UI distribution development tab 2025-07-24 15:30:25 +03:00
Santeri Rajamäki d6b289eef2 [Lint] Fix lint errors 2025-07-24 15:29:00 +03:00
Santeri Rajamäki eaaa55bba6 [Filters] Touch up filter types 2025-07-24 15:04:23 +03:00
Santeri Rajamäki 60fb3a8ae0 [Population students] Fix mappings 2025-07-24 14:23:51 +03:00
Santeri Rajamäki 208bd3511d [Backend] Acually type the formatted student data
This commit reworks the formatStatisticsForApi into near transportable form and adds types to the return value.
2025-07-23 20:04:22 +03:00
Santeri Rajamäki 3a9a5a69e4 [Backend] Add route types to routes/users 2025-07-23 19:31:02 +03:00
Santeri Rajamäki 3b8aa5bf56 [Frontend] Remove amount attribute from FilterSelect options 2025-07-23 14:21:55 +03:00
joonas-a 4e373d4ef2 [Credit statistics] Material UI statistics tab 2025-07-23 12:41:32 +03:00
joonas-a 219dc7ce47 [Credit statistics] Material UI tab component 2025-07-22 17:04:57 +03:00
joonas-a 9bc087818a [Credit statistics] convert filter toggle to tsx 2025-07-22 16:38:39 +03:00
joonas-a ad1998bcd0 [Age distribution] Use mui typography for consistency 2025-07-22 16:24:36 +03:00
joonas-a fb118094a9 [Percentage bar] Increase height 2025-07-22 16:17:04 +03:00
joonas-a dc9a7b3920 [Credits gained] add styling 2025-07-22 16:15:00 +03:00
joonas-a 5b5ec30458 [Credit statistics] Material UI credits gained table 2025-07-22 15:52:19 +03:00
joonas-a a7d30769b8 [Credit statistics] Material UI credits gained table container 2025-07-22 12:46:23 +03:00
joonas-a 442f2685e5 [Utils] make formatDate accept dayjs objects 2025-07-22 11:41:00 +03:00
joonas-a ae097d04b6 [Credit statistics] remove helper function for not being helpful 2025-07-22 11:01:54 +03:00
joonas-a e934157821 [Credit statistics] Material UI credits gained tab container 2025-07-22 10:51:15 +03:00
joonas-a db6cb155a0 [Credit statistics] Material UI main view 2025-07-21 15:48:31 +03:00
joonas-a 8c6ea09791 [Infobox] make it accept the sx prop 2025-07-21 15:26:26 +03:00
joonas-a 34ee583425 [Courses of class] Rename component import 2025-07-21 12:56:40 +03:00
joonas-a 592ea364f4 [Courses of class] Material UI selector 2025-07-21 12:53:47 +03:00
Santeri Rajamäki 2212af8d7e [Frontend] Convert transferredToProgramme filter to predefined MUI components 2025-07-18 20:51:02 +03:00
Santeri Rajamäki 27bba9f135 [Frontend] Convert tags filter to predefined MUI components 2025-07-18 20:46:37 +03:00
Santeri Rajamäki 42112bb36e [Frontend] Convert studyRightType filter to predefined MUI components 2025-07-18 20:23:38 +03:00
Santeri Rajamäki b1aed16f99 [Frontend] Convert studyRightStatus filter to predefined MUI components 2025-07-18 17:32:18 +03:00
joonas-a 238e6ea9ce [Population details] Material UI AgeStats 2025-07-18 17:16:51 +03:00
joonas-a 0682d90dce [Population statistics] small groundwork and renaming 2025-07-18 17:12:13 +03:00
Santeri Rajamäki bc26f1cf7b [Frontend] Convert studentNumber filter to predefined MUI components 2025-07-18 17:04:52 +03:00
Santeri Rajamäki 5b2c3d7fff [Frontend] Semesters present betterments 2025-07-18 16:38:45 +03:00
Santeri Rajamäki ffd7da54cc [Frontend] Convert programmes filter to predefined MUI components 2025-07-18 13:53:17 +03:00
Santeri Rajamäki 3b6b387c0d [Frontend] Extend FilterSelect with InputItem and MenuItem render options 2025-07-18 13:51:20 +03:00
Santeri Rajamäki a68d1f13c1 [Frontend] Convert hops filter to predefined MUI components 2025-07-18 11:12:56 +03:00
Santeri Rajamäki dbf838f5a5 [Frontend] Added FilterSwitch to common filter components 2025-07-18 11:12:56 +03:00
Santeri Rajamäki c37c9d0b99 [Frontend] Rename FilterRadioProp type 2025-07-18 11:12:56 +03:00
Santeri Rajamäki 24e478e7b4 [Frontend] Add default value checking to FilterRadio 2025-07-18 11:12:56 +03:00
joonas-a 2481713a0a [Class statistics] unmount loading spinner when finished loading 2025-07-18 11:09:18 +03:00
joonas-a 843a61d74a Remove unneeded code 2025-07-18 11:01:10 +03:00
joonas-a 783c46b62e [Population search] finish refactor 2025-07-18 10:59:02 +03:00
joonas-a ec55cc101f [Population search form] split studytrackselector 2025-07-17 17:26:57 +03:00
joonas-a 9698329ae2 [Population search form] split degreeprogrammeselector 2025-07-17 17:14:10 +03:00
joonas-a 191b8b4c3a [Population search form] split into parts 2025-07-17 16:01:19 +03:00
joonas-a b0d439c1be [Population search history] Use new query 2025-07-17 15:38:14 +03:00
Santeri Rajamäki 0f21a53381 [Frontend] Converted enrollmentStatus filter to use predefined MUI components 2025-07-16 21:05:07 +03:00
Santeri Rajamäki b2ff61f85b [Frontend] Remove nullish coalescing from FilterSelect value 2025-07-16 20:48:08 +03:00
Santeri Rajamäki ac7e3f2423 [Frontend] Converted course filter to use predefined MUI components 2025-07-16 20:33:15 +03:00
Santeri Rajamäki 46f316c82f [Frontend] Extended FilterSelect with disable option 2025-07-16 20:32:15 +03:00
Santeri Rajamäki 926a0d67d1 [Frontend] Converted graduatedFromProgramme to use predefined components 2025-07-16 19:46:25 +03:00
Santeri Rajamäki 4e319e801b [Frontend] Added FilterRadio to common filter components 2025-07-16 19:23:14 +03:00
Santeri Rajamäki 3182fa89d4 [Frontend] FilterSelect remove undefined from supported value type 2025-07-16 18:06:05 +03:00
Santeri Rajamäki 012686c49b [Frontend] Extend FilterSelect with multiple option 2025-07-16 18:02:11 +03:00
Santeri Rajamäki 9d5a927d86 [Frontend] Convert studyTrack filter to use predefined MUI components 2025-07-16 17:33:15 +03:00
Santeri Rajamäki 05f6a12871 [Frontend] Convert startYearAtUni filter to use predefined MUI components 2025-07-16 17:26:09 +03:00
Santeri Rajamäki 1ed659f93c [Frontend] Convert gender filter to use predefined components 2025-07-16 17:22:06 +03:00
Santeri Rajamäki 215002a86c Enable other valid value types in FilterSelect 2025-07-16 17:08:13 +03:00
Santeri Rajamäki dea0d29053 [Frontend] Convert curriculumPeriod filter t ouse predefined components 2025-07-15 16:52:26 +03:00
Santeri Rajamäki daac389968 [Frontend] Convert citizenship filter to use predefined components 2025-07-15 16:47:22 +03:00
Santeri Rajamäki 3e0ca1827a [Frontend] Migrate graduatedFromProgramme filter to MUI 2025-07-15 16:35:40 +03:00
Santeri Rajamäki fe2f73bd5c [Frontend] Migrate grade filter to MUI 2025-07-15 16:11:41 +03:00
Santeri Rajamäki d27a931c4e [Frontend] Migrate gender filter to MUI 2025-07-15 15:21:24 +03:00
Santeri Rajamäki 7f02d194d4 [Frontend] Migrate enrollmentStatus filter to MUI 2025-07-15 15:06:01 +03:00
Santeri Rajamäki 72e7519cb7 [Frontend] Migrate curriculumPeriod filter to MUI 2025-07-15 14:34:37 +03:00
Santeri Rajamäki a39b80946c [Frontend] Migrate citizenship filter to MUI 2025-07-15 14:26:57 +03:00
Santeri Rajamäki 9b7b7c7e2d [Frontend] Migrate FilterToggle to MUI 2025-07-15 13:32:41 +03:00
santeri0200andGitHub 73e69b5f57 Merge pull request #4834 from UniversityOfHelsinkiCS/filtering
Improve filtering
2025-07-14 20:13:26 +03:00
Santeri Rajamäki 6a4a7b539b [Frontend] Make filter actions less packed 2025-07-14 19:57:18 +03:00
Santeri Rajamäki a2dab9dbbe [Frontend] Use actions as intended 2025-07-14 19:43:46 +03:00
Santeri Rajamäki b06931b403 Reintroduce reset action to filters 2025-07-14 18:53:44 +03:00
Santeri Rajamäki 902af27091 Fix uncaught lint error 2025-07-14 18:46:26 +03:00
Santeri Rajamäki 6d1d8e90c1 [Frontend] Immediately execute filters before passing them to FilterView 2025-07-14 18:42:48 +03:00
Santeri Rajamäki 003ff2665e [Frontend] Rework FilterCard rendering logic 2025-07-14 18:42:07 +03:00
Santeri Rajamäki 56b87ba3a1 [Frontend] Cleanup view filter options, context and precompute values 2025-07-14 17:54:20 +03:00
Santeri Rajamäki ea94c510b0 [Frontend] Retype selectViewFilters and reorder FilterView filter counting 2025-07-14 17:21:55 +03:00
Santeri Rajamäki 97b82c71bc [Frontend] Filters now reset to default state (not initial) and setOptions options is now native 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 37710702a3 [Frontend] Separate filter selector and action types from factory scope 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 06dff4c3bc [Frontend] Remove unnecessary priority attribute from filters 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 47d8fef876 [Frontend] Add titles to all filters 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 267b79324c [Frontend] Do the todo in HOPS filter 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 88fc069110 [Frontend] Clarify filter contest 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 3ea74621db [Frontend] Reword FilterFactory 2025-07-14 16:21:53 +03:00
Santeri Rajamäki e88c544bb4 [Frontend] Clarify filter store 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 14d63f7009 [Frontend] Clarify filter selector wrapper type 2025-07-14 16:21:53 +03:00
Santeri Rajamäkiandmatiassinisalo 613aa14fd4 Make life easier
Co-authored-by: matiassinisalo <matias.aleksanteri@hotmail.fi>
2025-07-14 15:14:36 +03:00
Santeri Rajamäki d35e4309be Fix admission type test in filters suite 2025-07-11 17:21:29 +03:00
joonas-a 89e261c5f2 [Filters] mui select admission type 2025-07-11 16:36:55 +03:00
Santeri Rajamäki df8a257030 Rework columnHelpers createMap 2025-07-11 11:52:33 +03:00
joonas-a db391fb15d [Cypress] Filters 2025-07-10 16:59:11 +03:00
santeri0200andGitHub 7f96288849 Added QLTY badge for the project 2025-07-10 16:38:00 +03:00
joonas-a c64ba8ce29 [Filters] Material UI filter range v2 2025-07-10 16:31:49 +03:00
joonas-a 1a1d55bf93 [Filters] Material UI age filter 2025-07-10 15:29:43 +03:00
joonas-a 44b47331bb [Navbar] Fix hitboxes on tab items with dropdown 2025-07-10 13:40:12 +03:00
joonas-a bcaa0f498f [Class statistics] add loading spinner 2025-07-10 13:13:40 +03:00
joonas-a 59827d440a [Population details] cleanup 2025-07-10 12:13:27 +03:00
joonas-a 7f9b472e30 [Sortable Table] Remove crypto random uuid 2025-07-10 12:06:04 +03:00
joonas-a 95141e652e [Cypress] population statistics 2025-07-10 12:04:59 +03:00
joonas-a cf2a7e56e0 [Cypress] maybe fix study programme overview 2025-07-10 11:23:36 +03:00
joonas-a e50a6d5577 [Filters] Minor cleanup 2025-07-10 10:26:41 +03:00
joonas-a d1c41972f7 [Population search] styling 2025-07-09 16:33:12 +03:00
joonas-a cebee9d8cf [CI] fix cy 2025-07-09 16:15:30 +03:00
joonas-a 3f7b62015e [Population details] Material UI 2025-07-09 14:51:06 +03:00
Santeri Rajamäki e4e5a9d075 Remove analytistics tools from repo
qltysh allows us to use a _web editor_ for the config
2025-07-08 17:53:06 +03:00
joonas-a 435209b742 [Filters] Fix admissiontypefilter react issue 2025-07-08 17:42:00 +03:00
Santeri Rajamäki 866c5a64e4 Added qlty config 2025-07-08 17:36:15 +03:00
joonas-a 5f20d7a344 Remove unused code 2025-07-08 17:10:41 +03:00
joonas-a 6dcfadb68e [Material UI] FilterTray & FilterCard 2025-07-08 17:06:50 +03:00
Santeri Rajamäki 192171f270 [Frontend] Admission type for all 2025-07-08 17:00:15 +03:00
Santeri Rajamäki 0538329d60 Fix 2025-07-08 16:44:23 +03:00
Santeri Rajamäki 5b458933d2 [Frontend] Rework PopulationStudents 2025-07-08 16:35:08 +03:00
Santeri Rajamäki fdbed2f1cf Fk MUI part 2 2025-07-08 14:34:20 +03:00
Santeri Rajamäki cc73d5983e Hotfix population urls 2025-07-08 14:19:39 +03:00
Santeri Rajamäki e69e8dbe02 Update hardcoded populations query urls 2025-07-08 14:13:16 +03:00
Santeri Rajamäki ab460b0e9f Fix allSemesters indexing 2025-07-08 14:09:28 +03:00
Santeri Rajamäki b1d8e852df Update student hardcoded populations redirect 2025-07-08 13:16:04 +03:00
Santeri Rajamäki d5dfed6942 Hotfix 2025-07-08 13:08:52 +03:00
Santeri Rajamäki 5d6c0500a3 Add docker env defaults to overwrite Cypress misbehaviour 2025-07-08 13:02:20 +03:00
Santeri Rajamäki 2b58b9ba91 Fix date picker tests 2025-07-08 12:33:47 +03:00
Santeri Rajamäki 9bb72b2f0e Fix semestersPresent currentSemester is null issue 2025-07-08 11:58:26 +03:00
Santeri Rajamäki ad73cd7d0f [Shared] Fix omitKeys 2025-07-08 11:25:28 +03:00
Santeri Rajamäki 83551edacf Fix class size text capitalization 2025-07-08 10:20:07 +03:00
Santeri Rajamäki a0ee324fe7 [Frontend] Remove unused common file 2025-07-08 10:08:02 +03:00
Santeri Rajamäkiandtfhuhtal fb221edd87 Fix Cypress service provider error
Co-authored-by: tfhuhtal <tuomas.huhtala@icloud.com>
2025-07-08 09:55:45 +03:00
joonas-a 64c61d1be2 [Population statistics] convert info card to MUI 2025-07-07 17:43:31 +03:00
joonas-a 216083a0d4 Fix rebase conflict with tags 2025-07-07 16:41:57 +03:00
joonas-a a04789094e [Population details] Use new query 2025-07-07 16:08:21 +03:00
joonas-a 13db9cdb6d [Tags] Add safeguard for undefined 2025-07-07 16:08:21 +03:00
joonas-a 3b78e7ade3 [Population Link] Rework URLs 2025-07-07 16:08:21 +03:00
joonas-a 7cbc1dfb26 [Courses of class] use new query 2025-07-07 16:08:21 +03:00
joonas-a ab37b967ad [Class statistics] Start url streamlining 2025-07-07 16:08:01 +03:00
Santeri Rajamäki 5b15a2516f Make course statistics test less flaky 2025-07-07 06:26:44 +03:00
Santeri Rajamäki 56e6e88a9e [Frontend] Fix navbar key error 2025-07-07 02:20:43 +03:00
Santeri Rajamäki 3ed5aed710 [Frontend] Acually use Unarray in frontend 2025-07-07 02:12:41 +03:00
Santeri Rajamäki a0294b7750 [Shared] Fix lint error with isolatedModules 2025-07-07 01:51:29 +03:00
Santeri Rajamäki 9539d6b97b [Backend] Add route types to route/populations
Move around types to shared/
2025-07-07 01:31:15 +03:00
Santeri Rajamäki 578fef70a8 [Backend] Add route types to routes/customPopulationSearch 2025-07-07 00:40:57 +03:00
Santeri Rajamäki 31fac70a31 [Backend] Add route types to routes/curriculumPeriod 2025-07-07 00:24:32 +03:00
Santeri Rajamäki 9e631d119f [Backend] Add route types to routes/courses 2025-07-03 17:57:23 +03:00
Santeri Rajamäki ef0d95f83f [Backend] Add route types to routes/closeTograduation 2025-07-03 17:56:51 +03:00
Santeri Rajamäki e2774bfdc4 [Backend] Add route types to routes/login 2025-07-03 17:47:39 +03:00
Santeri Rajamäki dc65bb4571 [Backend] Root clean up 2025-07-03 16:59:18 +03:00
Santeri Rajamäki 48cb58deb7 Fix uncaught lint error 2025-07-03 16:27:54 +03:00
Santeri Rajamäki c65e4dadad [Frontend] Root clean up 2025-07-03 16:23:15 +03:00
Santeri Rajamäki 8388914406 [Frontend] Navbar improvments 2025-07-03 12:34:31 +03:00
Santeri Rajamäki 3e87dd0c79 [Frontend] Convert student view "Map" arrays to Maps 2025-07-03 11:29:44 +03:00
Santeri Rajamäki 81dda57671 [Frontend] Make FilterTray look nicer 2025-07-03 11:17:00 +03:00
Santeri Rajamäki 7670562b90 [Frontend] Upgrade DateSelector 2025-07-03 11:15:43 +03:00
Santeri Rajamäki df632591bb [Frontend] Rework TagsTab data fetching 2025-07-02 16:28:36 +03:00
Santeri Rajamäki ece01ad9d2 [Frontend] Rework semestersPresent 2025-07-02 15:31:30 +03:00
Santeri Rajamäki be1be25d00 [Frontend] Convert semestersPresent to use CSS based SVGs and MUI components 2025-07-02 13:04:02 +03:00
Santeri Rajamäki 4c553124bf [Frontend] Update Vite to version 7 2025-07-01 17:16:32 +03:00
Santeri Rajamäki a0e26d361f Fk MUI 2025-07-01 17:03:40 +03:00
Santeri Rajamäki 4bf5982062 Fix population search boxes in tests 2025-07-01 16:15:41 +03:00
Santeri Rajamäki d06d7b120b Fix open uni search tests 2025-07-01 15:48:01 +03:00
Santeri Rajamäki ad3ebe0464 Fix study guidance groups test 2025-07-01 15:09:12 +03:00
Santeri Rajamäki 76a7d40784 Fix already fixed tests 2025-07-01 14:54:38 +03:00
joonas-a ba4808aaf7 [Population search] tweaks & infoboxes 2025-07-01 14:27:21 +03:00
Santeri Rajamäki fdc97a4537 Fix population statistics tests 2025-07-01 12:57:36 +03:00
Santeri Rajamäki 73c3882751 Fix course statistics test 2025-07-01 12:57:36 +03:00
joonas-a d04c77e7d2 [Population search form] use MUI-based search history 2025-07-01 12:53:56 +03:00
Santeri Rajamäki 07fb9b7905 [Frontend] Convert searchHistory to use localStorage hook 2025-06-30 20:12:12 +03:00
Santeri Rajamäki 002d91613a Fix CI lint errors 2025-06-30 20:10:55 +03:00
Santeri Rajamäki 0a5332664b [Frontend] Convert PanelView to MUI 2025-06-30 19:41:33 +03:00
Santeri Rajamäki d72f1a8478 [Frontend] Students tab betterments 2025-06-30 19:40:25 +03:00
joonas-a 246ad05031 [Population search form] Material UI 2025-06-30 18:09:45 +03:00
Santeri Rajamäki 83b79fc9ea Fix semesters present not working 2025-06-30 16:50:49 +03:00
Santeri Rajamäki e8df57f895 Fix duration change to reflect fractional years in honours calculation 2025-06-30 12:04:48 +03:00
Santeri Rajamäki 040f8cad8f Remove dayjs duration and use diff as intended 2025-06-30 11:13:08 +03:00
Santeri Rajamäki 3cde980950 No moment to waste
Use dayjs instead of moment. We might want to explore some other alternatives in the future.
This commit closes #4800, #4811 and #4812

PopulationSearchForm is still waiting for @joonas-a
2025-06-27 14:58:20 +03:00
Santeri Rajamäki a2c665a501 Get all needed semester names at once
This closes #4781
2025-06-25 22:36:23 +03:00
Santeri Rajamäki ee99dcb449 [Frontend] Create a curriculum hook
Now setCurriculum isn't gatekept by curriculumPicker closing #4809
2025-06-25 21:44:26 +03:00
Santeri Rajamäki b4509f79cd Reduced curriculum response size 2025-06-25 19:38:24 +03:00
Santeri Rajamäki c7121795c3 [Frontend] Rework PopulationStatistics render chain 2025-06-25 16:51:28 +03:00
joonas-a 21e4e44713 [Population search] Rid momentjs 2025-06-25 16:25:37 +03:00
santeri0200andGitHub 37f93b328a Merge pull request #4833 from UniversityOfHelsinkiCS/populus
[Frontend]  Populationes optimizatae
- Reduce the amount of API calls
- Unify the calling convention to population APIs
- Make population API calls faster
2025-06-24 19:45:23 +03:00
Santeri Rajamäki ce0b42f88b Update lint action and updater workflow node versions to 22 2025-06-24 19:40:01 +03:00
Santeri Rajamäki dcd8ba77b3 Remove failing waits from tests 2025-06-24 19:30:03 +03:00
Santeri Rajamäki 6dc3bdb72a Make FilterCard more usable 2025-06-24 19:11:12 +03:00
Santeri Rajamäki ef80ee436d Cleanup FilterView 2025-06-24 19:11:12 +03:00
Santeri Rajamäki 6ad8981547 Refix credit accumulation 2025-06-24 19:11:12 +03:00
Santeri Rajamäki 3169c34864 Use correct API version for cypress route 2025-06-24 19:11:12 +03:00
santeri0200andGitHub 1d769c4fa2 Merge branch 'master' into populus 2025-06-24 17:38:14 +03:00
Santeri Rajamäki 11c6ab2db4 Rename FilterView course filter file 2025-06-24 17:36:39 +03:00
joonas-a b48ca23e9d Fix e2e 2025-06-24 17:24:02 +03:00
joonas-a 14da33f852 Fix lint errors 2025-06-24 17:03:14 +03:00
joonas-a 5e8beae9ed [Population search] MUI: 4 out of many 2025-06-24 16:49:10 +03:00
joonas-a 9676371823 [Filters] Fix Hops credit filter 2025-06-24 16:49:10 +03:00
joonas-a 5267cff110 [Filters] Add utils to transferred to programme filter 2025-06-24 16:49:10 +03:00
Santeri Rajamäki 647b2197fe Refix courses tests with enrollment calculations 2025-06-24 16:45:53 +03:00
Santeri Rajamäki 9f8048c0fb Fix semester loading and use strings in range selector tests 2025-06-24 16:29:28 +03:00
Santeri Rajamäki 3f9a245d16 Fix courses tests 2025-06-24 15:37:18 +03:00
Santeri Rajamäki ebfc16a07e Patch age filter behavior when reseting 2025-06-24 13:13:20 +03:00
Santeri Rajamäki 56ffaa6dd8 Fix population statistics waiting for courseData 2025-06-24 13:02:09 +03:00
Santeri Rajamäki e142fbb449 Fix population statistics incorrect total credits test 2025-06-24 13:01:12 +03:00
Santeri Rajamäki 219608293c Fix teacher statistics test 2025-06-24 12:16:47 +03:00
santeri0200andGitHub 8faa244c56 Merge branch 'master' into populus 2025-06-24 12:01:22 +03:00
Santeri Rajamäki dea230ecec Try resolve merge conflict
Rename PopulationQueryCard/index from jsx to tsx extension
2025-06-24 11:58:38 +03:00
Santeri Rajamäki 196bea2a58 [Backend] Make population fetching faster 2025-06-24 11:54:28 +03:00
Santeri Rajamäki 23dceab14c [Frontend] Fix tag filter 2025-06-24 10:54:21 +03:00
Santeri Rajamäki 7af0f28c14 Give population coursestats a type
Import the type to frontend
2025-06-23 20:50:41 +03:00
Santeri Rajamäki 9433aef65a [Backend] Convert student studyright lookup to a map 2025-06-23 20:33:30 +03:00
Santeri Rajamäki ad1455b03e Fix courses filter 2025-06-23 18:11:10 +03:00
Santeri Rajamäki 4f698b542e [Frontend] Consolidate semestry checking
This reduces unneccessary calls to API by one
2025-06-23 17:24:28 +03:00
joonas-a 4b8b042c2f [Population query card] Transform to use MUI 2025-06-23 14:55:26 +03:00
joonas-a f82ddf660c [Population search] MUI: 3 out of many 2025-06-23 14:14:59 +03:00
Santeri Rajamäki 54fec454f0 [Backend] Replace bottlenecksOf and courseStatsCounter
Fix some visible bugs with the new code in frontend
2025-06-23 13:32:01 +03:00
Santeri Rajamäki 610fab1c21 [Frontend] Move course filtering to FilterView 2025-06-20 18:22:32 +03:00
Santeri Rajamäki 2bbc041f22 Replace PopulationCourses request entirely
Remove courses-of-population request and bottlenecksOf, use optimizedStatisticsOf call to get the same data.
2025-06-19 17:39:24 +03:00
joonas-a 4efde9a997 Fix e2e 2025-06-19 16:48:29 +03:00
joonas-a be309972be Fix lint 2025-06-19 15:19:01 +03:00
Santeri Rajamäki 2603ef507a [Backend] Rework optimizedStatisticsOf and related functions 2025-06-19 15:11:39 +03:00
joonas-a 6334076698 [Population search] MUI: 2 out of many 2025-06-19 14:20:12 +03:00
joonas-a 3579580029 [Population search] MUI: 1 out of many 2025-06-19 13:07:49 +03:00
Santeri Rajamäki bede2502f5 [Backend] Remove unused course stats from OptimizedStatisticsOf 2025-06-19 10:04:10 +03:00
joonas-a 142f5c444f [Course statistics]: Fix missing programme from Study programmes col 2025-06-18 15:07:42 +03:00
joonas-a 43d11e615b [General tab] Status col: include info if cancelled studyright 2025-06-18 13:04:19 +03:00
joonas-a 2dcdae9796 [General tab] Add status column 2025-06-18 12:49:14 +03:00
joonas-a c0308f43fa [General tab] prepare for new column 2025-06-17 17:30:39 +03:00
Santeri Rajamäki 9f6606daa2 [Backend] Remove unused parameter from bottlenecksOf and PopulationCourses query 2025-06-17 14:00:48 +03:00
Santeri Rajamäki 5b7dda18ca [Frontend] FilterCourses is now a util function 2025-06-17 13:42:24 +03:00
Santeri Rajamäki 47685ac459 Remove unnecessary field from bottlenecksOf and PopulationCourses qurey 2025-06-17 13:25:26 +03:00
Santeri Rajamäki 54467fcdcf [Frontend] StudyguidanceGroups use CustomPopulation route to fetch population data 2025-06-17 13:07:04 +03:00
Santeri Rajamäki 83673a77f4 [Backend] Remove redundant field from bottlenecksOf call 2025-06-17 12:43:34 +03:00
Santeri Rajamäki ce244e1cb2 Move PopulationCourses types to shared and pass them to frontend
Additionally convert bottlenecksOf object to Map and reduce parsetime by ~17%
2025-06-16 19:50:53 +03:00
Santeri Rajamäki 8c20865e7a [Frontend] Unify courses fetching for all FilterView views 2025-06-16 16:18:30 +03:00
Santeri Rajamäki a0fdc68117 [Frontend] Force unique id on query result card
Using `.card` as a query us flaky...
2025-06-16 12:19:53 +03:00
Santeri Rajamäki 380c2f8154 Acually fix Population_statistics.js 2025-06-16 12:07:57 +03:00
Santeri Rajamäki 8922d1065f Move intercepts to be before visit 2025-06-13 23:38:10 +03:00
Santeri Rajamäki 4c7b52cfcb Wait for courses request before testing courses tab 2025-06-13 23:16:19 +03:00
Santeri Rajamäki 07bea28642 Touch up tests 2025-06-13 21:13:06 +03:00
Santeri Rajamäki e13a4da9ea Update PopulationStatistics tests to the new reality 2025-06-13 20:54:00 +03:00
Santeri Rajamäki 3f58a321a5 [Frontend] Use courses passed from parents in CoursesTab 2025-06-13 19:05:52 +03:00
Santeri Rajamäki b5cfc051b7 [Frontend] Consolidate PopulationCourse queries to one endpoint
This is long overdue.
2025-06-13 16:07:08 +03:00
Santeri Rajamäki eba3a77e2e [Frontend] Use the only query present at CoursesTab/index.jsx
This is a patch, since the query was present even though never used.
The patch should be replaced as mentioned in #4833
2025-06-13 15:42:38 +03:00
Santeri Rajamäki a20bdb5723 [Frontend] Move courses fetching to PopulationStatistics root
- Rework `redux/populationSelectedStudentCourses` to use RTKQuery
- Move primitive implementation of the query from `PopulationDetails/PopulationCourses/` to `PopulationStatistics/index.tsx`
2025-06-13 14:24:44 +03:00
520 changed files with 14451 additions and 14138 deletions
-82
View File
@@ -1,82 +0,0 @@
version: '2'
checks:
argument-count:
enabled: true
config:
threshold: 4
complex-logic:
enabled: true
config:
threshold: 4
file-lines:
enabled: true
config:
threshold: 250
method-complexity:
enabled: true
config:
threshold: 10
method-count:
enabled: true
config:
threshold: 20
method-lines:
enabled: true
config:
threshold: 25
nested-control-flow:
enabled: true
config:
threshold: 4
return-statements:
enabled: false
similar-code:
enabled: true
config:
threshold: # Language-specific defaults. Overrides affect all languages.
identical-code:
enabled: true
config:
threshold: # Language-specific defaults. Overrides affect all languages.
exclude_patterns:
- '.github/'
- '.husky/'
- '.vscode/'
- 'adminer-plugins/'
- 'anonyymioodi/'
- 'cypress/'
- 'documentation/'
- 'scripts/'
- 'services/backend/src/database/migrations_kone/'
- 'services/backend/src/database/migrations_user/'
- 'services/backend/src/models/'
- 'updater/sis-updater-worker/src/db/migrations/'
- 'updater/sis-updater-worker/src/db/models/'
- '.codeclimate.yml'
- '.env.template'
- '.lintstagedrc.js'
- '.nycrc'
- '.prettierrc'
- '.stylelintrc'
- 'cypress.config.js'
- 'LICENSE'
- 'nginx.conf'
- 'nyc-config.js'
- '**/.dockerignore'
- '**/.eslintrc.json'
- '**/.gitignore'
- '**/dev.Dockerfile'
- '**/Dockerfile'
- '**/*.css'
- '**/*.csv'
- '**/*.html'
- '**/*.json'
- '**/*.md'
- '**/*.png'
- '**/*.sh'
- '**/*.sql'
- '**/*.svg'
- '**/*.txt'
- '**/*.yml'
+11 -4
View File
@@ -1,6 +1,13 @@
// ! If we don't have this file, running ESLint will fail because of the import/no-unused-modules rule
// ! See: https://github.com/import-js/eslint-plugin-import/issues/3079
// ! When this issue is resolved, this file can be removed
{
"ignorePatterns": ["**/node_modules/", "**/dist/", "**/build/"]
"ignorePatterns": [
"eslint.config.mjs",
"**/build/**",
"**/dist/**",
"**/node_modules/**",
"**/instrumented/**",
"**/coverage/**",
"nyc-config.cjs",
".lintstagedrc.mjs",
"cypress.config.js"
]
}
+1 -1
View File
@@ -6,7 +6,7 @@ runs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: 'Install dependencies'
shell: bash
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
+4 -3
View File
@@ -1,9 +1,10 @@
const path = require('path')
import { relative } from 'path'
const cwd = process.cwd()
const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodikone`
const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file))].join(' ')
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
module.exports = {
export default {
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
+2 -1
View File
@@ -6,5 +6,6 @@
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"trailingComma": "es5"
"trailingComma": "es5",
"endOfLine": "lf"
}
+1
View File
@@ -1,4 +1,5 @@
# Oodikone
[![Maintainability](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone/maintainability.svg)](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
+3 -4
View File
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-commonjs */
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable import/no-unused-modules */
/* eslint-disable import-x/no-extraneous-dependencies */
/* eslint-disable import-x/no-default-export */
const { defineConfig } = require('cypress')
module.exports = defineConfig({
experimentalStudio: process.env.NODE_ENV === 'development',
projectId: 'c3jsph',
defaultCommandTimeout: 30000,
requestTimeout: 30000,
File diff suppressed because it is too large Load Diff
+396 -391
View File
@@ -1,464 +1,469 @@
/// <reference types="cypress" />
const MOCKED_DATE = new Date('2024-08-30').getTime()
const setClockToMockedDate = () => {
const MOCKED_DATE = new Date('2024-08-30')
cy.clock(MOCKED_DATE, ['Date'])
}
const checkFilteringResult = studentCount => {
const checkFilteredStudentCount = studentCount => {
cy.contains(`Students (${studentCount})`)
}
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
// This function tries to click "x" on all selections inside dropdown matching given attribute
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
cy.cs(dataCyAttribute).click()
}
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').clear()
cy.cs(parentEl).cs('range-selector-min').find('input').type(min)
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
cy.cs(parentEl).cs('range-selector-max').find('input').type(max)
checkFilteringResult(expected)
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
cy.cs(parentEl).cs('range-selector-min').find('input').type(initialMin)
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
cy.cs(parentEl).cs('range-selector-max').find('input').type(initialMax)
})
})
}
// 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.
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
return (filterName, testStepFunctionToRun) => {
cy.cs(`${filterName}-filter-card`)
.invoke('attr', 'data-open')
.then(open => {
const getHeader = () => cy.cs(`${filterName}-header`)
if (open === 'false') {
getHeader().click({ force: true })
}
checkFilteringResult(amountWithoutFiltering)
testStepFunctionToRun()
checkFilteringResult(amountWithoutFiltering)
if (open === 'false') {
getHeader().click({ force: true })
}
})
}
}
describe("Population statistics with a master's programme", () => {
const pathToMathMSc2020 =
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"MH50_001"%7D&year=2020'
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 26
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToMathMSc2020)
})
const expectedBaMaStudents = 24
const expectedMasterOnlyStudents = 2
before(() => cy.init(pathToMathMSc2020))
it('Study track filter works', () => {
runTestStepWithPreAndPostParts('StudyTrack', () => {
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
checkFilteringResult(15)
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
})
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 0)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
cy.cs('studyTrackFilter-header').click()
})
describe('Study right type filter', () => {
it('is visible', () => {
cy.contains('Study right type')
})
it('Study right type filter works', () => {
cy.cs('studyRightTypeFilter-header').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
it('is set to all by default', () => {
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
})
})
// Bachelor + master
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(expectedBaMaStudents)
// Master only
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(expectedMasterOnlyStudents)
// All
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
it('works', () => {
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
const expectedBaMaStudents = 24
const expectedMasterOnlyStudents = 2
cy.cs('studyRightTypeFilter-filter-card').cs('bama').click()
checkFilteringResult(expectedBaMaStudents)
cy.cs('studyRightTypeFilter-filter-card').cs('master').click()
checkFilteringResult(expectedMasterOnlyStudents)
cy.cs('studyRightTypeFilter-filter-card').cs('all').click()
checkFilteringResult(defaultAmountOfStudents)
})
})
cy.cs('studyRightTypeFilter-header').click()
})
})
describe('Population Statistics', () => {
const pathToMathBSc2020 =
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"KH50_001"%7D&year=2020'
describe.skip('Population Statistics', { testIsolation: false }, () => {
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 27
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
const graduated = 16
const transferred = 3
beforeEach(() => {
cy.init(pathToMathBSc2020)
})
before(() => cy.init(pathToMathBSc2020))
beforeEach(() => setClockToMockedDate())
it("Transfer filter is set to 'not transferred' by default", () => {
cy.contains('By default only students who have not transferred to this study programme are shown.')
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
})
})
it('Graduation filter works', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
const graduated = 16
getCard().cs('option-graduated-true').click()
checkFilteringResult(graduated)
getCard().cs('option-graduated-false').click()
checkFilteringResult(defaultAmountOfStudents - graduated)
getCard().cs('option-all').click()
})
})
it('Transfer filter works', () => {
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
const transferred = 3
cy.cs('TransferredToProgramme-filter-card').cs('option-have').click()
checkFilteringResult(transferred)
cy.cs('TransferredToProgramme-filter-card').cs('option-all').click()
checkFilteringResult(defaultAmountOfStudents + transferred)
cy.cs('TransferredToProgramme-filter-card').cs('option-havenot').click()
})
})
it('Enrollment filter works', () => {
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
checkFilteringResult(25)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
})
it('Credit filter works', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('CreditsEarned', () => {
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
})
})
it('Age filter works', { retries: 3 }, () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 23, 30, 21)
})
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(14)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(21)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
})
it('Admission type filter works', () => {
runTestStepWithPreAndPostParts('AdmissionType', () => {
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(18)
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(6)
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
})
})
it('Courses filter works', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
checkFilteringResult(10)
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
checkFilteringResult(8)
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
checkFilteringResult(6)
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
checkFilteringResult(5)
courses.forEach(({ code }) => {
cy.cs(`courseFilter-${code}-clear`).click()
})
})
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
runTestStepWithPreAndPostParts('Age', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
getCard().cs('option-graduated-true').click()
testRangeFilter('Age-filter-card', 23, 30, 12)
getCard().cs('option-all').click()
})
})
})
it('"Reset All Filters" button works', { retries: 3 }, () => {
cy.cs('Gender-header').click()
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('StartYearAtUni-header').click()
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(8)
cy.cs('reset-all-filters').click()
checkFilteringResult(defaultAmountOfStudents)
})
it('Study right type filter is not visible', () => {
it.skip('Study right type filter is not visible', () => {
cy.contains('Study right type').should('not.exist')
})
})
describe('Population Statistics with Bachelor + Master', () => {
const pathToMathBSc2017 =
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
const defaultAmountOfStudents = 47
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
// TODO: FilterRadio is not working correcly
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToMathBSc2017)
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
checkFilteredStudentCount(transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Graduation filter works', () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
cy.cs('graduatedFromProgrammeFilter-header').click()
getCard().cs('option-graduated-bachelor').click()
checkFilteringResult(42)
getCard().cs('option-graduated-master').click()
checkFilteringResult(18)
getCard().cs('option-not-graduated-bachelor').click()
checkFilteringResult(5)
getCard().cs('option-not-graduated-master').click()
checkFilteringResult(29)
getCard().cs('option-all').click()
})
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
})
it('Study right status filter works', () => {
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
it('Enrollment filter works', () => {
cy.cs('enrollmentStatusFilter-header').click()
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
checkFilteredStudentCount(25)
// First two are 0 because there are no semester enrollments for the current semester
getCard().cs('option-active').click()
checkFilteringResult(0)
getCard().cs('option-active-combined').click()
checkFilteringResult(0)
getCard().cs('option-inactive').click()
checkFilteringResult(5)
getCard().cs('option-inactive-combined').click()
checkFilteringResult(24)
getCard().cs('option-activity-status-all').click()
})
})
})
describe('Course Statistics', () => {
const pathToLimits2021 =
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
const defaultAmountOfStudents = 48
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToLimits2021)
cy.cs('enrollmentStatusFilter-clear').click()
cy.cs('enrollmentStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Grade filter works', () => {
runTestStepWithPreAndPostParts('Grade', () => {
cy.cs('gradeFilter-5').click()
checkFilteringResult(23)
cy.cs('gradeFilter-3').click()
checkFilteringResult(29)
cy.cs('gradeFilter-3').click()
cy.cs('gradeFilter-5').click()
})
it('Credit filter works', () => {
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
cy.cs('creditsEarnedFilter-clear').click()
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme defaults to "Attainment" mode', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
})
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 21)
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(33)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
})
it('Age filter works', { retries: 3 }, () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 21, 30, 36)
})
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(9)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(39)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(14)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(10)
checkFilteringResult(24)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(21)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Admission type filter works', () => {
cy.cs('admissionTypeFilter-header').click()
cy.selectFromDropdown('admissionTypeFilter', 0)
checkFilteredStudentCount(18)
cy.selectFromDropdown('admissionTypeFilter', 1)
checkFilteredStudentCount(6)
cy.cs('admissionTypeFilter-clear').click()
cy.cs('admissionTypeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-header').click()
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(9)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(7)
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
checkFilteredStudentCount(4)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(7)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Grade', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('gradeFilter-5').click()
checkFilteringResult(23)
testRangeFilter('Age-filter-card', 21, 30, 17)
cy.cs('gradeFilter-5').click()
})
})
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 23, 30, 12)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('"Reset All Filters" button works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(8)
cy.cs('reset-all-filters').click()
cy.cs('genderFilter-header').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Custom Population Statistics', () => {
let runTestStepWithPreAndPostParts
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
const pathToMathBSc2017 =
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 47
beforeEach(() => {
before(() => cy.init(pathToMathBSc2017))
it('Graduation filter works', () => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(42)
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(18)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(5)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
checkFilteredStudentCount(29)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Study right status filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('studyRightStatusFilter-header').click()
// Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Non-Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(5)
// Non-Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
checkFilteredStudentCount(24)
cy.cs('studyRightStatusFilter-clear').click()
cy.cs('studyRightStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
// FIXME: Flaky
describe.skip('Course Statistics', { testIsolation: false }, () => {
const pathToLimits2021 =
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
const defaultAmountOfStudents = 48
before(() => cy.init(pathToLimits2021))
beforeEach(() => setClockToMockedDate())
it('Grade filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(23)
cy.cs('gradeFilter-3').click()
checkFilteredStudentCount(29)
cy.cs('gradeFilter-3').click()
cy.cs('gradeFilter-5').click()
cy.cs('gradeFilter-clear').should('not.exist')
cy.cs('gradeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme filter works and defaults to "Attainment"', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(33)
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(9)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(39)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 10)
checkFilteredStudentCount(24)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(17)
cy.cs('gradeFilter-clear').click()
cy.cs('gradeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Custom Population Statistics', { testIsolation: false }, () => {
let defaultAmountOfStudents
before(() => {
cy.init('/custompopulation')
cy.cs('custom-pop-search-button').click()
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
defaultAmountOfStudents = students.length
cy.cs('student-number-input').click()
cy.cs('student-number-input').type(students.join('{enter}'))
cy.cs('search-button').click()
})
})
beforeEach(() => setClockToMockedDate())
it('Age filter works', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 24, 28, 5)
})
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 24, 28, 5)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme defaults to "Active Study Right" mode', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
})
})
it('Programme filter works and defaults to "Active Study Right"', () => {
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(3)
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(5)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(5)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(3)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(2)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(2)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = [
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
checkFilteringResult(7)
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
checkFilteringResult(6)
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
checkFilteringResult(5)
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
checkFilteringResult(2)
courses.forEach(({ code }) => {
cy.cs(`courseFilter-${code}-clear`).click()
})
})
const courses = [
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
cy.cs('courseFilter-header').click()
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(7)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(6)
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(2)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(6)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
+10 -7
View File
@@ -6,14 +6,13 @@ const hasLanded = () => {
}
const chooseSemester = (semester, fromOrTo) => {
cy.get(`[data-cy="semester-${fromOrTo}"]`)
.click()
.within(() => {
cy.get('div.visible.menu.transition').within(() => {
cy.contains('div.item', semester).click()
})
cy.get('div.menu.transition').should('not.have.class', 'visible')
cy.get(`[data-cy="semester-${fromOrTo}"]`).click()
cy.get(`[data-cy="semester-${fromOrTo}"]`).within(() => {
cy.get('div.visible.menu.transition').within(() => {
cy.contains('div.item', semester).click()
})
cy.get('div.menu.transition').should('not.have.class', 'visible')
})
}
const checkNumbers = (numbers, numberOfColumns, mode) => {
@@ -74,11 +73,13 @@ describe('When language center is opened', () => {
})
})
// FIXME: Flaky
it('Faculties tab "exceeding" button works', () => {
cy.get('[data-cy="difference-button"]').click()
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
})
// FIXME: Flaky
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
@@ -92,10 +93,12 @@ describe('When language center is opened', () => {
chooseSemester('Kevät 2024', 'to')
})
// FIXME: Flaky
it('Semester tab shows numbers', () => {
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
})
// FIXME: Flaky
it('Coloring mode works on semester tab', () => {
cy.contains('Compare to average of course').click()
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
+25 -23
View File
@@ -1,21 +1,18 @@
/// <reference types="cypress" />
const deleteAllSearches = () => {
cy.contains('Saved populations')
.parent()
.parent()
.parent()
.parent()
.get('.dropdown')
.then(dropdown => {
const searchItems = dropdown.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
cy.get('button').contains('Delete').click()
}
cy.cs('open-uni-search-button').click()
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
cy.get('@search').then($el => {
const searchItems = $el.find('div[role=option] > span[class=text]')
for (let i = searchItems.length - 1; 0 <= i; i--) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
cy.get('button').contains('Delete').click()
}
})
}
})
}
describe('Open uni population tests', () => {
@@ -26,18 +23,22 @@ describe('Open uni population tests', () => {
})
after(() => {
cy.init('/openunipopulation', 'admin')
cy.url().should('include', '/openunipopulation')
cy.contains('Open uni student population')
cy.get('[data-cy="open-uni-search-button"]').click()
deleteAllSearches()
})
it('Finds a proper population', () => {
// TODO: Rewrite the whole component
it.skip('Finds a proper population', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="begin-of-search"]').clear().type('01.01.2015')
cy.get('[data-cy="end-of-search"]').clear().type('01.01.2020')
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
cy.get('@textarea').type(courseCodesSet1.join('\n'))
cy.cs('begin-of-search').click()
cy.cs('begin-of-search').clear()
cy.cs('begin-of-search').type('01.01.2015')
cy.cs('end-of-search').click()
cy.cs('end-of-search').clear()
cy.cs('end-of-search').type('01.01.2020')
cy.get('button').contains('Search population').click()
cy.contains('Beginning of the search for all fields:')
cy.contains('01.01.2015')
@@ -79,9 +80,10 @@ describe('Open uni population tests', () => {
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="save-search"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('TEST-Avoimen-populaatio')
cy.get('button').contains('Search population').click()
cy.get('button').contains('Search population').as('btn')
cy.get('@btn').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
@@ -98,7 +100,7 @@ describe('Open uni population tests', () => {
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
cy.get('[data-cy="open-uni-search-button"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
+85 -87
View File
@@ -2,20 +2,24 @@
// Now "Class statistics" in UI
const selectStudyProgramme = programme => {
cy.contains('Select study programme')
cy.get('[data-cy=select-study-programme]').click()
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
}
const selectStudyTrack = studyTrack => {
cy.contains('Select study track')
cy.get('[data-cy=select-study-track]').click()
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
cy.cs('population-studytrack-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
)
cy.cs('population-studytrack-selector-parent').click()
cy.cs('population-studytrack-selector-parent').within(() => cy.contains(studyTrack).click())
}
describe('Population statistics tests', () => {
const getPath = programme => {
return `/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"${programme}"%7D&year=2020`
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
}
const pathToMathBSc2020 = getPath('KH50_001')
const pathToMathMSc2020 = getPath('MH50_001')
@@ -23,37 +27,40 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
it('Info box works', () => {
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
cy.get('[data-cy="PopulationSearch-open-info"]').click()
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
cy.get('[data-cy="PopulationSearch-info-content"]').should('contain', 'Tässä osiossa voi tarkastella')
cy.get('[data-cy="PopulationSearch-close-info"]').click()
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
cy.cs('PopulationSearch-info-box-content').should('not.exist')
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
cy.cs('PopulationSearch-info-box-content').should('be.visible')
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
cy.cs('PopulationSearch-info-box-content').should('not.exist')
})
it('Form is usable', () => {
// TODO: Delete MUI
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
cy.contains('Search for class')
cy.contains('See class').should('be.disabled')
cy.contains('Class of')
.parent()
.within(() => {
cy.get('.form-control').as('enrollmentSelect')
})
cy.get('@enrollmentSelect')
.its(`${[0]}.value`)
.then(beforeVal => {
cy.get('@enrollmentSelect').click()
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
})
cy.cs('population-year-selector').as('yearSelect')
cy.cs('population-year-decrement').as('yearDecrement')
cy.cs('population-year-increment').as('yearIncrement')
cy.contains('No study tracks available for this programme')
cy.get('@yearSelect').click()
cy.contains('2018 - 2019').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.get('@yearDecrement').click()
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
cy.get('@yearIncrement').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.cs('population-studytrack-selector').within(() => {
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
})
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.contains('See class').should('be.enabled')
selectStudyTrack('Matematiikka ja soveltava matematiikka')
@@ -61,21 +68,25 @@ describe('Population statistics tests', () => {
})
describe('Correct population is shown for programme', () => {
it('without study tracks', () => {
cy.contains('Select study programme')
// TODO: Delete MUI
it.skip('without study tracks', () => {
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('See class').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
cy.contains('class size 47 students')
cy.contains('Class size 47 students')
})
it('with study tracks', () => {
// TODO: Delete MUI
it.skip('with study tracks', () => {
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyTrack('Matematiikka ja soveltava matematiikka')
cy.contains('See class').click()
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
cy.contains('studytrack MAST-MSM')
cy.contains('class size 1 students')
cy.contains('Class size 1 students')
})
})
})
@@ -84,7 +95,7 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
@@ -128,7 +139,6 @@ describe('Population statistics tests', () => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.contains('Credits gained').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
@@ -143,8 +153,9 @@ describe('Population statistics tests', () => {
}))
}
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
cy.cs('credits-gained-table-All students of the class').within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
cy.get('th').eq(1).contains('(49 months)')
cy.get('th').eq(2).contains('Number of students')
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
cy.get('th').eq(3).contains('Percentage of population')
@@ -192,7 +203,7 @@ describe('Population statistics tests', () => {
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.cs('credit-statistics-tab').click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
@@ -219,7 +230,7 @@ describe('Population statistics tests', () => {
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h3', selector)
cy.contains('h5', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
@@ -261,47 +272,39 @@ describe('Population statistics tests', () => {
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
it('New fetch of courses data is done when curriculum is changed', () => {
it('Courses data is changed when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2020–2023').click({ force: true })
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
})
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
})
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('curriculum-picker').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2020–2023').click({ force: true })
cy.contains('Students (27)')
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
cy.contains('DIGI-100').should('exist')
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click({ force: true })
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.contains('DIGI-100').should('not.exist')
})
it('New fetch of courses data is done when filtered students change', () => {
it('Courses data is changed when filtered students change', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
})
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
cy.get('[data-cy=option-graduated-true]').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(16)
})
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
})
cy.contains('Students (16)')
})
})
@@ -327,6 +330,7 @@ describe('Population statistics tests', () => {
})
})
// FIXME: Flaky
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
@@ -400,29 +404,23 @@ describe('Population statistics tests', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
})
it('Population statistics is visible', () => {
cy.get('.card').within(() => {
cy.cs('PopulationQueryCard').within(() => {
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
})
it('Only correct panes are visible', () => {
const correctPanes = [
'Credit accumulation (for 27 students)',
'Credit statistics',
'Age distribution',
'Courses of class',
]
cy.get('.accordion.ui.fluid.styled').within(() => {
cy.get('.title').should('have.length', correctPanes.length)
correctPanes.forEach((pane, index) => {
cy.get('.title').eq(index).contains(pane)
})
it('Only correct panels are visible', () => {
cy.cs('panelview-parent').within(() => {
cy.cs('Credit accumulation (for 27 students)')
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of class')
})
})
+4 -5
View File
@@ -20,12 +20,13 @@ const typeStudentNumberAndClick = studentNumber => {
describe('Students tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init()
cy.init('')
cy.contains('Students').click()
cy.url().should('include', '/students')
cy.contains('Show student names')
})
// FIXME: Flaky
it('Students search form is usable', () => {
typeToSearch(student.lastName)
cy.contains('Student number')
@@ -159,12 +160,10 @@ describe('Students tests', () => {
cy.cs('study-rights-section').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => {
cy.get('a').click()
})
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
})
it('Grade graph works in all three different modes', () => {
+17 -14
View File
@@ -66,19 +66,19 @@ describe('Study guidance group tests', () => {
cy.cs('Credit accumulation (for 2 students)')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (2)')
cy.contains('Students (2)')
cy.cs('Credit statistics').should('not.exist')
})
it('students table has the correct tabs', () => {
cy.cs('Students (2)').click()
it.skip('students table has the correct tabs', () => {
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General'])
})
})
it('general tab of the students table has the correct columns', () => {
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
@@ -87,7 +87,7 @@ describe('Study guidance group tests', () => {
'Study programmes',
'Tags',
]
cy.cs('Students (2)').click()
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
@@ -110,28 +110,31 @@ describe('Study guidance group tests', () => {
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (3)')
cy.contains('Students (3)')
})
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
cy.cs('Credit accumulation (for 3 students)').click()
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="true"]')
cy.get('.ui.mini.icon.button.credit-date-filter-input').contains('01.08.2020')
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
cy.get('.date-picker')
.eq(0)
.within(() => cy.get('input').should('have.value', '01.08.2020'))
cy.contains('Reset All Filters')
cy.get('.ui.primary.button').contains('Show all credits').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
})
it('students table has the correct tabs', () => {
cy.cs('Students (3)').click()
it.skip('students table has the correct tabs', () => {
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
})
})
it('general tab of the students table has the correct columns', () => {
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
@@ -151,7 +154,7 @@ describe('Study guidance group tests', () => {
'Latest attainment date',
'Tags',
]
cy.cs('Students (3)').click()
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
+29 -11
View File
@@ -1,7 +1,19 @@
/* eslint-disable cypress/unsafe-to-chain-command */
/// <reference types="cypress" />
const { getEmptyYears } = require('../support/commands')
const getEmptyYears = isAcademicYear => {
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
const tagName = `tag-${new Date().getTime()}`
@@ -272,6 +284,10 @@ describe('Study programme overview', () => {
describe('Study tracks and class statistics tab works for basic user', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('study-tracks-and-class-statistics-tab').click()
@@ -357,13 +373,13 @@ describe('Study programme overview', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
cy.contains('class size 8 students')
cy.contains('Class size 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 227 students')
cy.contains('Class size 227 students')
})
it('Links to class statistics page with study track info included work', () => {
@@ -373,9 +389,9 @@ describe('Study programme overview', () => {
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 26 students')
cy.contains('3 students out of 26 shown')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
@@ -437,8 +453,9 @@ describe('Study programme overview', () => {
describe('Study track can be changed', () => {
beforeEach(() => {
cy.cs('study-track-select').click()
cy.wait('@stQuery')
cy.contains('All students of the programme')
cy.cs('study-track-select').click()
cy.contains('Ekonometria')
cy.contains('Tietojenkäsittelyteoria')
cy.contains('Tilastotiede')
@@ -464,9 +481,9 @@ describe('Study programme overview', () => {
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 30 students')
cy.contains('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
@@ -664,6 +681,7 @@ describe('Study programme overview', () => {
cy.cs('by-semester-tab').click()
})
// FIXME: Flaky
it('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
+2 -1
View File
@@ -51,7 +51,8 @@ describe('University view', () => {
checkProgressTables()
})
it('years in the tables can be clicked to show faculty level breakdown', () => {
// Nothing in this test is explained. Non of these values are verified.
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
cy.cs('study-right-toggle').click()
cy.cs('bachelors-faculty-progress-table-show-button3').click()
cy.contains('29.5%').trigger('mouseover', { force: true })
+12 -7
View File
@@ -72,14 +72,19 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.contains('label', 'Study programme')
.siblings()
.within(() => {
cy.get("div[role='option']").should('have.length', 2)
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
})
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('h6', 'Degree programme')
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
cy.cs('population-programme-selector-parent').within(() =>
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
)
})
})
})
-1
View File
@@ -15,7 +15,6 @@
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
+59 -43
View File
@@ -79,16 +79,19 @@ const userHeaders = [
]
/**
Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
*/
Cypress.Commands.add('init', (path = '', userId = 'basic') => {
cy.intercept('', req => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
cy.intercept({ hostname: 'localhost', url: '**', method: '*' }, req => {
req.headers = headersToUse
})
const { baseUrl } = Cypress.config()
cy.visit(baseUrl.concat(path))
// TODO: Add wait for visit url.
// This is block by course population query.
cy.visit({ method: 'GET', url: path, headers: headersToUse })
})
/**
@@ -104,27 +107,59 @@ Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
})
/**
* Select item specified by `index` number from a semantic-ui dropdown with
* `data-cy` attribute value `name`.
* Select item from FilterSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
const subject = cy.wrap(s)
const indexes = Array.isArray(index) ? index : [index]
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
cy.cs(`${label}-selector`).click()
indexes.forEach(i => {
subject.click()
if (typeof i === 'number') {
subject.children('.menu').children().eq(i).click({ force: true })
} else {
subject.children('.menu').containing(i).eq(0).click({ force: true })
}
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
// Close multiple selection so it does not block elements underneath it.
if (Array.isArray(index)) {
cy.wrap(s).children('.icon').click({ force: true })
}
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
})
/**
* Set range selector values FilterRange.
* If the reset flag is set, the function tries to reset the original values.
*/
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
const minText = min.toString()
const maxText = max.toString()
const expectedText = expected.toString()
const parent = `${filter}-filter-card`
cy.cs(parent)
.cs('FilterRangeStart')
.find('input')
.invoke('val')
.then(initialMin => {
cy.cs(parent)
.cs('FilterRangeEnd')
.find('input')
.invoke('val')
.then(initialMax => {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
// This should be linked to other checking functions.
// Maybe one day.
cy.contains(`Students (${expectedText})`)
if (reset) {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
}
})
})
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
@@ -142,22 +177,3 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
})
})
const getEmptyYears = isAcademicYear => {
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
module.exports = {
getEmptyYears,
}
-2
View File
@@ -13,11 +13,9 @@
// https://on.cypress.io/configuration
// ***********************************************************
// eslint-disable-next-line import/no-extraneous-dependencies
require('@cypress/code-coverage/support')
require('./commands')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Cypress.on('uncaught:exception', error => {
/* returning false here prevents Cypress from failing the test */
return true
+208 -135
View File
@@ -1,183 +1,256 @@
import pluginJs from '@eslint/js'
import globals from 'globals'
import pluginCypress from 'eslint-plugin-cypress/flat'
import pluginImport from 'eslint-plugin-import'
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import pluginReact from 'eslint-plugin-react'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import tsEslint from 'typescript-eslint'
// @ts-check
/** @type {import('eslint').Linter.Config[]} */
export default [
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'
import eslintPluginCypress from 'eslint-plugin-cypress'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginImportX from 'eslint-plugin-import-x'
import { createNodeResolver } from 'eslint-plugin-import-x'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
export default tseslint.config(
{
files: ['**/*.{js,ts,jsx,tsx}'],
},
{
ignores: ['eslint.config.mjs', '**/build/', '**/dist/', '**/node_modules/'],
// Global ignores
ignores: [
'eslint.config.mjs',
'**/build/**',
'**/dist/**',
'**/node_modules/**',
'**/instrumented/**',
'**/coverage/**',
'nyc-config.cjs',
'.lintstagedrc.mjs',
'cypress.config.js',
]
},
// Global options (excl. e2e)
{
ignores: ['cypress/**/*.js'],
extends: [
eslint.configs.recommended,
eslintPluginImportX.flatConfigs.recommended,
eslintPluginImportX.flatConfigs.typescript,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.{js,cjs}']
}
},
globals: {
...globals.browser,
...globals.node,
},
},
},
{
plugins: {
'import-x': eslintPluginImportX
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json',
],
},
},
},
pluginJs.configs.recommended,
...tsEslint.configs.recommendedTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
pluginCypress.configs.recommended,
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
pluginImport.flatConfigs.recommended,
pluginImport.flatConfigs.typescript,
pluginPrettierRecommended, // This must be the last plugin so it can override other configs
{
plugins: {
'react-hooks': pluginReactHooks,
},
},
{
rules: {
camelcase: 'off', // TODO: Enable this rule (at least for frontend)
'class-methods-use-this': 'error',
'consistent-return': 'error',
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
'import/no-commonjs': 'error',
'import/no-default-export': 'error',
'import/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import/no-unused-modules': ['error', { missingExports: true, unusedExports: true }],
'import/order': [
'error',
{
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
},
],
'no-alert': 'error',
'no-async-promise-executor': 'error',
'no-await-in-loop': 'error',
'no-console': 'error',
'no-implied-eval': 'error',
'no-param-reassign': ['error', { props: false }],
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
'no-param-reassign': 'error',
'no-promise-executor-return': 'error',
'no-return-assign': 'error',
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'react/display-name': 'off', // TODO: Delete this override
'react/function-component-definition': [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
],
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
'react/jsx-sort-props': ['error', { reservedFirst: false }],
'react/no-array-index-key': 'error',
'react/no-this-in-sfc': 'error',
'react/no-unescaped-entities': 'off',
'react/no-unknown-property': 'off', // TODO: Delete this override
'react/prefer-stateless-function': 'error',
'react/prop-types': 'off',
...pluginReactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/consistent-indexed-object-style': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-misused-promises': 'off', // Most of these errors come from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'import-x/no-commonjs': 'error',
'import-x/no-default-export': 'error',
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import-x/no-unused-modules': ['error',
{
missingExports: true,
unusedExports: true,
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
}],
'import-x/order': ['error', {
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
}],
// TypeScript
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
],
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/class-methods-use-this': 'error',
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/prefer-destructuring': ['error',
{ VariableDeclarator: { object: true } }
],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
'@typescript-eslint/no-implied-eval': 'error',
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
'@typescript-eslint/restrict-template-expressions': 'off',
},
},
{
files: ['**/*.{js,jsx}'],
rules: {
...tsEslint.configs.disableTypeChecked.rules,
'dot-notation': 'error',
settings: {
'import-x/internal-regex': '^@oodikone/shared/',
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json'
],
}),
createNodeResolver({
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
})
],
},
},
{
files: ['services/backend/src/routes/*.{ts,js}'],
rules: {
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/consistent-return': 'off',
}
},
// Disable type-aware linting for javascript files
{
files: ['{updater,services}/**/*.{js,cjs}'],
extends: [tseslint.configs.disableTypeChecked]
},
// Frontend camelCase and react
{
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
'react': eslintPluginReact,
},
extends: [
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
eslintPluginReactHooks.configs['recommended-latest'],
],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
}
}
},
rules: {
'camelcase': 'warn',
'react/prop-types': 'off', // Typescript but worse
'react/no-unescaped-entities': 'off',
'react/function-component-definition': ['error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/no-array-index-key': 'error',
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
'react/no-this-in-sfc': 'error',
'react/prefer-stateless-function': 'error',
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error',
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
],
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
'react/jsx-no-useless-fragment': 'error',
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
'react/jsx-sort-props': ['error', { reservedFirst: false }],
'react/jsx-tag-spacing': 'warn',
},
settings: {
'react': {
'version': 'detect'
}
}
},
// Cypress should only use rules provided by the plugin below
{
files: ['cypress/**/*.js'],
extends: [
eslintPluginCypress.configs.recommended
]
},
// Overrides for CommonJS
{
files: [
'services/backend/**/*.js',
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'updater/sis-updater-scheduler/**/*.js'
],
languageOptions: {
sourceType: 'commonjs',
},
rules: {
'import-x/no-commonjs': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Rapodiff
{
files: ['services/backend/src/rapodiff/**/*'],
rules: {
'no-console': 'off',
},
},
// Migration files
{
files: ['services/**/shared/**/*'],
files: [
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'services/backend/src/database/**/*.js'
],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
languageOptions: {
sourceType: 'commonjs',
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'import/no-commonjs': 'off',
'import/no-unused-modules': 'off',
'import-x/no-unused-modules': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/require-await': 'off',
},
},
// TODO: See if these can be disabled and or fix
{
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
rules: {
// TODO: Most of these overrides should probably be removed
'consistent-return': 'off',
'import/no-default-export': 'off',
'import/no-unused-modules': 'off',
'import-x/no-default-export': 'off',
'import-x/no-unused-modules': 'off',
'no-await-in-loop': 'off',
'no-param-reassign': 'off',
'no-promise-executor-return': 'off',
},
},
{
settings: {
'import/internal-regex': '^@oodikone/shared/',
'import/resolver': {
typescript: {
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json',
],
},
},
react: {
version: '18.3',
},
},
},
]
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
)
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
module.exports = {
+2611 -2806
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -49,7 +49,7 @@
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
@@ -64,7 +64,6 @@
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.30.1"
"lodash": "^4.17.21"
}
}
@@ -1,6 +1,6 @@
require('./src/util/sentry') // Sentry must be the first import
require('./src/app')
const logger = require('./src/util/logger')
import './src/util/sentry' // Sentry must be the first import
import './src/app'
import logger from './src/util/logger'
process.on('unhandledRejection', reason => {
logger.error({ message: 'unhandledRejection: ', meta: reason })
+7 -1
View File
@@ -14,11 +14,11 @@
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
@@ -2339,6 +2339,12 @@
"node": ">=12.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+1 -1
View File
@@ -13,11 +13,11 @@
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
@@ -1,16 +1,16 @@
require('express-async-errors')
const express = require('express')
import 'express-async-errors'
import express from 'express'
const { baseUrl, backendPort } = require('./config')
const { initializeDatabaseConnection, dbConnections } = require('./database/connection')
const { startCron } = require('./events')
const routes = require('./routes').default
const logger = require('./util/logger')
require('./worker/worker')
import { baseUrl, backendPort } from './config'
import { initializeDatabaseConnection, dbConnections } from './database/connection'
import { startCron } from './events'
import routes from './routes'
import logger from './util/logger'
import './worker/worker'
initializeDatabaseConnection()
.then(() => {
dbConnections.connect()
void dbConnections.connect()
dbConnections.on('connect', () => {
logger.info('Connected to sis db successfully')
-105
View File
@@ -1,105 +0,0 @@
// Node env to use
const isDev = process.env.NODE_ENV === 'development'
const isStaging = process.env.REACT_APP_STAGING === 'true'
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
const runningInCI = process.env.CI === 'true'
// IAM group
const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
const pateToken = process.env.PATE_API_TOKEN || ''
// Jami
const jamiUrl = process.env.JAMI_URL || ''
// Importer client
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
const importerDbApiUser = process.env.IMPORTER_DB_API_USER || ''
const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD || ''
// Sisu
const sisUrl = process.env.SIS_HOST
const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
// Networking: Urls & ports
const baseUrl = isDev ? '/api' : ''
const frontUrl = process.env.FRONT_URL
const backendPort = 8080
// System run in whose environment
const serviceProvider = process.env.SERVICE_PROVIDER ? process.env.SERVICE_PROVIDER.toLowerCase() : 'toska'
// Optional logout-url configuration
const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
const rootOrgId = process.env.ROOT_ORG_ID || 'hy-university-root-id'
const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
const concurrentWorkers = process.env.CONCURRENT_WORKERS ? parseInt(process.env.CONCURRENT_WORKERS, 10) : 2
let DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS, 10)
if (Number.isNaN(DB_MAX_CONNECTIONS)) {
DB_MAX_CONNECTIONS = 5 // sequelize's default
}
let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
if (DB_MAX_CRON_CONNECTIONS < 1) {
DB_MAX_CRON_CONNECTIONS = 1
}
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
const redis = process.env.REDIS
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
const KONE_PASSWORD = process.env.KONE_PASSWORD || 'postgres'
const USER_PASSWORD = process.env.USER_PASSWORD || 'postgres'
const SIS_PASSWORD = process.env.SIS_PASSWORD || 'postgres'
module.exports = {
DB_URL_USER,
CRYPT_KEY,
DB_URL_KONE,
DB_MAX_CONNECTIONS,
DB_MAX_CRON_CONNECTIONS,
CRON_SCHEDULE,
redis,
SECRET_TOKEN,
DB_SCHEMA_KONE,
KONE_PASSWORD,
USER_PASSWORD,
SIS_PASSWORD,
requiredGroup,
SIS_DB_URL,
SIS_UPDATER_URL,
runningInCI,
isProduction,
pateToken,
jamiUrl,
baseUrl,
frontUrl,
backendPort,
importerUrl,
importerToken,
importerDbApiUser,
importerDbApiPassword,
isDev,
isStaging,
rootOrgId,
serviceProvider,
configLogoutUrl,
sisUrl,
sisGrapqlAppAccount,
sisGrapqlAppKey,
languageCenterViewEnabled,
concurrentWorkers,
}
+60
View File
@@ -0,0 +1,60 @@
// Node env to use
export const isDev = process.env.NODE_ENV === 'development'
export const isStaging = process.env.REACT_APP_STAGING === 'true'
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
// IAM group
export const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
// Jami
export const jamiUrl = process.env.JAMI_URL ?? ''
// Importer client
export const importerUrl = process.env.IMPORTER_DB_API_URL ?? ''
export const importerToken = process.env.IMPORTER_DB_API_TOKEN ?? ''
export const importerDbApiUser = process.env.IMPORTER_DB_API_USER ?? ''
export const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD ?? ''
// Sisu
export const sisUrl = process.env.SIS_HOST
export const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
export const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
// Networking: Urls & ports
export const baseUrl = isDev ? '/api' : ''
export const frontUrl = process.env.FRONT_URL
export const backendPort = 8080
// System run in whose environment
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
// Optional logout-url configuration
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
export const DB_MAX_CRON_CONNECTIONS = Math.max(1, DB_MAX_CONNECTIONS - 5)
export const CRON_SCHEDULE = process.env.CRON_SCHEDULE ?? '0 23 * * *' // Default to 23:00 daily
export const redis = process.env.REDIS
export const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE ?? 'public'
export const KONE_PASSWORD = process.env.KONE_PASSWORD ?? 'postgres'
export const USER_PASSWORD = process.env.USER_PASSWORD ?? 'postgres'
export const SIS_PASSWORD = process.env.SIS_PASSWORD ?? 'postgres'
@@ -1,4 +1,7 @@
// Linter was not having any of this so needed to add resolveJsonModule:true to tsconfig
// export const { facultyCodes, ignoredFacultyCodes, magicFacultyCode } = require('../environment/organizationConfig.json')
export { facultyCodes, ignoredFacultyCodes, magicFacultyCode } from '../environment/organizationConfig.json'
import organizationConfig from '../environment/organizationConfig.json'
export const { facultyCodes } = organizationConfig
export const { ignoredFacultyCodes } = organizationConfig
export const { magicFacultyCode } = organizationConfig
+2
View File
@@ -1,3 +1,5 @@
// This is locked behind admin check.
// We probably want to keep it on the server side and not share it.
export const roles = [
'admin',
'courseStatistics',
+2 -2
View File
@@ -2,7 +2,7 @@ import EventEmitter from 'events'
import { Sequelize } from 'sequelize-typescript'
import { SequelizeStorage, Umzug } from 'umzug'
import conf from '../config'
import * as conf from '../config'
import {
CourseModel,
CourseProviderModel,
@@ -96,7 +96,7 @@ class DbConnection extends EventEmitter {
message: `Sis database connection failed! Attempt ${attempt}/${this.RETRY_ATTEMPTS}`,
meta: error,
})
setTimeout(() => this.connect(attempt + 1), 1000 * attempt)
setTimeout(() => void this.connect(attempt + 1), 1000 * attempt)
}
}
}
+6 -5
View File
@@ -106,14 +106,15 @@ const dailyJobs = async () => {
export const startCron = () => {
if (isProduction && !runningInCI) {
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
schedule(CRON_SCHEDULE, async () => {
schedule(CRON_SCHEDULE, () => {
logger.info('Running daily jobs from cron')
await dailyJobs()
void dailyJobs()
})
schedule('0 4 * * 3', async () => {
schedule('0 4 * * 3', () => {
logger.info("Deleting users who haven't logged in for 18 months")
const [, result] = (await deleteOutdatedUsers()) as { rowCount: number }[]
logger.info(`Deleted ${result.rowCount} users`)
const deletedOutdatedUsers = deleteOutdatedUsers() as Promise<{ rowCount: number }[]>
void deletedOutdatedUsers.then(([_, result]) => logger.info(`Deleted ${result.rowCount} users`))
})
schedule('0 19 * * 1', () => {
logger.info('Updating students whose studyplans have not been updated recently')
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/consistent-return */
import { NextFunction, Request, Response } from 'express'
import { Role } from '@oodikone/shared/types'
@@ -66,8 +66,9 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
if (!username) missingHeaders.push('uid')
if (missingHeaders.length > 0) {
const reqUser = username ?? 'Anonymous user'
throw new ApplicationError(
`Not enough data in request headers, the following headers were missing: ${missingHeaders.join(', ')}`,
`${reqUser} requested ${req.url} without valid request headers. Missing: ${missingHeaders.join(', ')}`,
403,
{ logoutUrl }
)
@@ -1,7 +1,6 @@
const { orderBy } = require('lodash')
// dbConnections must be imported to avoid Sequelize errors even if it's not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dbConnections } = require('../database/connection')
const { computeCreditsProduced } = require('../services/providerCredits')
+3 -3
View File
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/node'
import compression from 'compression'
import cors from 'cors'
import express, { Express } from 'express'
import { Express, json as jsonExpress } from 'express'
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
import accessLogger from './middleware/accessLogger'
@@ -39,11 +39,11 @@ import usersFd from './routes/usersFd'
const routes = (app: Express, url: string) => {
app.use(cors({ credentials: true, origin: frontUrl }))
app.use(express.json())
app.use(jsonExpress())
app.use(compression())
app.use(headersMiddleware)
app.use(currentUserMiddleware)
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
app.use(accessLogger)
app.use(`${url}/login`, login)
app.use(url, courses)
@@ -1,12 +1,14 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
router.get('/', async (req: Request, res: Response) => {
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const { user } = req
if (hasFullAccessToStudentData(user.roles)) {
const result = await getCloseToGraduationData()
+84 -76
View File
@@ -1,101 +1,109 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { CourseWithSubsId } from '../types/course'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
interface GetCoursesRequest extends Request {
query: {
name: string
code: string
combineSubstitutions: string
}
type CoursesMultiReqBody = never
type CoursesMultiResBody = { courses: CourseWithSubsId[] }
type CoursesMultiQuery = {
name: string
code: string
combineSubstitutions: string
}
router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) => {
const { name, code, combineSubstitutions } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code)
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
async (req, res) => {
const { name, code, combineSubstitutions } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
}
const mergedCourses = {}
const courses: CourseWithSubsId[] = await getCoursesByNameAndOrCode(name, code)
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
}
res.json({ courses: Object.values(mergedCourses) })
})
const mergedCourses: Record<string, CourseWithSubsId> = {}
interface GetCourseYearlyStatsRequest extends Request {
query: {
codes: string[]
separate: string
combineSubstitutions: string
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? (course.subsId?.toString() ?? '') : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
}
res.json({ courses: Object.values(mergedCourses) })
}
)
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
type CourseYearlyStatsReqBody = never
type CourseYearlyStatsQuery = {
codes: string[]
separate: string
combineSubstitutions: string
}
router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res: Response) => {
const { roles, programmeRights } = req.user
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/v3/courseyearlystats',
async (req, res) => {
const { roles, programmeRights } = req.user
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
return res.status(403).json({ error: 'No programmes so no access to course stats' })
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
return res.status(403).json({ error: 'No programmes so no access to course stats' })
}
const { codes } = req.query
if (!codes) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific study programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
res.json(results)
}
const { codes } = req.query
if (!codes) {
return res.status(422).send('Missing required query parameters')
}
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific study programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
res.json(results)
})
)
export default router
@@ -1,11 +1,17 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CurriculumPeriod } from '@oodikone/shared/models'
import { CurriculumPeriodModel } from '../models'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const result = await CurriculumPeriodModel.findAll()
type CurriculumPeriodsResBody = CurriculumPeriod[]
router.get<never, CurriculumPeriodsResBody>('/', async (_req, res) => {
const result: CurriculumPeriod[] = await CurriculumPeriodModel.findAll({
raw: true,
})
res.json(result)
})
@@ -1,5 +1,7 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
import { CanError } from '@oodikone/shared/routes'
import {
getCustomPopulationSearchesByUser,
createCustomPopulationSearch,
@@ -9,65 +11,92 @@ import {
const router = Router()
router.get('/', async (req: Request, res: Response) => {
type GetCustomPopulationSearchResBody = CustomPopulationSearch[]
router.get<never, GetCustomPopulationSearchResBody>('/', async (req, res) => {
const { id } = req.user
const customPopulationSearches = await getCustomPopulationSearchesByUser(id)
res.json(customPopulationSearches)
})
router.post('/', async (req: Request, res: Response) => {
const { name, students } = req.body
const { id } = req.user
type PostCustomPopulationSearchReqBody = {
name: string
students: string[]
}
type PostCustomPopulationSearchResBody = CustomPopulationSearch
if (!name) {
return res.status(400).json({ error: 'Name missing' })
router.post<never, CanError<PostCustomPopulationSearchResBody>, PostCustomPopulationSearchReqBody>(
'/',
async (req, res) => {
const { name, students } = req.body
const { id } = req.user
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (students && !Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
res.json(customPopulationSearch)
}
if (students && !Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
)
type PutCustomPopulationSearchReqBody = {
students: string[]
}
type PutCustomPopulationSearchResBody = CustomPopulationSearch
router.put<never, CanError<PutCustomPopulationSearchResBody>, PutCustomPopulationSearchReqBody>(
'/:id',
async (req, res) => {
const { students } = req.body
const { id } = req.params
const { id: userId } = req.user
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
if (!students) {
return res.status(400).json({ error: 'Students missing' })
}
if (!Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
if (!updatedPopulationSearch) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(updatedPopulationSearch)
}
)
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
res.json(customPopulationSearch)
})
type DeleteCustomPopulationSearchParams = {
id: string
}
type DeleteCustomPopulationSearchResBody = string
router.put('/:id', async (req: Request, res: Response) => {
const { students } = req.body
const { id } = req.params
const userId = req.user.id
router.delete<never, CanError<DeleteCustomPopulationSearchResBody>, never, DeleteCustomPopulationSearchParams>(
'/:id',
async (req, res) => {
const { id } = req.params
const userId = req.user.id
if (!id) {
return res.status(400).json({ error: 'Id missing' })
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
if (!deletedSuccessfully) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(id)
}
if (!students) {
return res.status(400).json({ error: 'Students missing' })
}
if (!Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
if (!updatedPopulationSearch) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(updatedPopulationSearch)
})
router.delete('/:id', async (req: Request, res: Response) => {
const { id } = req.params
const userId = req.user.id
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
if (!deletedSuccessfully) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(id)
})
)
export default router
+4 -2
View File
@@ -193,6 +193,7 @@ router.get(
const { graduated, special_groups: specialGroups } = req.query
/* Fetch cached data if exists */
const data = await getFacultyStudentStats(code, specialGroups, graduated)
if (data) {
return res.json(data)
@@ -203,9 +204,10 @@ router.get(
return res.status(422).end()
}
let updatedStats: any = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
if (updatedStats) {
updatedStats = await setFacultyStudentStats(updatedStats, specialGroups, graduated)
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
}
return res.json(updatedStats)
}
+12 -4
View File
@@ -1,11 +1,19 @@
import { Request, Response, Router } from 'express'
import { omit } from 'lodash'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types/user'
import { ApplicationError } from '../util/customErrors'
const router = Router()
router.get('/', (req: Request, res: Response) => {
type LoginReqBody = never
type LoginResBody = {
user: Omit<FormattedUser, 'studentsUserCanAccess'>
logoutUrl?: string
}
router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
const { user, logoutUrl } = req
if (!user) {
@@ -13,7 +21,7 @@ router.get('/', (req: Request, res: Response) => {
}
res.send({
user: omit(user, ['studentsUserCanAccess']),
user: omitKeys(user, ['studentsUserCanAccess']),
logoutUrl,
})
})
+127 -241
View File
@@ -1,100 +1,47 @@
import * as Sentry from '@sentry/node'
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference, intersection, uniq } from 'lodash'
import { CanError } from '@oodikone/shared/routes'
import { GenderCode } from '@oodikone/shared/types'
import type { CanError } from '@oodikone/shared/routes'
import {
type PopulationstatisticsQuery,
type PopulationstatisticsReqBody,
type PopulationstatisticsResBody,
type PopulationstatisticsbycourseResBody,
type PopulationstatisticsbycourseReqBody,
type PopulationstatisticsbycourseParams,
type PostByStudentNumbersResBody,
type PostByStudentNumbersReqBody,
type PopulationstatisticsStudyprogrammesResBody,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
} from '@oodikone/shared/routes/populations'
import { GenderCode, Unification, Unarray } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import { SISStudyRightModel } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { encrypt, type EncrypterData } from '../services/encrypt'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
import { getStudentTags } from '../services/populations/getStudentData'
import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
import { findByCourseAndSemesters } from '../services/students'
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
import { ParsedCourse } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
type EncryptedStudent = EncrypterData
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
return (student as EncryptedStudent)?.encryptedData !== undefined
}
export type PopulationstatisticsCoursesResBody = CanError<Bottlenecks>
export type PopulationstatisticsCoursesReqBody = {
// NOTE: Encrypted students have their iv in selectedStudents
selectedStudents: string[] | EncryptedStudent[]
selectedStudentsByYear?: { [year: string]: string[] }
courses?: string[]
}
// NOTE: POST instead of GET because of too long params and "sensitive" data
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
'/v4/populationstatistics/courses',
async (req, res) => {
const { roles, studentsUserCanAccess } = req.user
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
const hasFullAccess = hasFullAccessToStudentData(roles)
const isEncrypted = selectedStudents.some(isEncryptedStudent)
const hasAccessToStudents =
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
if (!hasAccessToStudents && !hasFullAccess) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
}
const requiredFields = [selectedStudents, selectedCourses]
if (requiredFields.some(field => !field)) {
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
}
const result = await bottlenecksOf(
selectedStudents as string[],
selectedStudentsByYear,
selectedCourses,
isEncrypted
)
return res.json(result)
}
)
export type PopulationstatisticsResBody = CanError<{ students: any }>
export type PopulationstatisticsReqBody = never
export type PopulationstatisticsQuery = {
semesters: string[]
studentStatuses?: string[]
// NOTE: This param is a JSON -object
studyRights: string
year: string
months?: string
years?: string[]
}
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
const months = req.query.months ?? '12'
// NOTE: `year` isn't needed anymore if `years` is defined
const requiredFields = [year, semesters, requestedStudyRightsJSON]
const requiredFields = [years, semesters, programme]
if (requiredFields.some(field => !field)) {
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
}
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
@@ -107,75 +54,39 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
await safeJSONParse(requestedStudyRightsJSON)
if (requestedStudyRights === null) {
return res.status(400).json({ error: 'Invalid studyrights value!' })
}
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme!)
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
let result
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiYearStudents = Promise.all(
req.query.years.map(year => {
const yearAsNumber = +year
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
return optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months: monthsForCurrentYear,
})
})
)
const { startDate, endDate } = parseDateRangeFromParams({
...req.query,
years: req.query.years,
})
const populationStudentsMerger = (multiYearStudents: any) => {
const samples = { students: [], courses: [] as any[] }
const uniqueCourseCodes = new Set<string>()
const studentNumbers = await getStudentNumbersWithStudyRights({
programmeCodes: [programme],
startDate,
endDate,
studentStatuses,
})
for (const year of multiYearStudents) {
samples.students = samples.students.concat(year.students)
for (const course of year.courses) {
if (!uniqueCourseCodes.has(course.code)) {
uniqueCourseCodes.add(course.code)
samples.courses.push(course)
}
}
}
const studyRights = [programme]
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
return samples
}
result = populationStudentsMerger(await multiYearStudents)
} else {
result = await optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months,
})
}
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
// Obfuscate if user has only limited study programme rights and there are any students
if (
'students' in result &&
!hasFullAccessToStudentData(userRoles) &&
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
) {
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
// correct year for age distribution calculation but the date is always January 1st
@@ -202,54 +113,38 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
}
)
interface GetPopulationStatisticsByCourseRequest extends Request {
query: {
coursecodes: string
from: string
to: string
separate: string
unifyCourses: UnifyStatus
}
}
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
router.get<
never,
CanError<PopulationstatisticsbycourseResBody>,
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/v3/populationstatisticsbycourse', async (req, res) => {
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
if (!coursecodes || !from || !to) {
if (!coursecodeJSON || !from || !to) {
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
}
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(JSON.parse(coursecodes), Unification.REGULAR)
const isSeparate = separate === 'true'
const coursecodes = JSON.parse(coursecodeJSON)
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
const separate = separateString === 'true'
const requestedYearsToCreatePopulationFrom = Math.ceil(separate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
}
const studentNumbers = await findByCourseAndSemesters(
JSON.parse(coursecodes),
Number(from),
Number(to),
separate,
unifyCourses
)
const result: any = await optimizedStatisticsOf(
{
// Useless, because studentnumbers are already filtered above by from & to.
// We should probably refactor this to avoid more confusement.
userId,
year: '1900',
studyRights: undefined,
semesters: ['FALL', 'SPRING'],
},
studentNumbers
)
let courseProviders: string[] = []
if (!hasFullAccessToStudentData(roles)) {
courseProviders = await getCourseProvidersForCourses(JSON.parse(coursecodes))
}
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const studyRights = []
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
const result: any = await statisticsOf(studentNumbers, studyRights, tagList)
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
? await getCourseProvidersForCourses(coursecodes)
: []
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
@@ -295,80 +190,71 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
res.json(result)
})
interface PostByStudentNumbersRequest extends Request {
body: {
studentnumberlist: string[]
tags?: {
studyProgramme: string | null
year: string | null
}
}
}
router.post<never, PostByStudentNumbersResBody, PostByStudentNumbersReqBody>(
'/v3/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentnumberlist, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
const { studentnumberlist, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
const result = await optimizedStatisticsOf(
{
userId,
year: tags?.year ?? '1900',
studyRights: studyProgrammeCode,
semesters: ['FALL', 'SPRING'],
},
filteredStudentNumbers
)
const { startDate } = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
: { startDate: undefined }
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
})
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const allRights = uniq(programmeRights.map(programme => programme.code))
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
allRights.push('KH90_001', 'MH90_001')
}
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
})
interface GetMaxYearsRequest extends Request {
query: {
courseCodes: string
}
}
router.get(
'/v3/populationstatistics/maxYearsToCreatePopulationFrom',
async (req: GetMaxYearsRequest, res: Response) => {
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
return res.json({
openCourses: maxYearsToCreatePopulationFromOpen,
uniCourses: maxYearsToCreatePopulationFromUni,
unifyCourses: maxYearsToCreatePopulationFromBoth,
})
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/v3/populationstatistics/studyprogrammes',
async (req, res) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const allRights = uniq(programmeRights.map(programme => programme.code))
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
allRights.push('KH90_001', 'MH90_001')
}
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
}
)
router.get<
never,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
never,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
return res.json({
openCourses: maxYearsToCreatePopulationFromOpen,
uniCourses: maxYearsToCreatePopulationFromUni,
unifyCourses: maxYearsToCreatePopulationFromBoth,
})
})
export default router
@@ -12,12 +12,13 @@ router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) =
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
const { code, periodIds } = req.params
const result = await getCoursesAndModules(code, periodIds)
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
res.json({
defaultProgrammeCourses: result.defaultProgrammeCourses.courses,
defaultProgrammeModules: result.defaultProgrammeCourses.modules,
secondProgrammeCourses: result.secondProgrammeCourses?.courses,
secondProgrammeModules: result.secondProgrammeCourses?.modules,
defaultProgrammeCourses: defaultProgrammeCourses.courses,
defaultProgrammeModules: defaultProgrammeCourses.modules,
secondProgrammeCourses: secondProgrammeCourses.courses,
secondProgrammeModules: secondProgrammeCourses.modules,
})
})
+92 -73
View File
@@ -1,8 +1,10 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { FormattedUser } from 'src/types'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
@@ -11,103 +13,120 @@ import logger from '../util/logger'
const router = Router()
router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
const results = await userService.findAll()
res.json(results)
})
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
type GetUserRolesResBody = readonly string[]
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
res.json(roles)
})
interface UidRequest extends Request {
params: {
uid: string
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { data: user, error } = await tryCatch(userService.findOne(uid))
if (error) return res.status(400).json({ error: error.message })
return res.json(user)
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
async (req, res) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
return res.status(204).end()
}
)
type UserEmailPreviewResBody = {
subject: string
html: string
}
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
const { uid } = req.params
const { data: user, error } = await tryCatch(userService.findOne(uid))
if (error) return res.status(400).json({ error: error.message })
return res.json(user)
})
interface ModifyRolesRequest extends Request {
body: {
username: string
roles: Record<Role, boolean>
}
}
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
return res.status(204).end()
})
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
res.json({ subject: accessMessageSubject, html: accessMessageText })
})
interface EmailRequest extends Request {
body: {
email: string
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
'/email',
auth.roles(['admin']),
async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
}
}
)
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
type PostUserUIDElementsResBody = void
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
interface ElementsRequest extends Request {
params: {
uid: string
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, true)
res.status(204).end()
}
body: {
codes: string[]
)
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, false)
res.status(204).end()
}
}
)
router.post('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, true)
res.status(204).end()
})
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.delete('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, false)
res.status(204).end()
})
interface ChangeLanguageRequest extends Request {
body: {
language: Language
}
}
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
return res.status(400).json('Invalid language')
return res.status(400).json({ error: 'Invalid language' })
}
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
@@ -1,6 +1,4 @@
import moment from 'moment'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { Graduated, SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -49,7 +47,7 @@ export const setBasicStats = async (data, yearType: YearType, specialGroups: Spe
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -84,7 +82,7 @@ export const setCreditStats = async (data, isAcademicYear: boolean, specialGroup
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!isRelevantProgramme(id) && !isFaculty(id)) {
return dataToRedis
@@ -117,7 +115,7 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -141,16 +139,16 @@ export const getStudyTrackStats = async (
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis)
return JSON.parse(dataFromRedis) as StudyTrackStats & { status: string; lastUpdated: string }
}
export const setStudyTrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
export const setStudyTrackStats = async (data: StudyTrackStats, graduated: Graduated, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -1,272 +0,0 @@
import { Name, EnrollmentState } from '@oodikone/shared/types'
const lengthOf = (obj: object) => Object.keys(obj).length
const percentageOf = (num: number, denom: number) => {
if (denom === 0) {
return 0
}
return Math.round(((100 * num) / denom) * 100) / 100
}
const fall: string[] = []
const spring: string[] = []
for (let i = 0; i < 7; i++) {
fall[i] = `${i}-FALL`
spring[i] = `${i}-SPRING`
}
const initializePassingSemesters = () => {
const initialValue = 0
const passingSemesters: Record<string, number> = {
BEFORE: initialValue,
LATER: initialValue,
}
for (let i = 0; i < 7; i++) {
passingSemesters[fall[i]] = initialValue
passingSemesters[spring[i]] = initialValue
}
return passingSemesters
}
const getFilteredEnrolledNoGrade = (enrollments: DynamicEnrollments, allStudents: string[]) => {
const enrolledStudents = [...(enrollments[EnrollmentState.ENROLLED] ?? [])]
const result: Record<string, boolean> = {}
for (const student of enrolledStudents) {
if (allStudents.includes(student)) {
continue
}
result[student] = true
}
return result
}
type DynamicEnrollments = {
[state in EnrollmentState]?: Set<string>
} & {
semesters: {
[semester: string]: {
[state in EnrollmentState]?: Set<string>
}
}
}
type Stats = {
students: number
passed: number
failed: number
attempts: number
improvedPassedGrade: number
percentage: number | undefined
passedOfPopulation: number | undefined
triedOfPopulation: number | undefined
perStudent: number | undefined
passingSemesters: Record<string, number>
passingSemestersCumulative?: Record<string, number>
totalStudents?: number
totalEnrolledNoGrade?: number
percentageWithEnrollments?: number
}
type Students = {
all: Record<string, boolean>
passed: Record<string, boolean>
failed: Record<string, boolean>
improvedPassedGrade: Record<string, boolean>
markedToSemester: Record<string, boolean>
enrolledNoGrade: Record<string, boolean>
}
type Course = {
code: string
name: Name
substitutions: string[]
}
type Grades = {
[grade: string]: {
count: number
status: {
passingGrade: boolean
improvedGrade: boolean
failingGrade: boolean
}
}
}
export type CourseStatistics = {
stats: Stats
students: Students
course: Course
grades: Grades
}
export class CourseStatsCounter {
private course: Course
private students: Students
private stats: Stats
private enrollments: DynamicEnrollments
private grades: Grades
constructor(code: string, name: Name) {
this.course = {
code,
name,
substitutions: [],
}
this.students = {
all: {},
passed: {},
failed: {},
improvedPassedGrade: {},
markedToSemester: {},
enrolledNoGrade: {},
}
this.stats = {
students: 0,
passed: 0,
failed: 0,
attempts: 0,
improvedPassedGrade: 0,
percentage: undefined,
passedOfPopulation: undefined,
triedOfPopulation: undefined,
perStudent: undefined,
passingSemesters: initializePassingSemesters(),
}
this.enrollments = {
semesters: {},
}
this.grades = {}
}
private markAttempt() {
this.stats.attempts += 1
}
private markToAll(studentNumber: string) {
this.students.all[studentNumber] = true
}
private markPassedSemester(semester: string) {
this.stats.passingSemesters[semester]++
}
private markToSemester(studentNumber: string) {
this.students.markedToSemester[studentNumber] = true
}
public markCredit(
studentNumber: string,
grade: string,
passed: boolean,
failed: boolean,
improved: boolean,
semester: string
) {
this.markAttempt()
this.markGrade(grade, passed, failed, improved)
this.markToAll(studentNumber)
if (passed) {
if (!this.students.markedToSemester[studentNumber]) {
this.markPassedSemester(semester)
this.markToSemester(studentNumber)
}
this.markPassingGrade(studentNumber)
} else if (improved) {
this.markImprovedGrade(studentNumber)
} else if (failed) {
this.markFailedGrade(studentNumber)
}
}
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
this.enrollments[state] ??= new Set()
this.enrollments[state].add(studentNumber)
this.enrollments.semesters[semester] ??= {}
this.enrollments.semesters[semester][state] ??= new Set()
this.enrollments.semesters[semester][state].add(studentNumber)
}
private failedBefore(studentNumber: string) {
return this.students.failed[studentNumber] !== undefined
}
private passedBefore(studentNumber: string) {
return this.students.passed[studentNumber] !== undefined
}
private removeFromFailed(studentNumber: string) {
delete this.students.failed[studentNumber]
}
private markPassingGrade(studentNumber: string) {
this.students.passed[studentNumber] = true
if (this.failedBefore(studentNumber)) {
this.removeFromFailed(studentNumber)
}
}
private markImprovedGrade(studentNumber: string) {
this.removeFromFailed(studentNumber)
this.students.improvedPassedGrade[studentNumber] = true
this.students.passed[studentNumber] = true
}
private markFailedGrade(studentNumber: string) {
if (this.passedBefore(studentNumber)) {
this.removeFromFailed(studentNumber)
} else {
this.students.failed[studentNumber] = true
}
}
private markGrade(grade: string, passingGrade: boolean, failingGrade: boolean, improvedGrade: boolean) {
const gradeCount = this.grades[grade] ? this.grades[grade].count || 0 : 0
this.grades[grade] = { count: gradeCount + 1, status: { passingGrade, improvedGrade, failingGrade } }
}
public addCourseSubstitutions(substitutions: string[]) {
this.course.substitutions = substitutions
}
private getPassingSemestersCumulative() {
const { passingSemesters } = this.stats
const attemptStats: Record<string, number> = {}
attemptStats.BEFORE = passingSemesters.BEFORE
attemptStats['0-FALL'] = passingSemesters.BEFORE + passingSemesters['0-FALL']
attemptStats['0-SPRING'] = attemptStats['0-FALL'] + passingSemesters['0-SPRING']
for (let i = 1; i < 7; i++) {
attemptStats[fall[i]] = attemptStats[spring[i - 1]] + passingSemesters[fall[i]]
attemptStats[spring[i]] = attemptStats[fall[i]] + passingSemesters[spring[i]]
}
attemptStats.LATER = attemptStats['6-SPRING'] + passingSemesters.LATER
return attemptStats
}
public getFinalStats(populationCount: number): CourseStatistics {
const { stats, students, course, grades } = this
const allStudents = Object.keys(students.all)
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, allStudents)
students.all = { ...students.all, ...filteredEnrolledNoGrade }
students.enrolledNoGrade = filteredEnrolledNoGrade
stats.students = lengthOf(students.all)
stats.passed = lengthOf(students.passed)
stats.failed = lengthOf(students.failed)
stats.improvedPassedGrade = lengthOf(students.improvedPassedGrade)
stats.percentage = percentageOf(stats.passed, stats.students)
stats.passedOfPopulation = percentageOf(stats.passed, populationCount)
stats.triedOfPopulation = percentageOf(stats.students, populationCount)
stats.perStudent = percentageOf(stats.attempts, stats.passed + stats.failed) / 100
stats.passingSemestersCumulative = this.getPassingSemestersCumulative()
stats.totalStudents = stats.students
stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade)
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
return { stats, students, course, grades }
}
}
@@ -1,14 +1,14 @@
import { max, min } from 'lodash'
import { Name } from '@oodikone/shared/types'
import { getSemesterNameByCode } from '../semesters'
import { getSemesterNames } from '../semesters'
import { FormattedProgramme, OrganizationDetails } from './helpers'
type Programme = {
name: Name
students: { [yearCode: number]: string[] }
passed: { [yearCode: number]: string[] }
credits: { [yearCode: number]: number }
students: Record<number, string[]>
passed: Record<number, string[]>
credits: Record<number, number>
}
type Faculty = {
@@ -22,11 +22,11 @@ type FacultyYearStats = {
year: string
allStudents: string[]
allPassed: string[]
faculties: { [facultyCode: string]: Faculty }
faculties: Record<string, Faculty>
allCredits: number
}
type Grades = { [grade: string]: string[] }
type Grades = Record<string, string[]>
type GroupAttempts = {
grades: Grades
@@ -37,7 +37,7 @@ type GroupAttempts = {
}
type GroupStudents = {
grades: { [studentNumber: string]: { grade: string; passed: boolean } }
grades: Record<string, { grade: string; passed: boolean }>
studentNumbers: string[]
}
@@ -58,9 +58,9 @@ type Student = {
}
export class CourseYearlyStatsCounter {
private groups: { [groupCode: number]: Group }
private programmes: { [code: string]: Programme }
private facultyStats: { [yearCode: number]: FacultyYearStats }
private groups: Record<number, Group>
private programmes: Record<string, Programme>
private facultyStats: Record<number, FacultyYearStats>
private obfuscated: boolean
private students: Map<string, Student>
@@ -215,11 +215,12 @@ export class CourseYearlyStatsCounter {
enrollment => enrollment.studentNumber === studentNumber
)
if (!oldEnrollment) {
return this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
} else {
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
.filter(enrollment => enrollment.studentNumber !== studentNumber)
.concat([enrollment])
}
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
.filter(enrollment => enrollment.studentNumber !== studentNumber)
.concat([enrollment])
}
private markCreditToAttempts(studentNumber: string, passed: boolean, grade: string, groupCode: number) {
@@ -301,16 +302,21 @@ export class CourseYearlyStatsCounter {
return `${startYear}-${endYear}`
}
for (let i = first + 1; i <= last; i++) {
if (this.groups[i]) {
continue
}
const groups = Object.keys(this.groups)
const a = first
const b = last + 1
const range = [...Array(b - a).keys()].map(v => v + a)
const diff = range.filter(v => !groups.includes(v.toString()))
const semesters = await getSemesterNames(diff)
diff.forEach(i => {
const previous = this.groups[i - 1]
const semester = await getSemesterNameByCode(i)
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
}
const semester = semesters[i]
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester
this.initGroup(i, name, previous.coursecode, i)
})
}
private async parseGroupStatistics(anonymizationSalt: string | null) {
@@ -1,6 +1,6 @@
import { Op } from 'sequelize'
import { EnrollmentState } from '@oodikone/shared/types'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import {
CreditModel,
StudentModel,
@@ -10,7 +10,6 @@ import {
SISStudyRightModel,
SISStudyRightElementModel,
} from '../../models'
import { Unification } from '../../types'
import { getIsOpen } from './helpers'
export const getCreditsForCourses = async (codes: string[], unification: Unification) => {
@@ -1,7 +1,6 @@
import { Op } from 'sequelize'
import { Name } from '@oodikone/shared/types'
import { Unification } from '../../types'
import { Name, Unification } from '@oodikone/shared/types'
export type FormattedProgramme = {
code: string
@@ -12,10 +11,10 @@ export type FormattedProgramme = {
}
export const getIsOpen = (unification: Unification) => {
const options = {
const options: Record<Unification, object> = {
open: { [Op.eq]: true },
regular: { [Op.eq]: false },
unify: { [Op.in]: [false, true] },
unify: { [Op.ne]: null },
}
return options[unification]
}
+23 -52
View File
@@ -1,11 +1,10 @@
import crypto from 'crypto'
import { Op } from 'sequelize'
import { Op, fn as dbFn, col as dbCol } from 'sequelize'
import { Name, EnrollmentState } from '@oodikone/shared/types'
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
import { dateIsBetween } from '@oodikone/shared/util/datetime'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
import { Unification } from '../../types'
import { isOpenUniCourseCode } from '../../util'
import { dateIsBetween } from '../../util/datetime'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
@@ -261,59 +260,31 @@ const getYearlyStatsOfNew = async (
}
export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unification: Unification) => {
const maxAttainmentDate = new Date(
Math.max(
...(
await CourseModel.findAll({
where: {
code: {
[Op.in]: courseCodes,
},
},
attributes: ['max_attainment_date'],
})
).map(course => new Date(course.max_attainment_date).getTime())
)
)
const attainmentThreshold = new Date(maxAttainmentDate.getFullYear(), 0, 1)
attainmentThreshold.setFullYear(attainmentThreshold.getFullYear() - 6)
const credits = await CreditModel.findAll({
const lastAttainmentDate = (await CourseModel.findOne({
attributes: [[dbFn('MAX', dbCol('max_attainment_date')), 'date']],
where: {
course_code: {
[Op.in]: courseCodes,
},
attainment_date: {
[Op.gt]: attainmentThreshold,
},
code: { [Op.in]: courseCodes },
},
raw: true,
})) as { date: Date } | null
if (lastAttainmentDate === null) return 0
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
const attainmentsWithinThreshold = await CreditModel.count({
attributes: [[dbFn('COUNT', dbCol('id')), 'numAttainments']],
where: {
course_code: { [Op.in]: courseCodes },
attainment_date: { [Op.gt]: attainmentDateThreshold },
is_open: getIsOpen(unification),
},
order: [['attainment_date', 'ASC']],
})
const yearlyStudents = Object.values(
credits.reduce(
(res, credit) => {
const attainmentYear = new Date(credit.attainment_date).getFullYear()
if (!res[attainmentYear]) {
res[attainmentYear] = 0
}
res[attainmentYear]++
return res
},
{} as Record<number, number>
)
)
// ? What should this be called?
const magicNumber = 1200 // * Lower this value to get a smaller result if necessary
const maxYearsToCreatePopulationFrom = Math.max(
Math.floor(magicNumber / (yearlyStudents.reduce((acc, curr) => acc + curr, 0) / yearlyStudents.length)),
1
)
return maxYearsToCreatePopulationFrom
// MAGIC NUMBER
const maxAllowedAttainments = 1000000 // * Lower this value to get a smaller result if necessary
return Math.max(1, maxAllowedAttainments / attainmentsWithinThreshold)
}
export const getCourseYearlyStats = async (
+1 -1
View File
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import type { EncrypterData } from '@oodikone/shared/types'
import { CRYPT_KEY } from '../config'
const algorithm = 'aes-256-cbc'
@@ -17,7 +18,6 @@ export const encrypt = (text: string) => {
}
}
export type EncrypterData = { iv: string; encryptedData: string }
export const decrypt = (data: EncrypterData) => {
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
const iv = Buffer.from(data.iv, 'hex')
@@ -1,8 +1,7 @@
import { groupBy, orderBy } from 'lodash'
import moment from 'moment'
import { QueryTypes } from 'sequelize'
import { ProgrammeModule } from '@oodikone/shared/models'
import { ProgrammeModuleWithRelevantAttributes } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { dbConnections } from '../../database/connection'
@@ -21,11 +20,6 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
return { startDate: new Date('1800-08-01'), endDate: new Date('1801-08-01') }
}
type ProgrammeModuleWithRelevantAttributes = Pick<ProgrammeModule, 'code' | 'name' | 'degreeProgrammeType'> & {
curriculumPeriodIds: string[]
progId: string
}
// Some programme modules are not directly associated to a faculty (organization).
// Some have intermediate organizations, such as department, so the connection must be digged up
export const getDegreeProgrammesOfOrganization = async (organizationId: string, onlyCurrentProgrammes: boolean) => {
@@ -86,7 +80,8 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
.flat()
const isRelevantProgramme =
!onlyCurrentProgrammes ||
(onlyCurrentProgrammes && yearsOfProgramme.some(year => moment().isBetween(year.startDate, year.endDate)))
(onlyCurrentProgrammes &&
yearsOfProgramme.some(year => year.startDate <= new Date() && new Date() <= year.endDate))
if (isRelevantProgramme) {
relevantProgrammes.push({
@@ -101,7 +96,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
return relevantProgrammes
}
export type ProgrammesOfOrganization = Awaited<ReturnType<typeof getDegreeProgrammesOfOrganization>>
export type ProgrammesOfOrganization = ProgrammeModuleWithRelevantAttributes[]
export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurrentProgrammes: boolean) => {
const organization = await OrganizationModel.findOne({
@@ -1,6 +1,6 @@
import { cloneDeep, omit } from 'lodash'
import { Name, DegreeProgrammeType } from '@oodikone/shared/types'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
@@ -8,7 +8,7 @@ import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrackStats'
import type { ProgrammesOfOrganization } from './faculty'
export type LevelGraduationStats = Omit<GraduationTimes['medians'][number], 'y'> & { median: number }
export type LevelGraduationStats = Omit<Unarray<GraduationTimes['medians']>, 'y'> & { median: number }
type ProgrammeStats = {
data: Array<Omit<LevelGraduationStats, 'times'> & { code: string }>
@@ -1,5 +1,3 @@
import moment from 'moment'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType, DegreeProgrammeType } from '@oodikone/shared/types'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -83,7 +81,7 @@ export const setBasicStats = async (
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
@@ -134,7 +132,7 @@ export const setThesisWritersStats = async (
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
@@ -183,7 +181,7 @@ export const setGraduationStats = async (data: GraduationData, programmeFilter:
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
@@ -215,7 +213,7 @@ export const setFacultyProgressStats = async (
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
@@ -256,7 +254,7 @@ export const setFacultyStudentStats = async (
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
@@ -1,9 +1,8 @@
import dayjs from 'dayjs'
import { cloneDeep } from 'lodash'
import moment from 'moment'
import { Graduated, SpecialGroups } from '@oodikone/shared/types'
import { Graduated, SpecialGroups, Unarray } from '@oodikone/shared/types'
import { rootOrgId } from '../../config'
import { Unarray } from '../../types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -61,8 +60,8 @@ const calculateProgressStats = (
const allYears = Object.keys(creditCounts)
const limitsForYears = allYears.reduce<Record<string, ReturnType<typeof createLimits>>>((acc, year) => {
const startDate = new Date(`${year.slice(0, 4)}-08-01`)
const lastDayOfMonth = moment().endOf('month')
const months = Math.round(moment.duration(moment(lastDayOfMonth).diff(moment(startDate))).asMonths())
const lastDayOfMonth = dayjs().endOf('month')
const months = Math.round(dayjs(lastDayOfMonth).diff(startDate, 'months', true))
const goalMonths = goalMonthsForLevels[level]
acc[year] = createLimits(Math.min(months, goalMonths), 0, level === 'bcMs' ? 'master' : level)
@@ -1,4 +1,4 @@
import { Unarray } from '../../types'
import { Unarray } from '@oodikone/shared/types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getPercentage, tableTitles } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -8,14 +8,18 @@ import type { ProgrammesOfOrganization } from './faculty'
type StudyTrackStats = Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>
const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats[]) => {
const facultyTableStats: Record<string, Array<string | number>> = {}
const facultyTableStats: Record<string, (string | number)[]> = {}
const facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>> = {}
const programmeTableStats: Record<string, Record<string, Array<string | number>>> = {}
const programmeTableStats: Record<string, Record<string, (string | number)[]>> = {}
for (const programmeCode of programmeCodes) {
const statsForProgramme = stats.find(programmeStats => programmeStats.id === programmeCode)?.mainStatsByTrack?.[
programmeCode
]
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === programmeCode)
?.otherCountriesCount?.[programmeCode]
for (const prog of programmeCodes) {
const statsForProgramme = stats.find(programmeStats => programmeStats.id === prog)?.mainStatsByTrack?.[prog]
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === prog)
?.otherCountriesCount?.[prog]
if (!statsForProgramme) continue
for (const yearStats of statsForProgramme) {
@@ -34,11 +38,11 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
}
}
if (!(prog in programmeTableStats)) {
programmeTableStats[prog] = {}
if (!(programmeCode in programmeTableStats)) {
programmeTableStats[programmeCode] = {}
}
if (!(yearStats[0] in programmeTableStats[prog])) {
programmeTableStats[prog][yearStats[0]] = yearStats.slice(1)
if (!(yearStats[0] in programmeTableStats[programmeCode])) {
programmeTableStats[programmeCode][yearStats[0]] = yearStats.slice(1)
}
}
@@ -47,7 +51,7 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
if (!(year in facultyTableStatsExtra)) {
facultyTableStatsExtra[year] = {}
}
facultyTableStatsExtra[year][prog] = countryStats
facultyTableStatsExtra[year][programmeCode] = countryStats
}
}
@@ -64,8 +68,8 @@ export const combineFacultyStudents = async (
const programmeCodes = programmes.map(programme => programme.code)
const newStats: StudyTrackStats[] = []
for (const studyProgramme of programmeCodes) {
const statsFromRedis = await getStudyTrackStats(studyProgramme, null, graduated, specialGroups)
for (const studyProgrammeCode of programmeCodes) {
const statsFromRedis = await getStudyTrackStats(studyProgrammeCode, null, graduated, specialGroups)
if (statsFromRedis) {
newStats.push(statsFromRedis)
if (!years.length) {
@@ -73,9 +77,9 @@ export const combineFacultyStudents = async (
}
continue
}
const studyRightsOfProgramme = await getStudyRightsInProgramme(studyProgramme, false, true)
const studyRightsOfProgramme = await getStudyRightsInProgramme(studyProgrammeCode, false, true)
const updatedStats = await getStudyTrackStatsForStudyProgramme({
studyProgramme,
studyProgramme: studyProgrammeCode,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
@@ -73,5 +73,6 @@ export const updateSearch = async (userId: string, id: string, courseCodes: stri
return updated
} catch (error) {
logger.error(`Couldn't update open uni search instance: ${error}`)
return null
}
}
@@ -1,4 +1,4 @@
import moment from 'moment'
import dayjs from 'dayjs'
import { SISStudyRightModel } from '../../models'
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
@@ -11,11 +11,10 @@ type CourseInfo = {
}
}
type StudentStats = {
[studentNumber: string]: {
courseInfo: {
[courseCode: string]: CourseInfo
}
type StudentStats = Record<
string,
{
courseInfo: Record<string, CourseInfo>
email: string
secondaryEmail: string
totals: {
@@ -24,7 +23,7 @@ type StudentStats = {
unfinished: number
}
}
}
>
const uniq = objects => [...new Set(objects)]
@@ -47,14 +46,14 @@ const getAllCourseCodes = (courseCodes: string[]) => {
}
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
return moment(studyRight.startDate).isBetween(startDate, moment())
return startDate <= studyRight.startDate && studyRight.startDate <= new Date()
}
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
return moment(studyRight.startDate).isSameOrBefore(startDate) && moment(studyRight.endDate).isSameOrAfter(moment())
return studyRight.startDate <= startDate && new Date() <= studyRight.endDate
}
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => moment(studyRight.endDate).isSameOrBefore(moment())
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => studyRight.endDate <= new Date()
const getEmptyCourseInfo = (): CourseInfo => ({
status: {
@@ -65,7 +64,7 @@ const getEmptyCourseInfo = (): CourseInfo => ({
})
const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (!courseInfo.status.passed || moment(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
if (!courseInfo.status.passed || dayjs(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
courseInfo.status.passed = attainmentDate
}
}
@@ -73,7 +72,7 @@ const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
const updateFailedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (
!courseInfo.status.passed &&
(!courseInfo.status.failed || moment(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
(!courseInfo.status.failed || dayjs(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
) {
courseInfo.status.failed = attainmentDate
}
@@ -83,7 +82,7 @@ const updateUnfinishedStatus = (courseInfo: CourseInfo, enrollmentDateTime: Date
if (
!courseInfo.status.passed &&
!courseInfo.status.failed &&
(!courseInfo.status.unfinished || moment(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
(!courseInfo.status.unfinished || dayjs(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
) {
courseInfo.status.unfinished = enrollmentDateTime
}
@@ -1,88 +0,0 @@
import { getPassingSemester } from '../../util/semester'
import { type CourseStatistics, CourseStatsCounter } from '../courses/courseStatsCounter'
import { encrypt, decrypt, EncrypterData } from '../encrypt'
import { findCourses, parseCreditInfo } from './shared'
export type Bottlenecks = {
allStudents: number
coursestatistics: CourseStatistics[]
}
export const bottlenecksOf = async (
selectedStudents: string[],
selectedStudentsByYear: { [year: string]: string[] },
selectedCourses: string[],
isEncrypted = false
) => {
const encryptStudentNumbers = (bottlenecks: Bottlenecks) => {
for (const course of Object.keys(bottlenecks.coursestatistics)) {
const encryptedStudentStats = {}
for (const data of Object.keys(bottlenecks.coursestatistics[course].students)) {
encryptedStudentStats[data] = {}
const studentnumbers = Object.keys(bottlenecks.coursestatistics[course].students[data])
studentnumbers.forEach(studentnumber => {
encryptedStudentStats[data][encrypt(studentnumber).encryptedData] =
bottlenecks.coursestatistics[course].students[data][studentnumber]
})
}
bottlenecks.coursestatistics[course].students = encryptedStudentStats
}
}
// HACK: Encrypted students can't be decrypted if passed as strings
if (isEncrypted) selectedStudents = (selectedStudents as unknown as EncrypterData[]).map(decrypt)
const courses = await findCourses(selectedStudents, selectedCourses)
// HACK: If year isn't present or is invalid, return MAGIC_NUMBER.
// The new degree programs start on 2017.
const getYear = (studentnumber: string): number => {
const MAGIC_NUMBER = 2017
for (const year in selectedStudentsByYear) {
if (selectedStudentsByYear[year].includes(studentnumber)) {
return parseInt(year, 10) || MAGIC_NUMBER
}
}
return MAGIC_NUMBER
}
const selectedStudentCount = selectedStudents.length
const stats = {} as Record<string, CourseStatsCounter>
for (const course of courses) {
const { code: mainCode, name: mainName } = courses.find(({ code }) => code == course?.main_course_code) ?? course
const coursestats = stats[mainCode] ?? new CourseStatsCounter(mainCode, mainName)
coursestats.addCourseSubstitutions(course.substitutions)
course.enrollments?.forEach(({ studentnumber, state, enrollment_date_time }) => {
// HACK: date should already be Date object
const initialDate = new Date(enrollment_date_time)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
coursestats.markEnrollment(studentnumber, state, semester)
})
course.credits
?.map(parseCreditInfo)
.forEach(({ studentnumber, passingGrade, improvedGrade, failingGrade, grade, date }) => {
// HACK: date should already be Date object
const initialDate = new Date(date)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
coursestats.markCredit(studentnumber, grade, passingGrade, failingGrade, improvedGrade, semester)
})
stats[mainCode] = coursestats
}
const bottlenecks: Bottlenecks = {
allStudents: selectedStudentCount,
coursestatistics: Object.values(stats).map(coursestatistics =>
coursestatistics.getFinalStats(selectedStudentCount)
),
}
if (isEncrypted) {
encryptStudentNumbers(bottlenecks)
}
return bottlenecks
}
@@ -1,14 +1,8 @@
import { col, Op, where } from 'sequelize'
import { Credit, Student, Studyplan } from '@oodikone/shared/models'
import {
Name,
CreditTypeCode,
DegreeProgrammeType,
EnrollmentType,
ExtentCode,
SemesterEnrollment,
} from '@oodikone/shared/types'
import { CloseToGraduationData, AttainmentDates } from '@oodikone/shared/routes/populations'
import { Name, CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode } from '@oodikone/shared/types'
import {
CourseModel,
CreditModel,
@@ -25,50 +19,6 @@ import { getCurriculumVersion } from './shared'
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
type AttainmentDates = {
latestTotal?: Date
latestHops?: Date
earliestHops?: Date
}
type AccumulatorType = {
student: {
studentNumber: string
name: string
sis_person_id: string
email: string
phoneNumber: string
secondaryEmail: string
preferredLanguage: string
}
studyright: {
startDate: Date
semesterEnrollments: SemesterEnrollment[] | null
isBaMa: boolean
}
thesisInfo: {
grade: string
attainmentDate: Date
courseCode: string
} | null
programme: {
code: string
name: Name
studyTrack: Name | null
startedAt: Date
degreeProgrammeType: DegreeProgrammeType
}
faculty: Name
attainmentDates: AttainmentDates
numberOfAbsentSemesters: number
numberOfUsedSemesters: number
curriculumPeriod: string | null
credits: {
hops: number
all: number
}
}
const findThesisAndLatestAndEarliestAttainments = (
studyPlan: Studyplan,
attainments: Credit[],
@@ -114,7 +64,7 @@ const formatStudent = (student: Student, facultyMap: Record<string, Name>, curre
preferredLanguage,
secondary_email: secondaryEmail,
} = student
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
return student.studyplans.reduce<CloseToGraduationData[]>((acc, studyPlan) => {
const { studyRight } = studyPlan
const {
id: studyRightId,
@@ -326,7 +276,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
}
return acc
},
{ bachelor: [] as AccumulatorType[], masterAndLicentiate: [] as AccumulatorType[] }
{ bachelor: [] as CloseToGraduationData[], masterAndLicentiate: [] as CloseToGraduationData[] }
)
}
@@ -1,129 +1,81 @@
import { Name, ProgressCriteria } from '@oodikone/shared/types'
import { CreditModel, SISStudyRightModel, SISStudyRightElementModel } from '../../models'
import { ParsedCourse } from '../../types'
import { dateYearsFromNow, dateDaysFromNow } from '../../util/datetime'
import { SISStudyRight } from '@oodikone/shared/models'
import { FormattedStudent, CriteriaYear, Name, ProgressCriteria } from '@oodikone/shared/types'
import { dateYearsFromNow, dateDaysFromNow, dayInMilliseconds } from '@oodikone/shared/util/datetime'
import { CreditModel } from '../../models'
import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers'
import type { TaggetStudentData, StudentStudyPlan } from './getStudentData'
import type { StudentEnrollmentObject, StudentCreditObject, AnonymousCredit } from './optimizedStatisticsOf'
import type { StudentStudyPlan, StudentStudyRight, TaggetStudentData } from './getStudentData'
import { getCurriculumVersion } from './shared'
import type { AnonymousCredit, AnonymousEnrollment } from './statisticsOf'
type CoursesSatisfied = Record<string, string | null>
const yearMap: [string, keyof ProgressCriteria['courses']][] = [
['year1', 'yearOne'],
['year2', 'yearTwo'],
['year3', 'yearThree'],
['year4', 'yearFour'],
['year5', 'yearFive'],
['year6', 'yearSix'],
]
type CriteriaYear = {
credits: boolean
totalSatisfied: number
coursesSatisfied: CoursesSatisfied
}
const getTransferSource = (code: string, studyRights: StudentStudyRight[]): [boolean, string | undefined] => {
if (code) {
const correctStudyRight = studyRights.find(({ studyRightElements }) =>
studyRightElements.some(element => element.code === code)
)!
const correctStudyRightElement = correctStudyRight?.studyRightElements?.find(element => element.code === code)
const getCreditAmount = (course: ParsedCourse, hops: StudentStudyPlan[], courseCode: string, startDate: string) => {
const courseDate = new Date(course.date)
const startDateFromString = new Date(startDate)
if (correctStudyRight && correctStudyRightElement) {
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
const creditAmounts = [0, 0, 0, 0, 0, 0]
const [, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
correctStudyRight as SISStudyRight,
correctStudyRightElement
)
for (let i = 1; i <= 6; i++) {
if (
courseDate > startDateFromString &&
courseDate < dateYearsFromNow(startDateFromString, i) &&
hops.length > 0 &&
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
) {
creditAmounts[i - 1] = course.credits
}
}
return creditAmounts
}
if (hasTransferredToProgramme) {
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
const otherStudyRightEndDate = new Date(element.endDate)
const updateCourseByYear = (
criteria: ProgressCriteria,
course: ParsedCourse,
criteriaChecked: Record<string, CriteriaYear>,
correctCode: string
) => {
const yearMap: {
criteriaYear: keyof ProgressCriteria['courses']
yearToAdd: string
}[] = [
{ criteriaYear: 'yearOne', yearToAdd: 'year1' },
{ criteriaYear: 'yearTwo', yearToAdd: 'year2' },
{ criteriaYear: 'yearThree', yearToAdd: 'year3' },
{ criteriaYear: 'yearFour', yearToAdd: 'year4' },
{ criteriaYear: 'yearFive', yearToAdd: 'year5' },
{ criteriaYear: 'yearSix', yearToAdd: 'year6' },
]
return studyRightStartDate.getTime() - otherStudyRightEndDate.getTime() < dayInMilliseconds
})?.code
yearMap.forEach(
({ criteriaYear, yearToAdd }: { criteriaYear: keyof ProgressCriteria['courses']; yearToAdd: string }) => {
const mainCourseCodes = criteria.courses[criteriaYear]
// Each mainCode will have their substitutions fetched.
// Therefore theses will always be valid keys to the allCourses -object.
const substitutions = mainCourseCodes.flatMap(mainCode => criteria.allCourses[mainCode])
const yearSet = new Set([...mainCourseCodes, ...substitutions])
if (yearSet?.has(course.course_code)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
if (!currentDate || new Date(currentDate) > new Date(course.date)) {
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
}
return [true, transferredFromProgramme]
}
}
)
}
return [false, undefined]
}
const updateCreditCriteriaInfo = (
criteria: ProgressCriteria,
criteriaYear: string,
criteriaChecked: Record<string, CriteriaYear>,
yearToAdd: string,
academicYears: Record<string, number>
) => {
if (criteriaChecked[yearToAdd]) {
criteriaChecked[yearToAdd].totalSatisfied +=
Object.values(criteriaChecked?.[yearToAdd].coursesSatisfied).filter(course => course !== null).length || 0
}
if (academicYears[yearToAdd] >= criteria?.credits[criteriaYear] && criteria?.credits[criteriaYear] > 0) {
criteriaChecked[yearToAdd].credits = true
criteriaChecked[yearToAdd].totalSatisfied += 1
const getCriteriaBase = (criteria: ProgressCriteria): [boolean, Record<string, CriteriaYear>] => {
const thereAreCriteriaCourses = !!Object.values(criteria.courses).flatMap(val => val).length
const thereAreCriteriaCredits = !!Object.values(criteria.credits).reduce((acc, cur) => acc + cur, 0)
const createEmptyCriteriaYear = (criteria: ProgressCriteria, year: keyof ProgressCriteria['courses']) => ({
credits: false,
totalSatisfied: 0,
coursesSatisfied: Object.fromEntries(criteria.courses[year].map(course => [course, null])),
})
const criteriaChecked: Record<string, CriteriaYear> = {
year1: createEmptyCriteriaYear(criteria, 'yearOne'),
year2: createEmptyCriteriaYear(criteria, 'yearTwo'),
year3: createEmptyCriteriaYear(criteria, 'yearThree'),
year4: createEmptyCriteriaYear(criteria, 'yearFour'),
year5: createEmptyCriteriaYear(criteria, 'yearFive'),
year6: createEmptyCriteriaYear(criteria, 'yearSix'),
}
return [thereAreCriteriaCourses || thereAreCriteriaCredits, criteriaChecked]
}
export const formatStudentForAPI = (
student: TaggetStudentData,
enrollments: StudentEnrollmentObject,
credits: StudentCreditObject,
const getProgressCriteria = (
startDate: string,
endDate: string,
criteria: ProgressCriteria,
code: string,
optionData: Record<string, { name: Name }>
credits: AnonymousCredit[],
hops: StudentStudyPlan | undefined
) => {
const startDateFromISO = new Date(startDate)
const endDateFromISO = new Date(endDate)
const {
firstnames,
lastname,
studentnumber,
dateofuniversityenrollment,
creditcount,
abbreviatedname,
email,
secondary_email,
phone_number,
studyRights,
studyplans,
updatedAt,
gender_code,
tags,
birthdate,
sis_person_id,
citizenships,
} = student
const started = dateofuniversityenrollment
const starting = +startDateFromISO <= +started && +started <= +endDateFromISO
const criteriaCoursesBySubstitutions: Record<string, string> = Object.fromEntries(
Object.entries(criteria.allCourses).flatMap(([courseCode, substitutionCodes]) => [
@@ -132,48 +84,78 @@ export const formatStudentForAPI = (
])
)
const correctStudyplan = studyplans.filter(plan => plan.programme_code === code)
const [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
if (!thereAreCriteria) return criteriaChecked
const option = studentnumber in optionData ? optionData[studentnumber] : null
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
let transferredStudyright = false
let transferSource: string | null = null
if (code) {
const correctStudyRight = studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === code)
)
if (correctStudyRight) {
// NOTE: This must never be undefined
const correctStudyRightElement = correctStudyRight.studyRightElements.find(element => element.code === code)
const [, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
correctStudyRight as SISStudyRightModel,
correctStudyRightElement as SISStudyRightElementModel
)
if (hasTransferredToProgramme) {
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
const studyRightEndDate = new Date(element.endDate)
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
return (
studyRightStartDate.getFullYear() === studyRightEndDate.getFullYear() &&
studyRightStartDate.getMonth() === studyRightEndDate.getMonth() &&
studyRightStartDate.getDate() === studyRightEndDate.getDate()
)
})?.code
if (transferredFromProgramme) {
transferredStudyright = true
transferSource = transferredFromProgramme
}
}
}
}
const parseCourse = (credit: AnonymousCredit, normalizeDate: boolean): ParsedCourse => {
const attainmentDateNormalized =
normalizeDate && credit.attainment_date < startDateFromISO
const courses = credits.map(({ attainment_date, course_code, credits, credittypecode }) => ({
course_code,
credits,
credittypecode,
date:
attainment_date < startDateFromISO
? dateDaysFromNow(startDateFromISO, 1).toISOString()
: credit.attainment_date.toISOString()
: attainment_date.toISOString(),
}))
courses
.filter(({ course_code }) => !!criteriaCoursesBySubstitutions[course_code])
.filter(({ credittypecode }) => CreditModel.passed({ credittypecode }) || CreditModel.improved({ credittypecode }))
.forEach(course => {
const courseDate = new Date(course.date)
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
yearMap.forEach(([yearToAdd, criteriaYear]) => {
if (criteria.courses[criteriaYear].includes(correctCode)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
if (!currentDate || courseDate < new Date(currentDate)) {
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
}
}
})
})
courses.forEach(course => {
const courseDate = new Date(course.date)
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
if (
startDateFromISO < courseDate &&
!!hops &&
(hops.included_courses.includes(course.course_code) || hops.included_courses.includes(correctCode))
)
Object.keys(academicYears)
.filter((_, index) => courseDate < dateYearsFromNow(startDateFromISO, index + 1))
.forEach(year => (academicYears[year] += course.credits))
})
yearMap.forEach(([yearToAdd, criteriaYear]) => {
// UPDATE CREDIT CRITERIA
if (!!criteria.credits[criteriaYear] && criteria.credits[criteriaYear] < academicYears[yearToAdd]) {
criteriaChecked[yearToAdd].credits = true
}
})
return criteriaChecked
}
export const formatStudentForAPI = (
code: string,
startDate: string,
student: TaggetStudentData,
credits: AnonymousCredit[],
enrollments: AnonymousEnrollment[],
optionData: Name | undefined,
criteria: ProgressCriteria
): FormattedStudent => {
const { studentnumber, studyRights, studyplans } = student
const hops = studyplans.find(plan => plan.programme_code === code)
const [transferredStudyright, transferSource] = getTransferSource(code, studyRights)
const courses = credits.map(credit => {
const attainmentDateNormalized = credit.attainment_date.toISOString()
const passed =
CreditModel.passed({ credittypecode: credit.credittypecode }) ||
CreditModel.improved({ credittypecode: credit.credittypecode })
@@ -189,83 +171,36 @@ export const formatStudentForAPI = (
language: credit.language,
studyright_id: credit.studyright_id,
}
}
const toProgressCriteria = () => {
const createEmptyCriteriaYear = (
criteria: ProgressCriteria,
year: keyof ProgressCriteria['courses']
): CriteriaYear => ({
credits: false,
totalSatisfied: 0,
coursesSatisfied: Object.fromEntries(criteria.courses[year].map(course => [course, null])),
})
const criteriaChecked = {
year1: createEmptyCriteriaYear(criteria, 'yearOne'),
year2: createEmptyCriteriaYear(criteria, 'yearTwo'),
year3: createEmptyCriteriaYear(criteria, 'yearThree'),
year4: createEmptyCriteriaYear(criteria, 'yearFour'),
year5: createEmptyCriteriaYear(criteria, 'yearFive'),
year6: createEmptyCriteriaYear(criteria, 'yearSix'),
}
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
if (criteria.courses || criteria.credits) {
const courses = credits[studentnumber]?.map(credit => parseCourse(credit, true)) ?? []
courses
.filter(course => course.passed)
.forEach(course => {
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
updateCourseByYear(criteria, course, criteriaChecked, correctCode)
const creditAmounts = getCreditAmount(course, correctStudyplan, correctCode, startDate)
Object.keys(academicYears).forEach((year, index) => {
academicYears[year] += creditAmounts[index]
})
})
updateCreditCriteriaInfo(criteria, 'yearOne', criteriaChecked, 'year1', academicYears)
updateCreditCriteriaInfo(criteria, 'yearTwo', criteriaChecked, 'year2', academicYears)
updateCreditCriteriaInfo(criteria, 'yearThree', criteriaChecked, 'year3', academicYears)
updateCreditCriteriaInfo(criteria, 'yearFour', criteriaChecked, 'year4', academicYears)
updateCreditCriteriaInfo(criteria, 'yearFive', criteriaChecked, 'year5', academicYears)
updateCreditCriteriaInfo(criteria, 'yearSix', criteriaChecked, 'year6', academicYears)
}
return criteriaChecked
}
})
return {
firstnames,
lastname,
studyRights,
started,
firstnames: student.firstnames,
lastname: student.lastname,
started: student.dateofuniversityenrollment,
studentNumber: studentnumber,
credits: creditcount || 0,
courses: credits[studentnumber]?.map(credit => parseCourse(credit, false)) ?? [],
enrollments: enrollments[studentnumber],
name: abbreviatedname,
gender_code,
email,
secondaryEmail: secondary_email,
phoneNumber: phone_number,
updatedAt,
tags,
credits: student.creditcount ?? 0,
hopsCredits: hops?.completed_credits ?? 0,
name: student.abbreviatedname,
gender_code: student.gender_code,
email: student.email,
secondaryEmail: student.secondary_email,
phoneNumber: student.phone_number,
updatedAt: student.updatedAt,
tags: student.tags,
studyrightStart: startDate,
starting,
option,
birthdate,
studyplans,
sis_person_id,
citizenships,
criteriaProgress: toProgressCriteria(),
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
option: optionData ? { name: optionData } : null,
birthdate: student.birthdate,
sis_person_id: student.sis_person_id,
citizenships: student.citizenships,
criteriaProgress: getProgressCriteria(startDate, criteria, credits, hops),
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
transferredStudyright,
transferSource,
studyRights,
studyplans,
courses,
enrollments,
}
}
@@ -1,9 +1,10 @@
import { literal, Op, type WhereOptions } from 'sequelize'
import { Op } from 'sequelize'
import { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
import { EnrollmentState } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import {
CourseModel,
CreditModel,
EnrollmentModel,
StudentModel,
@@ -13,8 +14,8 @@ import {
} from '../../models'
import { TagModel, TagStudentModel } from '../../models/kone'
type StudentTags = TagStudentModel & {
tag: Pick<TagModel, 'tag_id' | 'tagname' | 'personal_user_id'>
export type StudentTags = TagStudent & {
tag: Pick<Tag, 'tag_id' | 'tagname' | 'personal_user_id'>
}
export type TaggetStudentData = StudentData & {
@@ -37,79 +38,33 @@ export const getStudentTags = async (studyRights: string[], studentNumbers: stri
},
})
const studentTagList: Record<string, TagStudentModel[]> = Object.fromEntries(studentNumbers.map(n => [n, []]))
const studentTagList: Record<string, StudentTags[]> = Object.fromEntries(studentNumbers.map(n => [n, []]))
studentTags.forEach(studentTag => studentTagList[studentTag.studentnumber].push(studentTag))
return studentTagList
}
export const creditFilterBuilder = async (
studentNumbers: string[],
studyRights: string[],
attainmentDateFrom: string,
endDate: Date
): Promise<WhereOptions> => {
const includedCoursesInStudyPlans: Array<Pick<StudyplanModel, 'included_courses'>> = await StudyplanModel.findAll({
attributes: ['included_courses'],
where: {
studentnumber: { [Op.in]: studentNumbers },
},
raw: true,
})
const uniqueCourses = new Set(includedCoursesInStudyPlans.flatMap(plan => plan.included_courses))
// takes into account possible progress tests taken earlier than the start date
const courseCodes =
['320001', 'MH30_001'].includes(studyRights[0]) && serviceProvider !== 'fd'
? [...uniqueCourses, '375063', '339101']
: [...uniqueCourses]
return {
student_studentnumber: { [Op.in]: studentNumbers },
[Op.or]: [{ attainment_date: { [Op.between]: [attainmentDateFrom, endDate] } }, { course_code: courseCodes }],
}
}
export type StudentCourse = Pick<CourseModel, 'code' | 'name'>
export const getCourses = (creditFilter: WhereOptions): Promise<Array<StudentCourse>> =>
CourseModel.findAll({
attributes: [[literal('DISTINCT ON("code") code'), 'code'], 'name'],
include: [
{
model: CreditModel,
attributes: [],
where: creditFilter,
},
],
raw: true,
})
export type StudentEnrollment = Pick<
EnrollmentModel,
'course_code' | 'state' | 'enrollment_date_time' | 'studentnumber' | 'semestercode' | 'studyright_id'
Enrollment,
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber'
>
export const getEnrollments = (
studentNumbers: string[],
startDate: string,
endDate: Date
): Promise<Array<StudentEnrollment>> =>
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> =>
EnrollmentModel.findAll({
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber', 'semestercode', 'studyright_id'],
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
state: EnrollmentState.ENROLLED,
enrollment_date_time: {
[Op.between]: [startDate, endDate],
[Op.gte]: startDate,
},
},
raw: true,
})
type StudentPersonalData = Pick<
StudentModel,
Student,
| 'firstnames'
| 'lastname'
| 'studentnumber'
@@ -127,19 +82,19 @@ type StudentPersonalData = Pick<
>
type StudentStudyRightElement = Pick<
SISStudyRightElementModel,
SISStudyRightElement,
'code' | 'name' | 'studyTrack' | 'graduated' | 'startDate' | 'endDate' | 'phase' | 'degreeProgrammeType'
>
type StudentStudyRight = Pick<
SISStudyRightModel,
export type StudentStudyRight = Pick<
SISStudyRight,
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate' | 'tvex'
> & {
studyRightElements: Array<StudentStudyRightElement>
}
export type StudentStudyPlan = Pick<
StudyplanModel,
Studyplan,
| 'included_courses'
| 'programme_code'
| 'includedModules'
@@ -223,7 +178,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
}).then(data => data.map(x => x.get({ plain: true })))
export type StudentCredit = Pick<
CreditModel,
Credit,
| 'grade'
| 'credits'
| 'credittypecode'
@@ -234,8 +189,29 @@ export type StudentCredit = Pick<
| 'language'
| 'studyright_id'
>
export const getCredits = (creditFilter: WhereOptions): Promise<Array<StudentCredit>> =>
CreditModel.findAll({
export const getCredits = async (
studentNumbers: string[],
studyRights: string[],
attainmentDateFrom: string
): Promise<Array<StudentCredit>> => {
const includedCoursesInStudyPlans: Array<Pick<Studyplan, 'included_courses'>> = await StudyplanModel.findAll({
attributes: ['included_courses'],
where: {
studentnumber: { [Op.in]: studentNumbers },
},
raw: true,
})
const uniqueCourses = new Set(includedCoursesInStudyPlans.flatMap(plan => plan.included_courses))
// takes into account possible progress tests taken earlier than the start date
const courseCodes =
['320001', 'MH30_001'].includes(studyRights[0]) && serviceProvider !== 'fd'
? [...uniqueCourses, '375063', '339101']
: [...uniqueCourses]
return CreditModel.findAll({
attributes: [
'grade',
'credits',
@@ -247,6 +223,10 @@ export const getCredits = (creditFilter: WhereOptions): Promise<Array<StudentCre
'language',
'studyright_id',
],
where: creditFilter,
where: {
student_studentnumber: { [Op.in]: studentNumbers },
[Op.or]: [{ attainment_date: { [Op.gte]: attainmentDateFrom } }, { course_code: courseCodes }],
},
raw: true,
})
}
@@ -1,149 +0,0 @@
import { formatToArray, omitKeys } from '@oodikone/shared/util'
import { getDegreeProgrammeType } from '../../util'
import { dateMonthsFromNow } from '../../util/datetime'
import { SemesterStart } from '../../util/semester'
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
import { formatStudentForAPI } from './formatStatisticsForApi'
import {
type StudentEnrollment,
type StudentCredit,
getStudents,
getStudentTags,
creditFilterBuilder,
getCourses,
getEnrollments,
getCredits,
} from './getStudentData'
import { getOptionsForStudents } from './shared'
import { getStudentNumbersWithStudyRights } from './studentNumbersWithStudyRights'
export type OptimizedStatisticsQuery = {
userId: string
semesters: string[]
studentStatuses?: string[]
studyRights?: string | string[]
year: string
months?: string
}
export type ParsedQueryParams = {
startDate: string
endDate: string
includeExchangeStudents: boolean
includeNondegreeStudents: boolean
includeTransferredStudents: boolean
studyRights: string[]
months?: string
}
const parseQueryParams = (query: OptimizedStatisticsQuery): ParsedQueryParams => {
const { semesters, studentStatuses, studyRights, months, year } = query
const yearAsNumber = +year
const hasFall = semesters.includes('FALL')
const hasSpring = semesters.includes('SPRING')
const startDate = hasFall
? new Date(`${yearAsNumber}-${SemesterStart.FALL}`).toISOString()
: new Date(`${yearAsNumber + 1}-${SemesterStart.SPRING}`).toISOString()
const endDate = hasSpring
? new Date(`${yearAsNumber + 1}-${SemesterStart.FALL}`).toISOString()
: new Date(`${yearAsNumber + 1}-${SemesterStart.SPRING}`).toISOString()
const includeExchangeStudents = !!studentStatuses?.includes('EXCHANGE')
const includeNondegreeStudents = !!studentStatuses?.includes('NONDEGREE')
const includeTransferredStudents = !!studentStatuses?.includes('TRANSFERRED')
return {
includeExchangeStudents,
includeNondegreeStudents,
includeTransferredStudents,
// Remove falsy values so the query doesn't break
studyRights: formatToArray(studyRights).filter(Boolean) as string[],
months,
startDate,
endDate,
}
}
export type AnonymousEnrollment = Omit<StudentEnrollment, 'studentnumber'>
export type AnonymousCredit = Omit<StudentCredit, 'student_studentnumber'>
export type StudentEnrollmentObject = Record<StudentEnrollment['studentnumber'], AnonymousEnrollment[]>
export type StudentCreditObject = Record<StudentCredit['student_studentnumber'], AnonymousCredit[]>
export const optimizedStatisticsOf = async (query: OptimizedStatisticsQuery, studentNumberList?: string[]) => {
const { userId } = query
const {
studyRights,
startDate,
months,
endDate,
includeExchangeStudents,
includeNondegreeStudents,
includeTransferredStudents: includeTransferredOutStudents,
} = parseQueryParams(query)
const studentNumbers =
studentNumberList ??
(await getStudentNumbersWithStudyRights({
studyRights,
startDate,
endDate,
includeExchangeStudents,
includeNondegreeStudents,
includeTransferredOutStudents,
}))
const code = studyRights[0] ?? ''
const degreeProgrammeType = await getDegreeProgrammeType(code)
const creditsFilter = await creditFilterBuilder(
studentNumbers,
studyRights,
startDate,
dateMonthsFromNow(startDate, months)
)
const [students, courses, enrollments, credits] = await Promise.all([
getStudents(studentNumbers),
getCourses(creditsFilter),
getEnrollments(studentNumbers, startDate, dateMonthsFromNow(startDate, months)),
getCredits(creditsFilter),
])
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
const optionData = await getOptionsForStudents(studentNumbers, code, degreeProgrammeType)
const criteria = await getCriteria(code)
const creditsByStudent: StudentCreditObject = Object.fromEntries(studentNumbers.map(n => [n, []]))
credits.forEach(credit => {
const { student_studentnumber: studentnumber } = credit
creditsByStudent[studentnumber].push(omitKeys(credit, ['student_studentnumber']))
})
const enrollmentsByStudent: StudentEnrollmentObject = Object.fromEntries(studentNumbers.map(n => [n, []]))
enrollments.forEach(enrollment => {
const { studentnumber } = enrollment
enrollmentsByStudent[studentnumber].push(omitKeys(enrollment, ['studentnumber']))
})
return {
students: students
.map(student => ({ ...student, tags: tagList[student.studentnumber] }))
.map(student =>
formatStudentForAPI(
student,
enrollmentsByStudent,
creditsByStudent,
startDate,
endDate,
criteria,
code,
optionData
)
),
courses,
}
}
@@ -1,13 +1,41 @@
import { orderBy } from 'lodash'
import { Op, QueryTypes } from 'sequelize'
import { Op } from 'sequelize'
import type { CourseStats } from '@oodikone/shared/routes/populations'
import { Name, DegreeProgrammeType, EnrollmentState } from '@oodikone/shared/types'
import { dbConnections } from '../../database/connection'
import { CreditModel, EnrollmentModel, SISStudyRightModel, SISStudyRightElementModel } from '../../models'
import { SISStudyRightModel, SISStudyRightElementModel, CreditModel, CourseModel } from '../../models'
import { getPassingSemester, SemesterStart } from '../../util/semester'
import { StudentCredit, StudentEnrollment } from './getStudentData'
const { sequelize } = dbConnections
type QueryParams = {
semesters: string[]
years: string[]
}
export const getCurriculumVersion = (curriculumPeriodId: string) => {
type ParsedQueryParams = {
startDate: string
endDate: string
}
export const parseDateRangeFromParams = (query: QueryParams): ParsedQueryParams => {
const { semesters, years } = query
const startingYear = Math.min(...years.map(y => +y))
const endingYear = Math.max(...years.map(y => +y))
const hasFall = semesters.includes('FALL')
const hasSpring = semesters.includes('SPRING')
const startDate = hasFall
? new Date(`${startingYear}-${SemesterStart.FALL}`).toISOString()
: new Date(`${startingYear + 1}-${SemesterStart.SPRING}`).toISOString()
const endDate = hasSpring
? new Date(`${endingYear + 1}-${SemesterStart.FALL}`).toISOString()
: new Date(`${endingYear + 1}-${SemesterStart.SPRING}`).toISOString()
return { startDate, endDate }
}
export const getCurriculumVersion = (curriculumPeriodId: string | undefined) => {
if (!curriculumPeriodId) {
return null
}
@@ -23,7 +51,7 @@ export const getOptionsForStudents = async (
studentNumbers: string[],
code: string,
degreeProgrammeType: DegreeProgrammeType | null
): Promise<Record<string, { name: Name }>> => {
): Promise<Record<string, Name>> => {
if (!code || !studentNumbers.length) {
return {}
} else if (
@@ -64,141 +92,206 @@ export const getOptionsForStudents = async (
[levelIsMasters ? 'desc' : 'asc']
)
return [studyRight.studentNumber, { name: latestProgramme?.name }] as [string, { name: Name }]
return [studyRight.studentNumber, latestProgramme?.name]
})
.filter(([_, { name }]) => !!name)
.filter(([_, name]) => !!name)
)
}
export type EnrollmentsQueryResult = Array<{
code: string
name: Name
substitutions: string[]
main_course_code: string
enrollments: Array<Pick<EnrollmentModel, 'studentnumber' | 'state' | 'enrollment_date_time'>> | null
}>
type CreditPick = Pick<
CreditModel,
'grade' | 'student_studentnumber' | 'attainment_date' | 'credittypecode' | 'course_code'
>
export type CoursesQueryResult = Array<
EnrollmentsQueryResult[number] & {
credits: Array<CreditPick>
}
>
const findDefinedCourseStatsForStudents = `
SELECT
course.code,
course.name,
course.substitutions,
course.main_course_code,
enrollment.data AS enrollments,
credit.data AS credits
FROM course
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE studentnumber IN (:studentnumbers)
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'grade', grade,
'student_studentnumber', student_studentnumber,
'attainment_date', attainment_date,
'credittypecode', credittypecode,
'course_code', course_code
)) AS data
FROM credit
WHERE student_studentnumber IN (:studentnumbers)
GROUP BY course_code
) AS credit
ON credit.course_code = course.code
WHERE
(
course.code IN (:courseCodes)
OR (
SELECT
JSONB_AGG(DISTINCT alt_code)
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
WHERE code IN (:courseCodes)
) ? course.code
) AND (enrollment.data IS NOT NULL OR credit.data IS NOT NULL)
`
const findAllCourseStatsForStudents = `
SELECT
course.code,
course.name,
course.substitutions,
course.main_course_code,
enrollment.data AS enrollments,
credit.data AS credits
FROM course
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE studentnumber IN (:studentnumbers)
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'grade', grade,
'student_studentnumber', student_studentnumber,
'attainment_date', attainment_date,
'credittypecode', credittypecode,
'course_code', course_code
)) AS data
FROM credit
WHERE student_studentnumber IN (:studentnumbers)
GROUP BY course_code
) AS credit
ON credit.course_code = course.code
WHERE
enrollment.data IS NOT NULL OR credit.data IS NOT NULL
`
export const findCourses = async (studentNumbers: string[], courses: string[] = []) => {
return sequelize.query(courses.length ? findDefinedCourseStatsForStudents : findAllCourseStatsForStudents, {
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
courseCodes: courses.length ? courses : ['DUMMY'],
enrollmentState: EnrollmentState.ENROLLED,
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name'>>> =>
CourseModel.findAll({
attributes: ['code', 'name'],
where: {
code: { [Op.in]: courses },
},
type: QueryTypes.SELECT,
}) as Promise<CoursesQueryResult>
raw: true,
})
const defaultCourse = {
attempts: 0,
enrollments: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
semesters: {
BEFORE: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'0-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'0-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'1-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'1-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'2-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'2-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'3-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'3-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'4-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'4-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'5-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'5-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
LATER: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
},
},
grades: {},
students: {
all: new Set<string>(),
passed: new Set<string>(),
failed: new Set<string>(),
improvedPassedGrade: new Set<string>(),
markedToSemester: new Set<string>(),
enrolledNoGrade: new Set<string>(),
},
stats: {
passingSemesters: {
BEFORE: 0,
'0-FALL': 0,
'0-SPRING': 0,
'1-FALL': 0,
'1-SPRING': 0,
'2-FALL': 0,
'2-SPRING': 0,
'3-FALL': 0,
'3-SPRING': 0,
'4-FALL': 0,
'4-SPRING': 0,
'5-FALL': 0,
'5-SPRING': 0,
LATER: 0,
},
},
}
export const parseCreditInfo = (credit: CreditPick) => {
return {
studentnumber: credit.student_studentnumber,
grade: credit.grade,
passingGrade: CreditModel.passed(credit),
failingGrade: CreditModel.failed(credit),
improvedGrade: CreditModel.improved(credit),
date: credit.attainment_date,
export const parseCourseData = async (
studentStartingYears: Map<string, number>,
enrollments: StudentEnrollment[],
credits: StudentCredit[]
): Promise<CourseStats[]> => {
const getYear = (studentnumber: string) => studentStartingYears.get(studentnumber)!
const coursestats = new Map<string, typeof defaultCourse>()
for (const enrollment of enrollments) {
const { course_code, studentnumber, state, enrollment_date_time } = enrollment
// We cannot display these
if (course_code === null) continue
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
const course = coursestats.get(course_code)!
const initialDate = new Date(enrollment_date_time)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
course.students.all.add(studentnumber)
course.students.enrolledNoGrade.add(studentnumber)
course.enrollments[state].add(studentnumber)
course.enrollments.semesters[semester][state].add(studentnumber)
}
for (const credit of credits) {
const { course_code, student_studentnumber: studentnumber, grade, attainment_date: date } = credit
// We cannot display these
if (course_code === null) continue
const passingGrade = CreditModel.passed(credit)
const failingGrade = CreditModel.failed(credit)
const improvedGrade = CreditModel.improved(credit)
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
const course = coursestats.get(course_code)!
course.attempts += 1
const gradeCount = course.grades[grade]?.count ?? 0
course.grades[grade] = { count: gradeCount + 1, status: { passingGrade, failingGrade, improvedGrade } }
course.students.all.add(studentnumber)
course.students.enrolledNoGrade.delete(studentnumber)
if (passingGrade) {
if (!course.students.markedToSemester.has(studentnumber)) {
course.students.markedToSemester.add(studentnumber)
const semester = getPassingSemester(getYear(studentnumber), new Date(date))
course.stats.passingSemesters[semester]++
}
course.students.passed.add(studentnumber)
course.students.failed.delete(studentnumber)
} else if (improvedGrade) {
course.students.improvedPassedGrade.add(studentnumber)
course.students.passed.add(studentnumber)
course.students.failed.delete(studentnumber)
} else if (failingGrade && !course.students.passed.has(studentnumber)) {
course.students.failed.add(studentnumber)
}
}
const courses = await getCourses(Array.from(coursestats.keys()))
const courseMap = new Map(courses.map(({ code, name }) => [code, name]))
return Array.from(coursestats.entries()).map(([code, { attempts, enrollments, grades, students, stats }]) => {
return {
course: {
code,
name: courseMap.get(code) ?? { en: code },
substitutions: [],
},
attempts,
enrollments: {
[EnrollmentState.ENROLLED]: Array.from(enrollments[EnrollmentState.ENROLLED]),
[EnrollmentState.REJECTED]: Array.from(enrollments[EnrollmentState.REJECTED]),
semesters: Object.fromEntries(
Object.entries(enrollments.semesters).map(([key, val]) => [
key,
{
[EnrollmentState.ENROLLED]: Array.from(val[EnrollmentState.ENROLLED]),
[EnrollmentState.REJECTED]: Array.from(val[EnrollmentState.REJECTED]),
},
])
),
},
grades,
students: Object.fromEntries(Object.entries(students).map(([key, val]) => [key, Array.from(val)])),
stats,
}
})
}
@@ -0,0 +1,85 @@
import { omitKeys } from '@oodikone/shared/util'
import { getDegreeProgrammeType } from '../../util'
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
import { formatStudentForAPI } from './formatStatisticsForApi'
import {
type StudentEnrollment,
type StudentCredit,
getStudents,
getEnrollments,
getCredits,
StudentTags,
} from './getStudentData'
import { getOptionsForStudents, parseCourseData } from './shared'
export type OptimizedStatisticsQuery = {
userId: string
semesters: string[]
studentStatuses?: string[]
studyRights?: string | string[]
years: string[]
}
export type AnonymousEnrollment = Omit<StudentEnrollment, 'studentnumber'>
export type AnonymousCredit = Omit<StudentCredit, 'student_studentnumber'>
export type StudentEnrollmentObject = Map<StudentEnrollment['studentnumber'], AnonymousEnrollment[]>
export type StudentCreditObject = Map<StudentCredit['student_studentnumber'], AnonymousCredit[]>
export const statisticsOf = async (
studentNumbers: string[],
studyRights: string[],
tagList: Record<string, StudentTags[]>,
startDate?: string
) => {
const code = studyRights[0] ?? ''
const mockedStartDate = startDate ?? new Date(1900).toISOString()
const [enrollments, credits, students] = await Promise.all([
getEnrollments(studentNumbers, mockedStartDate),
getCredits(studentNumbers, studyRights, mockedStartDate),
getStudents(studentNumbers),
])
const studentStartingYears = new Map(
students.map(({ studentnumber, studyRights }) => [
studentnumber,
studyRights
?.flatMap(({ studyRightElements }) => studyRightElements)
.find(element => element.code === code)
?.startDate.getFullYear() ?? +mockedStartDate,
])
)
const formattedCoursestats = await parseCourseData(studentStartingYears, enrollments, credits)
const creditsByStudent: StudentCreditObject = new Map<string, AnonymousCredit[]>(studentNumbers.map(n => [n, []]))
credits.forEach(credit =>
creditsByStudent.get(credit.student_studentnumber)!.push(omitKeys(credit, ['student_studentnumber']))
)
const enrollmentsByStudent: StudentEnrollmentObject = new Map<string, AnonymousEnrollment[]>(
studentNumbers.map(n => [n, []])
)
enrollments.forEach(enrollment =>
enrollmentsByStudent.get(enrollment.studentnumber)!.push(omitKeys(enrollment, ['studentnumber']))
)
const criteria = await getCriteria(code)
const optionData = await getOptionsForStudents(studentNumbers, code, await getDegreeProgrammeType(code))
return {
coursestatistics: formattedCoursestats,
students: students.map(student =>
formatStudentForAPI(
code,
mockedStartDate,
{ ...student, tags: tagList[student.studentnumber] },
creditsByStudent.get(student.studentnumber) ?? [],
enrollmentsByStudent.get(student.studentnumber) ?? [],
optionData?.[student.studentnumber],
criteria
)
),
}
}
@@ -22,14 +22,11 @@ type StudentStudyRights = Pick<SISStudyRightModel, 'studentNumber' | 'studyRight
studyRightElements: Array<Pick<SISStudyRightElementModel, 'code' | 'endDate' | 'startDate' | 'phase'>>
}
export const getStudentNumbersWithStudyRights = async ({
studyRights,
startDate,
endDate,
includeExchangeStudents,
includeNondegreeStudents,
includeTransferredOutStudents,
}) => {
export const getStudentNumbersWithStudyRights = async ({ programmeCodes, startDate, endDate, studentStatuses }) => {
const includeExchangeStudents = !!studentStatuses?.includes('EXCHANGE')
const includeNondegreeStudents = !!studentStatuses?.includes('NONDEGREE')
const includeTransferredOutStudents = !!studentStatuses?.includes('TRANSFERRED')
const filteredExtents = [
ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS,
...(!includeExchangeStudents ? exchangeStudents : []),
@@ -42,7 +39,7 @@ export const getStudentNumbersWithStudyRights = async ({
model: SISStudyRightElementModel,
attributes: [],
where: {
code: { [Op.in]: studyRights },
code: { [Op.in]: programmeCodes },
startDate: { [Op.gte]: startDate, [Op.lt]: endDate },
},
},
@@ -73,7 +70,7 @@ export const getStudentNumbersWithStudyRights = async ({
if (includeTransferredOutStudents) return true
// NOTE: We know that this will always return a value because of the first query in the function
const element = student.studyRightElements.find(element => studyRights.includes(element.code))!
const element = student.studyRightElements.find(element => programmeCodes.includes(element.code))!
const [hasTransferredFromProgramme, _] = hasTransferredFromOrToProgramme(student, element)
return !hasTransferredFromProgramme
@@ -1,6 +1,7 @@
import { Op, QueryTypes } from 'sequelize'
import { CurriculumOption, Name } from '@oodikone/shared/types'
import { ProgrammeModule } from '@oodikone/shared/models'
import { CurriculumOption, DegreeProgrammeType, Name } from '@oodikone/shared/types'
import { dbConnections } from '../database/connection'
import { CurriculumPeriodModel, ProgrammeModuleModel } from '../models'
import { ExcludedCourseModel } from '../models/kone'
@@ -46,27 +47,14 @@ export const getCurriculumOptions = async (code: string) => {
}
}
type ModuleWithChildren = Pick<
ProgrammeModuleModel,
| 'id'
| 'code'
| 'name'
| 'type'
| 'order'
| 'organization_id'
| 'valid_from'
| 'valid_to'
| 'group_id'
| 'curriculum_period_ids'
> & {
parent_id: string
created_at: Date
updated_at: Date
study_level: string | null
degree_programme_type: string | null
module_order: number | null
parent_name: Name
type ModuleWithChildren = Pick<ProgrammeModule, 'id' | 'type' | 'code' | 'name'> & {
// TODO: This is supposed to be in the ProgrammeModule
degree_programme_type: DegreeProgrammeType | null
module_order: number
parent_name: Name | null
parent_code: string | null
parent_id: string | null
}
const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_ids: string[]) => {
@@ -75,15 +63,47 @@ const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_i
try {
const result: ModuleWithChildren[] = await connection.query(
`WITH RECURSIVE children as (
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS parent_name, NULL AS parent_code, NULL as parent_id FROM programme_modules pm
WHERE pm.code = ? AND ARRAY[?]::text[] && curriculum_period_ids
SELECT
DISTINCT pm.*,
0 AS module_order,
NULL::jsonb AS parent_name,
NULL AS parent_code,
NULL as parent_id
FROM programme_modules pm
WHERE pm.code = :code
AND ARRAY[:curriculum_period_ids]::text[] && curriculum_period_ids
UNION ALL
SELECT pm.*, c.order AS module_order, c.name AS parent_name, c.code AS parent_code, c.id as parent_id
SELECT
pm.*,
c.order AS module_order,
c.name AS parent_name,
c.code AS parent_code,
c.id as parent_id
FROM children c, programme_modules pm, programme_module_children pmc
WHERE c.id = pmc.parent_id AND pm.group_id = pmc.child_id AND (ARRAY[?]::text[] && pm.curriculum_period_ids OR pm.type = 'course' OR pm.code is null)
WHERE c.id = pmc.parent_id
AND pm.group_id = pmc.child_id
AND (
ARRAY[:curriculum_period_ids]::text[] && pm.curriculum_period_ids
OR pm.type = 'course'
OR pm.code is null
)
GROUP BY pm.id, c.name, c.code, c.order, c.id
) SELECT * FROM children`,
{ replacements: [code, curriculum_period_ids, curriculum_period_ids], type: QueryTypes.SELECT }
) SELECT
id,
type,
module_order,
parent_code,
parent_name,
parent_id,
code,
name,
degree_programme_type
FROM children`,
{
replacements: { code, curriculum_period_ids },
type: QueryTypes.SELECT,
}
)
return result
} catch (error) {
@@ -94,11 +114,11 @@ const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_i
}
const modifyParent = (course: ModuleWithChildren, moduleMap: Record<string, ModuleWithChildren>) => {
let parent = moduleMap[course.parent_id]
let parent = moduleMap[course.parent_id!]
const parents: any[] = []
while (parent) {
parents.push(parent)
parent = moduleMap[parent.parent_id]
parent = moduleMap[parent.parent_id!]
}
const skip = 0
@@ -117,12 +137,13 @@ const modifyParent = (course: ModuleWithChildren, moduleMap: Record<string, Modu
const labelProgrammes = (modules: ModuleWithChildren[], excludedCourses: ExcludedCourseModel[]) => {
return modules.map(module => {
const label = {
id: module.parent_name.fi,
label: `${module.parent_code}\n${module.parent_name.fi}`,
id: module.parent_name?.fi,
label: `${module.parent_code}\n${module.parent_name?.fi}`,
orderNumber: module.module_order,
}
const foundCourse = excludedCourses.find(course => course.course_code === module.code)
const visible = { visibility: !foundCourse, id: foundCourse?.id ?? null }
return { ...module, label, visible }
})
}
@@ -157,15 +178,36 @@ const getCoursesAndModulesForProgramme = async (code: string, periodIds: string)
index
)
return { courses: labelProgrammes(modifiedCourses, excludedCourses), modules }
return {
courses: labelProgrammes(modifiedCourses, excludedCourses).map(mod => ({
code: mod.code,
name: mod.name,
degree_programme_type: mod.degree_programme_type,
module_order: mod.module_order,
parent_code: mod.parent_code,
parent_name: mod.parent_name,
label: mod.label,
visible: mod.visible,
})),
modules: modules.map(mod => ({
code: mod.code,
name: mod.name,
degree_programme_type: mod.degree_programme_type,
module_order: mod.module_order,
parent_code: mod.parent_code,
parent_name: mod.parent_name,
})),
}
}
export const getCoursesAndModules = async (code: string, periodIds: string) => {
const defaultProgrammeCourses = await getCoursesAndModulesForProgramme(code, periodIds)
if (code in combinedStudyProgrammes) {
const secondProgramme = combinedStudyProgrammes[code as keyof typeof combinedStudyProgrammes]
const secondProgrammeCourses = await getCoursesAndModulesForProgramme(secondProgramme, periodIds)
return { defaultProgrammeCourses, secondProgrammeCourses }
}
return { defaultProgrammeCourses, secondProgrammeCourses: { courses: [], modules: [] } }
}
+16 -12
View File
@@ -1,37 +1,41 @@
import { Op } from 'sequelize'
import { Name } from '@oodikone/shared/types'
import { dateMinFromList, dateMaxFromList } from '@oodikone/shared/util/datetime'
import { SemesterModel } from '../models'
import { dateMinFromList, dateMaxFromList } from '../util/datetime'
type SemestersAndYears = {
years: {
[yearcode: string]: {
years: Record<
string,
{
yearcode: number
yearname: string
startdate: Date
enddate: Date
}
}
semesters: {
[semestercode: string]: {
>
semesters: Record<
string,
{
semestercode: number
name: Name
yearcode: number
startdate: Date
enddate: Date
}
}
>
}
export const getSemesterNameByCode = async (semesterCode: number) => {
const semester = await SemesterModel.findOne({
attributes: ['name'],
export const getSemesterNames = async (semesterCodes: number[]) => {
const semesters = await SemesterModel.findAll({
attributes: ['name', 'semestercode'],
where: {
semestercode: semesterCode,
semestercode: { [Op.in]: semesterCodes },
},
raw: true,
})
return semester!
return Object.fromEntries(semesters?.map(({ name, semestercode }) => [semestercode, name]) ?? [])
}
export const getCurrentSemester = async () => {
+14 -8
View File
@@ -1,7 +1,8 @@
import { Op, QueryTypes } from 'sequelize'
import type { Credit, Student } from '@oodikone/shared/models'
import { EnrollmentState } from '@oodikone/shared/types'
import { EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
import { dbConnections } from '../database/connection'
import {
StudentModel,
@@ -14,7 +15,6 @@ import {
SISStudyRightElementModel,
} from '../models'
import { TagModel, TagStudentModel } from '../models/kone'
import { UnifyStatus } from '../types'
import { splitByEmptySpace } from '../util'
const { sequelize } = dbConnections
@@ -217,7 +217,7 @@ const formatSharedStudentData = ({
updatedAt,
createdAt,
sis_person_id,
}: Student) => {
}: Student): Omit<StudentPageStudent, 'tags'> => {
const toCourse = ({ grade, credits, credittypecode, is_open, attainment_date, course, isStudyModule }: Credit) => {
return {
course: {
@@ -239,8 +239,8 @@ const formatSharedStudentData = ({
.map(toCourse)
return {
firstnames,
lastname,
firstNames: firstnames,
lastName: lastname,
studyRights: studyRights || [],
studentNumber: studentnumber,
started: dateofuniversityenrollment,
@@ -250,18 +250,24 @@ const formatSharedStudentData = ({
email,
updatedAt: updatedAt || createdAt,
studyplans: studyplans || [],
sis_person_id,
sisPersonId: sis_person_id,
}
}
export const withStudentNumber = async (studentNumber: string, userId: string) => {
export const withStudentNumber = async (studentNumber: string, userId: string): Promise<StudentPageStudent | null> => {
const student = await byStudentNumber(studentNumber)
if (student == null) {
return null
}
return {
...formatSharedStudentData(student),
tags: student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId),
tags: student.tags
.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
.map(tag => ({
programme: tag.programme,
tagId: tag.tag_id,
tag: tag.tag,
})),
}
}
@@ -2,44 +2,13 @@ import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash'
import { Name } from '@oodikone/shared/types'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
import { StudyGuidanceGroupTagModel } from '../models/kone'
import { getImporterClient } from '../util/importerClient'
import logger from '../util/logger'
const importerClient = getImporterClient()
type ValidityPeriod = { startDate?: string; endDate?: string }
type ResponsibilityInfo = {
text: string | null
roleUrn: string
personId: string
validityPeriod: ValidityPeriod
}
type Member = {
id: string
personGroupId: string
personGroupName: Name
personId: string
personFirstNames: string
personLastName: string
personStudentNumber: string
validityPeriod: ValidityPeriod
personPrimaryEmail: string
}
type StudyGuidanceGroup = {
id: string
name: Name
responsibilityInfos: ResponsibilityInfo[]
type: string
createdAt: string
updatedAt: string
members: Member[]
}
const getGroupsFromImporter = async (sisPersonId: string) => {
if (!importerClient || !sisPersonId) {
return []
@@ -66,14 +35,6 @@ const getGroupsFromImporter = async (sisPersonId: string) => {
}
}
type Tags = { studyProgramme?: string | null; year?: string | null }
type TagsByGroupId = {
[groupId: string]: Tags
}
type GroupsWithTags = StudyGuidanceGroup & { tags: Tags }
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
const tags = await StudyGuidanceGroupTagModel.findAll()
const tagsByGroupId = tags.reduce((acc, curr) => {
@@ -15,9 +15,7 @@ import { getStudyRightsInProgramme } from './studyRightFinders'
export const getDateOfFirstSemesterPresent = (
semesterEnrollments: SemesterEnrollment[] | null,
semesters: {
[semestercode: string]: { semestercode: number; name: Name; yearcode: number; startdate: Date; enddate: Date }
},
semesters: Record<string, { semestercode: number; name: Name; yearcode: number; startdate: Date; enddate: Date }>,
currentSemester: number,
since: Date
) => {
@@ -67,46 +67,40 @@ const makeYearlyPromises = (
type: 'passed' | 'notCompleted' | 'ownStudents' | 'withoutStudyRight' | 'otherStudents' | 'transfer',
programmeCourses: string[],
studyProgramme?: string
): Promise<Students[]>[] => {
return years.map(
year =>
// eslint-disable-next-line no-async-promise-executor
new Promise(async res => {
const from = getFrom(academicYear, year)
const to = getTo(academicYear, year)
let result: Students[] | null = null
) => {
return years.map(async year => {
const from = getFrom(academicYear, year)
const to = getTo(academicYear, year)
let result: Students[] | null = null
switch (type) {
case 'passed':
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
break
case 'notCompleted':
result = await getNotCompletedForProgrammeCourses(from, to, programmeCourses)
break
case 'ownStudents':
result = await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'withoutStudyRight':
result = await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)
break
case 'otherStudents':
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'transfer':
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
break
default:
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
}
switch (type) {
case 'passed':
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
break
case 'notCompleted':
result = await getNotCompletedForProgrammeCourses(from, to, programmeCourses)
break
case 'ownStudents':
result = await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'withoutStudyRight':
result = await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)
break
case 'otherStudents':
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'transfer':
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
break
default:
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
}
res(
result.map(course => {
course.year = year
return course
})
)
})
)
return result.map(course => {
course.year = year
return course
})
})
}
type Attempt = {
@@ -70,6 +70,7 @@ const createCriteria = async (
return await formatCriteria(createdCriteria)
} catch (error) {
logger.error(`Creating criteria failed: ${error}`)
return null
}
}
@@ -100,6 +101,7 @@ export const saveYearlyCreditCriteria = async (studyProgramme: string, credits:
return await formatCriteria(updatedCriteria)
} catch (error) {
logger.error(`Updating yearly credit criteria failed: ${error}`)
return null
}
}
@@ -143,6 +145,7 @@ export const saveYearlyCourseCriteria = async (studyProgramme: string, courses:
return await formatCriteria(updatedCriteria)
} catch (error) {
logger.error(`Updating yearly credit criteria failed: ${error}`)
return null
}
}
@@ -1,5 +1,5 @@
import dayjs from 'dayjs'
import { indexOf, orderBy } from 'lodash'
import moment from 'moment'
import {
GraduationStatistics,
@@ -49,15 +49,12 @@ const calculateAbsenceInMonths = (
startDate: Date,
endDate: Date
) => {
const absenceStart = moment(absence.startdate)
const absenceEnd = moment(absence.enddate)
if (absenceStart.isAfter(endDate) || absenceEnd.isBefore(startDate)) {
if (endDate < absence.startdate || absence.enddate < startDate) {
return 0
}
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
return Math.round(absenceEnd.diff(absenceStart, 'months', true))
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
}
export const calculateDurationOfStudies = (
@@ -73,7 +70,7 @@ export const calculateDurationOfStudies = (
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
0
)
return Math.round(moment(graduationDate).subtract(monthsToSubtract, 'months').diff(moment(startDate), 'months', true))
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
}
export type GraduationTimes = {
@@ -238,9 +235,7 @@ type QueryParameters = {
includeAllSpecials: boolean
}
type ProgrammeWithYears = {
[key: string]: number | string | Name
} & StudyTrack
type ProgrammeWithYears = Record<string, number | string | Name> & StudyTrack
const getProgrammesBeforeStarting = async ({
studyProgramme,
@@ -1,11 +1,11 @@
import { orderBy } from 'lodash'
import { Op } from 'sequelize'
import { SISStudyRightElement } from '@oodikone/shared/models'
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel, SISStudyRightModel } from '../../models'
import { ProgrammeModuleModel } from '../../models'
import { getDegreeProgrammeType } from '../../util'
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
@@ -198,10 +198,7 @@ export const getGoal = async (programme?: string) => {
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
export const getStudyRightElementsWithPhase = (
studyRight: Pick<SISStudyRightModel, 'studyRightElements'>,
phase: Phase
) => {
export const getStudyRightElementsWithPhase = (studyRight: Pick<SISStudyRight, 'studyRightElements'>, phase: Phase) => {
return orderBy(
studyRight.studyRightElements.filter(element => element.phase === phase),
['startDate'],
@@ -210,8 +207,8 @@ export const getStudyRightElementsWithPhase = (
}
export const hasTransferredFromOrToProgramme = (
studyRight: Pick<SISStudyRightModel, 'studyRightElements'>,
studyRightElement: SISStudyRightElement
studyRight: Pick<SISStudyRight, 'studyRightElements'>,
studyRightElement: Pick<SISStudyRightElement, 'code' | 'phase' | 'startDate' | 'endDate'>
): [boolean, boolean] => {
const studyRightElementsWithSamePhase = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
const hasTransferredToProgramme =
@@ -1,5 +1,3 @@
import moment from 'moment'
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import {
GraduationTimes,
@@ -31,9 +29,7 @@ import {
import { getStudyTracksForProgramme } from './studyRightFinders'
const getCreditCount = (credits: Credit[], startDate: Date) =>
credits
.filter(credit => moment(credit.attainment_date).isSameOrAfter(startDate))
.reduce((prev, curr) => prev + curr.credits, 0)
credits.filter(credit => startDate <= credit.attainment_date).reduce((prev, curr) => prev + curr.credits, 0)
const getGraduationTimeStats = async (
studyProgramme: string,
@@ -120,12 +116,9 @@ const combineStats = (
for (const year of Object.keys(yearlyStats)) {
for (const programmeOrStudyTrack of Object.keys(yearlyStats[year])) {
const yearStats = yearlyStats[year][programmeOrStudyTrack]
if (!mainStatsByYear[year]) {
mainStatsByYear[year] = []
}
if (!mainStatsByTrack[programmeOrStudyTrack]) {
mainStatsByTrack[programmeOrStudyTrack] = []
}
mainStatsByYear[year] ??= []
mainStatsByTrack[programmeOrStudyTrack] ??= []
const yearArray = [
yearStats.all,
@@ -201,13 +194,11 @@ const getMainStatsByTrackAndYear = async (
startedInProgramme: Date,
studyRightElement: SISStudyRightElement
) => {
if (!yearlyStats[year]) {
yearlyStats[year] = {}
}
if (!yearlyStats[year][programmeOrStudyTrack]) {
yearlyStats[year][programmeOrStudyTrack] = getEmptyYear()
}
yearlyStats[year] ??= {}
yearlyStats[year][programmeOrStudyTrack] ??= getEmptyYear()
yearlyStats[year][programmeOrStudyTrack].all += 1
/* Started studying col */
if (!hasTransferredToProgramme) {
const firstPresentAt = getDateOfFirstSemesterPresent(
studyRight.semesterEnrollments,
@@ -222,6 +213,8 @@ const getMainStatsByTrackAndYear = async (
}
}
}
/* Gender cols */
if (studyRight.student.gender_code === GenderCode.MALE) {
yearlyStats[year][programmeOrStudyTrack].male += 1
} else if (studyRight.student.gender_code === GenderCode.FEMALE) {
@@ -230,6 +223,7 @@ const getMainStatsByTrackAndYear = async (
yearlyStats[year][programmeOrStudyTrack].otherUnknown += 1
}
/* Current status cols */
const hasGraduated = studyRightElement.graduated
const hasGraduatedFromCombinedProgramme = combinedProgramme
? (studyRight.studyRightElements.find(element => element.code === combinedProgramme)?.graduated ?? false)
@@ -238,6 +232,7 @@ const getMainStatsByTrackAndYear = async (
if (hasGraduated) {
yearlyStats[year][programmeOrStudyTrack].graduated += 1
}
if (hasGraduatedFromCombinedProgramme) {
yearlyStats[year][programmeOrStudyTrack].graduatedCombinedProgramme += 1
} else if (combinedProgramme || !hasGraduated) {
@@ -253,6 +248,7 @@ const getMainStatsByTrackAndYear = async (
}
}
/* Citizenship cols */
const studentCitizenships = studyRight.student.citizenships
for (const citizenship of studentCitizenships) {
@@ -269,6 +265,7 @@ const getMainStatsByTrackAndYear = async (
}
}
/* Graduation statistics */
if (!hasGraduated) return
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
const [firstStudyRightElementWithSamePhase] = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
@@ -368,7 +365,7 @@ const getMainStatsByTrackAndYear = async (
studyRightElement
)
const studyTrack = studyRightElement.studyTrack?.code ?? null
const studyTrack = studyRightElement.studyTrack?.code
if (studyTrack) {
updateCounts(
startYear,
@@ -379,6 +376,7 @@ const getMainStatsByTrackAndYear = async (
studyRightElement
)
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
@@ -395,12 +393,8 @@ const getMainStatsByTrackAndYear = async (
for (const year of Object.keys(yearlyStats)) {
if (year === 'Total') continue
for (const track of Object.keys(yearlyStats[year])) {
if (!yearlyStats.Total) {
yearlyStats.Total = {}
}
if (!yearlyStats.Total[track]) {
yearlyStats.Total[track] = getEmptyYear()
}
yearlyStats.Total ??= {}
yearlyStats.Total[track] ??= getEmptyYear()
for (const field of keysOf(yearlyStats[year][track])) {
if (field !== 'otherCountriesCounts') {
@@ -408,9 +402,7 @@ const getMainStatsByTrackAndYear = async (
continue
}
for (const country of Object.keys(yearlyStats[year][track].otherCountriesCounts)) {
if (!yearlyStats.Total[track].otherCountriesCounts[country]) {
yearlyStats.Total[track].otherCountriesCounts[country] = 0
}
yearlyStats.Total[track].otherCountriesCounts[country] ??= 0
yearlyStats.Total[track].otherCountriesCounts[country] +=
yearlyStats[year][track].otherCountriesCounts[country]
}
@@ -462,7 +454,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
studyProgramme: string
combinedProgramme?: string
settings: { graduated: boolean; specialGroups: boolean }
studyRightsOfProgramme: Array<SISStudyRight>
studyRightsOfProgramme: SISStudyRight[]
}) => {
const isAcademicYear = true
const includeYearsCombined = true
@@ -64,10 +64,8 @@ const parseCreditInfo = (credit: Credit) => ({
type Stats = { credits: number; passed: number; failed: number; transferred: number }
const markCredit = (stats: Stats, credits: number, passed: boolean, failed: boolean, transferred: boolean) => {
if (!stats) {
stats = { credits: 0, passed: 0, failed: 0, transferred: 0 }
}
const markCredit = (paramStats: Stats, credits: number, passed: boolean, failed: boolean, transferred: boolean) => {
const stats = paramStats ?? { credits: 0, passed: 0, failed: 0, transferred: 0 }
if (passed) {
stats.credits = transferred ? stats.credits : stats.credits + credits
stats.passed += 1
-11
View File
@@ -1,14 +1,3 @@
export { CourseWithSubsId, ParsedCourse } from './course'
export { IamAccess } from './iamAccess'
export { Unification, UnifyStatus } from './unification'
export { ExpandedUser, FormattedUser } from './user'
/**
* A utility type that extracts the type of the elements of an array. This type
* takes a generic type `T` which extends an array of any type, and returns the
* type of the elements in that array. If `T` is not an array, it returns `never`.
*
* @example
* type ElementType = Unarray<number[]>; // number
*/
export type Unarray<T extends any[]> = T extends Array<infer U> ? U : never
+1 -1
View File
@@ -1,4 +1,4 @@
import { dateDaysFromNow } from './datetime'
import { dateDaysFromNow } from '@oodikone/shared/util/datetime'
const isSpring = (date: Date) => {
return 0 <= date.getMonth() && date.getMonth() <= 6
+8 -8
View File
@@ -1,7 +1,6 @@
const Sentry = require('@sentry/node')
// eslint-disable-next-line no-redeclare
const { Worker } = require('bullmq')
const moment = require('moment')
const { Worker: BullMQWorker } = require('bullmq')
const dayjs = require('dayjs')
const { redis, concurrentWorkers } = require('../config')
const logger = require('../util/logger')
@@ -15,18 +14,19 @@ const connection = {
// https://github.com/taskforcesh/bullmq/issues/2075#issuecomment-1646079335
process.execArgv = process.execArgv.filter(arg => !arg.includes('--max_old_space_size='))
const worker = new Worker(queueName, `${__dirname}/processor.js`, {
const worker = new BullMQWorker(queueName, `${__dirname}/processor.js`, {
connection,
useWorkerThreads: true,
concurrency: concurrentWorkers,
})
worker.on('completed', job => {
const timeUsed = moment.duration(moment(job.finishedOn).diff(job.processedOn, undefined, true))
const timeUsedInSeconds = dayjs(job.finishedOn).diff(job.processedOn, 'seconds', true)
const timeUsedInMinutes = timeUsedInSeconds / 60
const formattedTime =
timeUsed.asSeconds() > 60
? `${timeUsed.asMinutes().toFixed(3)} minutes`
: `${timeUsed.asSeconds().toFixed(3)} seconds`
timeUsedInSeconds > 60 ? `${timeUsedInMinutes.toFixed(3)} minutes` : `${timeUsedInSeconds.toFixed(3)} seconds`
logger.info(`Completed job: ${job.id} (took ${formattedTime})`)
})
+1 -1
View File
@@ -19,6 +19,6 @@
"inlineSources": true,
"resolveJsonModule": true
},
"include": ["index.js", "src"],
"include": ["index.ts", "src"],
"exclude": ["../frontend"]
}
+6 -6
View File
@@ -10,25 +10,25 @@ ENV PUBLIC_URL=$BASE_PATH
ARG BUILT_AT
ENV REACT_APP_BUILT_AT=$BUILT_AT
ARG STAGING
ARG STAGING='false'
ENV REACT_APP_STAGING=$STAGING
ARG SENTRY_ENVIRONMENT
ARG SENTRY_ENVIRONMENT=''
ENV REACT_APP_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ARG SENTRY_RELEASE=''
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
ARG SENTRY_DSN
ARG SENTRY_DSN=''
ENV REACT_APP_SENTRY_DSN=$SENTRY_DSN
ARG SERVICE_PROVIDER
ARG SERVICE_PROVIDER='toska'
ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
ARG LANGUAGE_CENTER_VIEW_ENABLED
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
ARG SIS_URL
ARG SIS_URL='https://sisu.helsinki.fi'
ENV REACT_APP_SIS_URL=$SIS_URL
WORKDIR /opt/app-root/shared
+74 -30
View File
@@ -14,12 +14,13 @@
"@oodikone/shared": "file:../shared",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/browser": "^9.28.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.2.1",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datetime": "^3.3.1",
@@ -44,7 +45,7 @@
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^3.10.2",
"typescript": "^5.8.3",
"vite": "^6.3.5"
"vite": "^7.0.0"
}
},
"../shared": {
@@ -1775,12 +1776,12 @@
}
},
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
@@ -1812,9 +1813,10 @@
}
},
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -2147,6 +2149,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -2652,6 +2660,39 @@
"react-dom": ">=18.0"
}
},
"node_modules/material-react-table/node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/material-react-table/node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -3241,6 +3282,7 @@
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"peer": true,
"engines": {
"node": "*"
}
@@ -3251,9 +3293,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@@ -3261,6 +3303,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -3360,9 +3403,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -3380,7 +3423,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3790,6 +3833,7 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -4040,24 +4084,24 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"fdir": "^6.4.6",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -4066,14 +4110,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "*",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
+3 -2
View File
@@ -17,12 +17,13 @@
"@oodikone/shared": "file:../shared",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/browser": "^9.28.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.2.1",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datetime": "^3.3.1",
@@ -47,7 +48,7 @@
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^3.10.2",
"typescript": "^5.8.3",
"vite": "^6.3.5"
"vite": "^7.0.0"
},
"overrides": {
"react-highcharts": {
+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 80 80"
>
<path
d="M70 30L60 56H20L10 30L27 40L40 15L52 40L70 30Z"
fill="#fff238"
stroke="#696969"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M40 52l9 -11l-9 -13l-9 13l9 11"
fill="#61DAFF"
stroke="#E8740E"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

+12
View File
@@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 80 80"
>
<path
d="M70 30L60 56H20L10 30L27 40L40 15L52 40L70 30Z"
fill="#fff238"
stroke="#696969"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+1 -15
View File
@@ -35,7 +35,7 @@ const actionSuffixes = {
success: 'SUCCESS',
}
export const actionTypes = prefix => ({
const actionTypes = prefix => ({
attempt: `${prefix}${actionSuffixes.attempt}`,
failure: `${prefix}${actionSuffixes.failure}`,
success: `${prefix}${actionSuffixes.success}`,
@@ -62,20 +62,6 @@ export const callApi = async (url, method = 'get', data, params, timeout = 0, pr
}
}
export const callController = (route, prefix, data, method = 'get', query, params, onProgress = null) => {
const requestSettings = {
route,
method,
data,
prefix,
query,
params,
onProgress,
}
const { attempt } = actionTypes(prefix)
return { type: attempt, requestSettings }
}
const handleError = (error, actionHistory = []) => {
const { response } = error
if (response && response.status) {
@@ -19,5 +19,5 @@ export const filterToolTips = {
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
`,
transferred:
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta. Oletusarvoisesti pois päältä Class statistics -näkymässä.',
}
@@ -1,5 +1,10 @@
export const populationStatisticsToolTips = {
advanced: `
advanced: {
semesters:
'Halutessa tarkastella vain syyslukukaudella (1.8.–31.12.) tai kevätlukukaudella (1.1.–31.7.) aloittaneita, haluttu ajanjakso valitaan tästä.',
include:
'Oletusarvoisesti pois suodatettuja ryhmiä voi sisällyttää mukaan tästä. Muutokset astuvat voimaan painamalla ”Fetch class with new settings”-painiketta.',
legacy: `
Tässä näkymässä on **oletusarvoisesti** suodatettu pois
- vaihto-opiskelijat
- opiskelijat, joilla ei ole tutkintoon johtavaa opinto-oikeutta
@@ -11,6 +16,7 @@ export const populationStatisticsToolTips = {
**Include**: Oletusarvoisesti pois suodatetut ryhmät voi sisällyttää mukaan ja klikkaamalla ”Fetch class with new settings”-painiketta.
`,
},
creditAccumulation: `
**Credit accumulation**
@@ -2,7 +2,8 @@ export const studentToolTips = {
studyRightStatus: `
Shows the status of the current semester enrollment of the student in the corresponding programme.
**Active:** Student has a semester enrollment to the corresponding programme as either present or absent.
**Active:** Student has a semester enrollment to the corresponding programme as present.
**Absent:** Student has a semester enrollment to the corresponding programme as absent.
**Inactive:** Student has not enrolled to the corresponding programme this semester.
**Cancelled:** Study right for the corresponding programme is cancelled.
**Graduated:** Student has graduated from the corresponding programme.
@@ -1,46 +1,90 @@
import { filter, maxBy, orderBy, range } from 'lodash'
import moment from 'moment'
import dayjs from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import { maxBy, orderBy, range } from 'lodash'
import irtomikko from '@/assets/irtomikko.png'
import toskaLogo from '@/assets/toska.svg'
import { serviceProvider } from '@/conf'
import { SemestersData } from '@/redux/semesters'
import type { Absence } from '@/types/students'
import type { SISStudyRightElement } from '@oodikone/shared/models'
import {
FormattedStudent,
Release,
CreditTypeCode,
Name,
Unarray,
SemesterEnrollment,
EnrollmentType,
} from '@oodikone/shared/types'
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
export const textAndDescriptionSearch = (dropDownOptions, param) =>
filter(dropDownOptions, option =>
dayjs.extend(isBetween)
export const textAndDescriptionSearch = (dropDownOptions: any[], param: string) =>
dropDownOptions.filter(option =>
option.text
? option.text.toLowerCase().concat(option.description.toLowerCase()).includes(param.toLowerCase())
: null
)
export const images = {
toskaLogo,
irtomikko,
export const isFall = (semester: number) => semester % 2 === 1
/**
* @param currentSemester semesterCode for wanted semester
* @param semesterEnrollments fetched from within a studyRight
* @returns boolean depicting if registration for given semester is "absent"
*/
const semesterEnrollmentAbsence = (
currentSemester: number,
semesterEnrollments: SemesterEnrollment[] | undefined | null
) => {
const relevantSemesterEnrollment = semesterEnrollments?.find(se => se.semester === currentSemester)
return relevantSemesterEnrollment?.type === EnrollmentType.ABSENT
}
export const isFall = semester => semester % 2 === 1
// NB: statutory absence is named just "absent"
export const getStudyRightStatusText = (
programme: { active: boolean; graduated: boolean; cancelled: boolean } | undefined,
studyRight: StudentStudyRight | undefined,
semestercode: number | undefined
) => {
if (!programme) return null
if (programme.graduated) return 'Graduated'
if (programme.cancelled) return 'Cancelled'
if (semesterEnrollmentAbsence(semestercode ?? NaN, studyRight?.semesterEnrollments)) return 'Absent'
if (programme.active) return 'Active'
return 'Inactive'
}
export const getStudentTotalCredits = (student, includeTransferredCredits = true) => {
export const getStudentTotalCredits = (
student: Pick<FormattedStudent, 'courses'>,
includeTransferredCredits = true
) => {
const passedCourses = includeTransferredCredits
? student.courses.filter(course => [4, 9].includes(course.credittypecode) && !course.isStudyModuleCredit)
: student.courses.filter(course => course.credittypecode === 4 && !course.isStudyModuleCredit)
? student.courses.filter(
course =>
[CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode) &&
!course.isStudyModuleCredit
)
: student.courses.filter(course => course.credittypecode === CreditTypeCode.PASSED && !course.isStudyModuleCredit)
return passedCourses.reduce((a, b) => a + b.credits, 0)
}
const getGradedCourses = (student, includeTransferredCredits = true) =>
const getGradedCourses = (student: FormattedStudent, includeTransferredCredits = true) =>
includeTransferredCredits
? student.courses.filter(course => Number(course.grade) && !course.isStudyModuleCredit)
: student.courses.filter(
course => Number(course.grade) && !course.isStudyModuleCredit && course.credittypecode !== 9
course =>
Number(course.grade) && !course.isStudyModuleCredit && course.credittypecode !== CreditTypeCode.APPROVED
)
export const getStudentGradeMean = (student, includeTransferredCredits = true) => {
export const getStudentGradeMean = (student: FormattedStudent, includeTransferredCredits = true) => {
const courses = getGradedCourses(student, includeTransferredCredits)
const gradeTotal = courses.reduce((a, b) => a + Number(b.grade), 0)
const mean = gradeTotal / courses.length || 0
return mean
}
export const getStudentGradeMeanWeightedByCredits = (student, includeTransferredCredits = true) => {
export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent, includeTransferredCredits = true) => {
const courses = getGradedCourses(student, includeTransferredCredits)
const gradeTotal = courses.reduce((a, b) => a + Number(b.grade) * Number(b.credits), 0)
const sumWeights = courses.reduce((a, b) => a + Number(b.credits), 0)
@@ -48,7 +92,12 @@ export const getStudentGradeMeanWeightedByCredits = (student, includeTransferred
return mean
}
export const getTextInWithOpen = (course, getTextIn, isOpenCourse, isStudyModuleCredit) => {
export const getTextInWithOpen = (
course: { code: string; name: Name },
getTextIn: any,
isOpenCourse: boolean,
isStudyModuleCredit: boolean
) => {
if (!course) return ''
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
let courseName = getTextIn(course.name)
@@ -65,7 +114,7 @@ export const getTextInWithOpen = (course, getTextIn, isOpenCourse, isStudyModule
return courseName
}
export const getUnifyTextIn = unifyCourses => {
export const getUnifyTextIn = (unifyCourses: string) => {
switch (unifyCourses) {
case 'regularStats':
return '(normal)'
@@ -79,26 +128,37 @@ export const getUnifyTextIn = unifyCourses => {
}
// Gives students course completion date
export const getStudentToTargetCourseDateMap = (students, codes) => {
export const getStudentToTargetCourseDateMap = (
students: Pick<FormattedStudent, 'studentNumber' | 'courses'>[],
codes: string[]
) => {
const codeSet = new Set(codes)
return students.reduce((acc, student) => {
const targetCourse = student.courses
.filter(course => codeSet.has(course.course_code))
.sort((a, b) => new Date(b.date) - new Date(a.date))[0]
.sort((a, b) => +new Date(b.date) - +new Date(a.date))[0]
acc[student.studentNumber] = targetCourse ? targetCourse.date : null
return acc
}, {})
}
const programmeIsActive = (studyRight, hasGraduated, currentSemesterCode) =>
!studyRight.cancelled &&
!hasGraduated &&
(currentSemesterCode == null ||
studyRight.semesterEnrollments?.find(
enrollment => enrollment.semester === currentSemesterCode && [1, 2].includes(enrollment.type)
) != null)
const programmeIsActive = (
studyRight: Unarray<FormattedStudent['studyRights']>,
hasGraduated: boolean,
currentSemesterCode: number | null
) => {
return (
!studyRight.cancelled &&
!hasGraduated &&
!!studyRight.semesterEnrollments?.some(
enrollment =>
enrollment.semester === currentSemesterCode &&
[EnrollmentType.PRESENT, EnrollmentType.ABSENT].includes(enrollment.type)
)
)
}
export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
export const getAllProgrammesOfStudent = (studyRights: FormattedStudent['studyRights'], currentSemester: number) =>
orderBy(
studyRights?.flatMap(studyRight =>
studyRight.studyRightElements
@@ -108,6 +168,7 @@ export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
name: element.name,
graduated: element.graduated,
startDate: element.startDate,
cancelled: studyRight.cancelled,
active: programmeIsActive(studyRight, element.graduated, currentSemester),
facultyCode: studyRight.facultyCode,
}))
@@ -116,50 +177,36 @@ export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
['desc', 'desc']
)
export const getNewestProgrammeOfStudentAt = (studyRights, currentSemester, date) => {
export const getNewestProgrammeOfStudentAt = (
studyRights: FormattedStudent['studyRights'],
currentSemester: number,
date: string
) => {
const programmes = getAllProgrammesOfStudent(studyRights, currentSemester)
if (!programmes.length) return null
if (!date) return programmes[0]
return programmes.find(programme => moment(date).isSameOrAfter(programme.startDate)) ?? null
return programmes.find(programme => dayjs(date).isSameOrAfter(programme.startDate)) ?? null
}
export const getHighestGradeOfCourseBetweenRange = (courses, lowerBound, upperBound) => {
const grades = []
courses.forEach(course => {
if (
new Date(lowerBound).getTime() <= new Date(course.date).getTime() &&
new Date(course.date).getTime() <= new Date(upperBound).getTime()
) {
if (course.grade === 'Hyv.') {
grades.push({ grade: course.grade, value: 1 })
} else if (!Number(course.grade)) {
grades.push({ grade: course.grade, value: 0 })
} else {
grades.push({ grade: course.grade, value: Number(course.grade) })
}
}
})
return maxBy(grades, grade => grade.value)
export const getHighestGradeOfCourseBetweenRange = (
courses: FormattedStudent['courses'],
lowerBound: string,
upperBound: string
) => {
const grades = courses
.filter(course => new Date(lowerBound) <= new Date(course.date) && new Date(course.date) <= new Date(upperBound))
.map(course => {
if (course.grade === 'HT') return { grade: course.grade, sortValue: 2 }
if (course.grade === 'Hyv.' || course.grade === 'TT') return { grade: course.grade, sortValue: 1 }
if (!Number(course.grade)) return { grade: course.grade, sortValue: 0 }
return { grade: course.grade, sortValue: Number(course.grade) }
})
return maxBy(grades, grade => grade.sortValue)?.grade
}
export const getHighestGradeOrEnrollmentOfCourseBetweenRange = (courses, enrollments, lowerBound, upperBound) => {
const grade = getHighestGradeOfCourseBetweenRange(courses, lowerBound, upperBound)
if (!grade) return enrollments.length ? { grade: 'No grade' } : undefined
return grade
}
export const findStudyRightForClass = (studyRights, programmeCode, year) =>
studyRights.find(studyRight =>
studyRight.studyRightElements.some(
element =>
element.code === programmeCode &&
(year == null ||
year === 'All' ||
moment(element.startDate).isBetween(`${year}-08-01`, `${Number(year) + 1}-07-31`, 'day', '[]'))
)
)
export const getTargetCreditsForProgramme = code => {
export const getTargetCreditsForProgramme = (code: string) => {
if (code === 'MH30_001' || code === 'KH90_001-MH90_001') return 360
if (code === 'MH30_003') return 330
if (code === 'MH30_004') return 150
@@ -170,11 +217,12 @@ export const getTargetCreditsForProgramme = code => {
// Those codes begin with 'LIS' is it 40 credits or something else?
}
export const isMastersProgramme = programmeCode => programmeCode.startsWith('MH') || programmeCode.endsWith('-ma')
export const isMastersProgramme = (programmeCode: string) =>
programmeCode.startsWith('MH') || programmeCode.endsWith('-ma')
export const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
export const getMonthsForDegree = (code: string) => getTargetCreditsForProgramme(code) / (60 / 12)
export const calculatePercentage = (numerator, denominator, numberOfDecimals = 2) =>
export const calculatePercentage = (numerator: number, denominator: number, numberOfDecimals = 2) =>
new Intl.NumberFormat(undefined, {
style: 'percent',
minimumFractionDigits: numberOfDecimals,
@@ -186,65 +234,69 @@ export const calculatePercentage = (numerator, denominator, numberOfDecimals = 2
* and for masters a 2 year target. Any absences within the study right element extends the end date.
* If any absence is in the start of study right element the start date is postponed.
*/
export const getStudyRightElementTargetDates = (studyRightElement, absences = []) => {
export const getStudyRightElementTargetDates = (
studyRightElement: Pick<SISStudyRightElement, 'code' | 'startDate'> | undefined,
absences: Absence[]
) => {
if (!studyRightElement) return []
const { code, startDate } = studyRightElement
const { code, startDate: sreStartDate } = studyRightElement
const months = getMonthsForDegree(code)
const end =
const sreEndDateTarget =
code.includes('KH') || code.includes('ba') || ['MH30_001', 'MH30_003'].includes(code)
? moment(startDate).add(months, 'months').set('month', 6).endOf('month')
: moment(startDate).add(months, 'months')
? dayjs(sreStartDate).add(months, 'months').set('month', 6).endOf('month').toDate()
: dayjs(sreStartDate).add(months, 'months').toDate()
if (!absences) return [new Date(sreStartDate), sreEndDateTarget]
if (!absences) return [new Date(startDate), new Date(end)]
const absencesWithinStudyRightElement = absences.filter(
({ startdate, enddate }) => startdate >= new Date(startDate).getTime() && enddate <= new Date(end).getTime()
({ startDate, endDate }) => startDate >= sreStartDate && endDate <= sreEndDateTarget
)
if (!absencesWithinStudyRightElement.length) return [new Date(startDate), new Date(end)]
if (!absencesWithinStudyRightElement.length) return [sreStartDate, sreEndDateTarget]
const absenceInStartOfStudyRight = absencesWithinStudyRightElement.find(({ startDate }) => sreStartDate === startDate)
const absenceInStartOfStudyRight = absencesWithinStudyRightElement.find(
({ startdate }) => new Date(startDate).getTime() === startdate
)
const absentMonthsDuringStudy = Math.round(
absencesWithinStudyRightElement
.filter(({ startdate, enddate }) => {
.filter(({ startDate, endDate }) => {
if (!absenceInStartOfStudyRight) return true
return startdate !== absenceInStartOfStudyRight.startdate && enddate !== absenceInStartOfStudyRight.enddate
return startDate !== absenceInStartOfStudyRight.startDate && endDate !== absenceInStartOfStudyRight.endDate
})
.filter(({ startdate }) => startdate < new Date(end).getTime())
.reduce((acc, absent) => {
const { startdate, enddate } = absent
const diff = moment(startdate).diff(moment(enddate), 'days') / 30
.filter(({ startDate }) => startDate < sreEndDateTarget)
.reduce((acc, absence) => {
const { startDate, endDate } = absence
const diff = dayjs(startDate).diff(endDate, 'days') / 30
return acc + Math.abs(diff)
}, 0)
)
const absentMonthsBeforeStudy = absenceInStartOfStudyRight
? Math.round(
Math.abs(
moment(absenceInStartOfStudyRight.startdate).diff(moment(absenceInStartOfStudyRight.enddate), 'days') / 30
)
Math.abs(dayjs(absenceInStartOfStudyRight.startDate).diff(absenceInStartOfStudyRight.endDate, 'days') / 30)
)
: 0
return [
new Date(moment(startDate).add(absentMonthsBeforeStudy, 'months')),
new Date(end.add(absentMonthsDuringStudy + absentMonthsBeforeStudy, 'months')),
dayjs(sreStartDate).add(absentMonthsBeforeStudy, 'months').toDate(),
dayjs(sreEndDateTarget)
.add(absentMonthsDuringStudy + absentMonthsBeforeStudy, 'months')
.toDate(),
]
}
export const TimeDivision = {
ACADEMIC_YEAR: 'academic-year',
CALENDAR_YEAR: 'calendar-year',
SEMESTER: 'semester',
export enum TimeDivision {
ACADEMIC_YEAR = 'Academic year',
CALENDAR_YEAR = 'Calendar year',
SEMESTER = 'Semester',
}
/* Returns an array of credit categories depending on parameters, shows the high limit
of the category, for example [20, 40, 60, 80, 100, 120] where the first category is 0 - 20 */
export const getCreditCategories = (
cumulative,
timeDivision,
programmeCredits,
timeSlots,
creditCategoryAmount,
cumulative: boolean,
timeDivision: TimeDivision,
programmeCredits: number,
timeSlotsLength: number,
creditCategoryAmount: number,
minCredits = 0
) => {
// In calendar-year mode, minus 30 from target credits because programmes (usually) start in autumn,
@@ -253,9 +305,9 @@ export const getCreditCategories = (
const isPastAugust = new Date().getMonth() > 6
const calendarModifier = 30 + (isPastAugust ? 0 : 30)
const creditsByTimeslots =
timeSlots.length * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
timeSlotsLength * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
const maxCredits = creditsByTimeslots > programmeCredits ? programmeCredits : creditsByTimeslots
const creditCategoryArray = []
const creditCategoryArray: number[] = []
for (let i = 1; i <= creditCategoryAmount; i++) creditCategoryArray.push(i)
const limitBreaks = cumulative
@@ -264,25 +316,25 @@ export const getCreditCategories = (
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
}
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
export const validateInputLength = (input: string, minLength: number) => input?.trim().length >= minLength
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
export const splitByEmptySpace = (str: string) => str.replace(/\s\s+/g, ' ').split(' ')
export const getCurrentSemester = allSemesters => {
export const getCurrentSemester = (allSemesters: SemestersData['semesters']) => {
if (!allSemesters) return null
return Object.values(allSemesters).find(
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
)
}
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
export const isNewStudyProgramme = (programmeCode: string) => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
/**
* Extracts items from a string separated by commas, semicolons, spaces, or newlines.
* @param {string} input - The input string containing items.
* @returns An array of extracted items.
*/
export const extractItems = input => {
export const extractItems = (input: string) => {
const items = input.match(/[^\s,;]+/g) ?? []
return [...items]
}
@@ -345,7 +397,7 @@ export const languageAbbreviations = {
export const showAsUserKey = 'showAsUser'
export const getEnrollmentTypeTextForExcel = (type, statutoryAbsence) => {
export const getEnrollmentTypeTextForExcel = (type: number, statutoryAbsence?: boolean) => {
if (type === 1) return 'Present'
if (type === 2 && statutoryAbsence) return 'Absent (statutory)'
if (type === 2) return 'Absent'
@@ -360,11 +412,11 @@ export const isDefaultServiceProvider = () => {
return serviceProvider === 'toska'
}
export const formatContent = content => content.replace(/\n +/g, '\n')
export const formatContent = (content: string) => content.replace(/\n +/g, '\n')
export const filterInternalReleases = release => !release.title.startsWith('Internal:')
export const filterInternalReleases = (release: Release) => !release.title.startsWith('Internal:')
export const getDescription = description => {
export const getDescription = (description: string) => {
const lines = description.split('\n')
const internalIndex = lines.findIndex(line => line.toLowerCase().includes('internal'))
if (internalIndex === -1 || internalIndex === 0) {
@@ -1,11 +1,12 @@
import CssBaseline from '@mui/material/CssBaseline'
import ThemeProvider from '@mui/material/styles/ThemeProvider'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment/AdapterMoment'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import HighCharts from 'highcharts'
import moment from 'moment'
import dayjs from 'dayjs'
import updateLocale from 'dayjs/plugin/updateLocale'
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
@@ -36,59 +37,44 @@ HighCharts.setOptions({
},
})
moment.updateLocale('en', {
dayjs.extend(updateLocale)
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
},
})
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
if (!isProduction || !id || !username) {
return
}
Sentry.setUser({ id, username, mockedBy })
}
const Layout = ({ children }) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<LanguageProvider>
<LocalizationProvider dateAdapter={AdapterMoment}>
<ThemeProvider theme={theme}>
<StatusNotificationProvider>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<StatusNotification />
<Footer />
</StatusNotificationProvider>
</ThemeProvider>
</LocalizationProvider>
</LanguageProvider>
</div>
<LanguageProvider>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<ThemeProvider theme={theme}>
<StatusNotificationProvider>{children}</StatusNotificationProvider>
</ThemeProvider>
</LocalizationProvider>
</LanguageProvider>
)
export const App = () => {
const { isLoading, error, id, username, mockedBy } = useGetAuthorizedUserQuery()
useEffect(() => {
if (isProduction) {
initShibbolethPinger()
}
if (isProduction) initShibbolethPinger()
}, [])
useEffect(() => {
addUserDetailsToLoggers({ id, username, mockedBy })
if (isProduction && !isLoading && id && username) Sentry.setUser({ id, username, mockedBy })
}, [id, username, mockedBy])
if (error) {
return <AccessDenied notEnabled />
}
if (isLoading) return <SegmentDimmer isLoading />
if (error) return <AccessDenied />
return <Layout>{isLoading ? <SegmentDimmer isLoading={isLoading} /> : <Routes />}</Layout>
return (
<Layout>
<CssBaseline />
<NavigationBar />
<Routes />
<StatusNotification />
<Footer />
</Layout>
)
}
@@ -44,7 +44,7 @@ export const FacultiesTab = () => {
const facultiesTotal = { ...emptyFields }
selectedSemesters.forEach(semestercode => {
data.faculties.forEach(faculty => {
if (!course.bySemesters.cellStats[faculty]) course.bySemesters.cellStats[faculty] = { ...emptyFields }
course.bySemesters.cellStats[faculty] ??= { ...emptyFields }
const stats = course.bySemesters[semestercode]?.[faculty]
if (!stats) return
course.bySemesters.cellStats[faculty].completions += stats.completions
@@ -13,7 +13,7 @@ export const getCourseCodeColumn = getTextIn => ({
getRowContent: row => (
<div style={{ display: 'flex', flexDirection: 'column' }} title={getTextIn(row.name)}>
<b>{row.code}</b>
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name && getTextIn(row.name)}</i>
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name ? getTextIn(row.name) : null}</i>
</div>
),
})
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Divider, Loader, Tab } from 'semantic-ui-react'
import { getCurrentSemester } from '@/common'
import { useTitle } from '@/hooks/title'
import { useGetSemestersQuery } from '@/redux/semesters'
import { ColorizedCoursesTableContext } from './common'
@@ -12,13 +11,11 @@ import './index.css'
export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, panes, infoBox, dividerText }) => {
useTitle(title)
const { data: semesterData } = useGetSemestersQuery()
const currentSemester = semesterData && getCurrentSemester(semesterData.semesters)
const semesters =
semesterData &&
Object.values(semesterData.semesters).filter(
// 135 = Fall 2017
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
)
const { semesters: allSemesters, currentSemester } = semesterData ?? { semesters: {}, currentSemester: null }
const semesters = Object.values(allSemesters).filter(
// 135 = Fall 2017
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
)
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
@@ -90,11 +87,11 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
return (
<ColorizedCoursesTableContext.Provider value={settingsContext}>
<div className="colorized-courses-table">
{dividerText && (
{dividerText ? (
<Divider horizontal style={{ marginTop: '3em' }}>
{dividerText}
</Divider>
)}
) : null}
{infoBox}
{displayedPanes.length === 1 ? displayedPanes[0].render() : <Tab panes={displayedPanes} />}
</div>
@@ -111,7 +111,7 @@ export const SemesterRangeSelector = () => {
</div>
<div>
<b>Until</b>
{semesters && (
{semesters ? (
<SemesterSelector
allSemesters={semesters?.filter(semester => {
return semesterFilter.start <= semester.semestercode
@@ -122,7 +122,7 @@ export const SemesterRangeSelector = () => {
setSemesterFilter({ ...semesterFilter, end: semester })
}}
/>
)}
) : null}
</div>
</div>
)

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