Compare commits

...
938 Commits
Author SHA1 Message Date
Santeri Rajamäki 81da962991 [Ooditable] Enable usage of maxSize attribute in columns 2025-08-21 15:44:51 +03:00
Santeri Rajamäki d3d2c2aaf2 Rework StudentInfoItem 2025-08-21 15:21:38 +03:00
Santeri Rajamäki 8fc2019358 [Ooditable] Simplify cell function 2025-08-21 15:08:15 +03:00
Santeri Rajamäki 7f253257b1 [Filters] Use defined key for graduatedFromProgramme default value 2025-08-21 14:48:27 +03:00
Santeri Rajamäki 43c866fa74 [Filters] Tags and study track use the same alert notification when empty 2025-08-21 14:44:31 +03:00
Santeri Rajamäki a5c842222f [Filters] Add default values to options list for rest of the FilterRadio users 2025-08-21 14:41:28 +03:00
Santeri Rajamäki 2e948c802c [Filters] Add possibility of controlled value to FilterRadio
Fix Transferred to programme filter state
2025-08-21 14:34:48 +03:00
Santeri Rajamäki da64f63d48 [Filters] Add disabled tooltip to FilterSelect 2025-08-21 13:20:19 +03:00
Santeri Rajamäki 800012ef05 [GeneralTab] Add columns to excel columns 2025-08-21 12:47:43 +03:00
Santeri Rajamäki b29d478c86 [Ooditable] Use header names instead of id to match columns 2025-08-20 13:39:26 +03:00
tfhuhtal d4506fe70f fix: add jami to db list 2025-08-19 17:39:22 +03:00
tfhuhtal 80159e46f0 feat: use selection only when choosing one dump to reset 2025-08-19 13:39:20 +03:00
tfhuhtal 3a834eb5f4 feat: update setup scripts to use s3 2025-08-19 11:59:24 +03:00
Santeri Rajamäki e56e553f25 Fix study track overflow 2025-08-18 18:13:30 +03:00
Santeri Rajamäki d6e32d2b19 [Filters] programmes move component definitions out of the filter card 2025-08-18 18:06:25 +03:00
Santeri Rajamäki 1413745a3a Fix react render warnings from filter components 2025-08-18 17:53:58 +03:00
Santeri Rajamäki e174dc5403 [E2E][Filters] Fix studyTrack dropdown type 2025-08-18 17:44:40 +03:00
Santeri Rajamäki 45cd8d82d9 Refactor dayjs imports to abide by x-import-modules rules 2025-08-18 17:30:06 +03:00
Santeri Rajamäki a10e3dbaf8 [Filters] Add multiple attribute to studyTracks filter
Suppress MUI warning about invalid empty strings when only empty string is available...
2025-08-18 17:15:53 +03:00
Santeri Rajamäki afa652db30 Upgrade stylelint-config-standard to version 39
Closes #4849
2025-08-18 16:35:12 +03:00
Santeri Rajamäki a1165c76dc Upgrade vite plugin-react-swc to version 4
Closes #4865
2025-08-18 16:32:52 +03:00
Santeri Rajamäki affea21c5a Upgrade download-artifact to action version 5
Closes #4853
2025-08-18 16:30:13 +03:00
santeri0200andGitHub 3e68dcd494 Merge pull request #4864 from UniversityOfHelsinkiCS/renovate/actions-checkout-5.x
Update actions/checkout action to v5
2025-08-18 16:18:44 +03:00
Santeri Rajamäki eb6837a079 [ModuleTab] Mankel some export data 2025-08-18 16:16:58 +03:00
renovate[bot]andGitHub a29e01b6d9 Update actions/checkout action to v5 2025-08-18 13:01:07 +00:00
Santeri Rajamäki 6916824d26 Upgrade sentry to version 10 2025-08-18 16:00:01 +03:00
Santeri Rajamäki 21127a7d6c [ModuleTab] Add value type (again) 2025-08-18 15:49:28 +03:00
Santeri Rajamäki af9f7c06e6 Fix lint 2025-08-18 15:47:56 +03:00
Santeri Rajamäki f882a22263 [ModuleTab] Replace MRT with Ooditable 2025-08-18 15:43:35 +03:00
Santeri Rajamäki 89ae035cf3 [Ooditable] Add VerticalHeader feature 2025-08-18 15:43:35 +03:00
joonas-a 1767790adb [Filters] Revisit Admission type 2025-08-18 14:34:23 +03:00
joonas-a a46ff9b570 [Population search form] reverse order of years in picker 2025-08-18 12:41:37 +03:00
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 7531a9def4 [Frontend] Fix curriculumPeriod filter 2025-06-17 10:16:47 +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 f5b81d73ac [Frontend] Fix CourseStatistics resetState on empty URL params 2025-06-13 17:08:07 +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
Santeri Rajamäki 4544d1cdec [Frontend] Small optimizations to course filter 2025-06-12 22:23:02 +03:00
Santeri Rajamäki 072360763f Update cypress a bit more 2025-06-12 19:44:10 +03:00
Santeri Rajamäki 639858f874 Update cypress to the new reality 2025-06-12 19:27:11 +03:00
Santeri Rajamäki 92f3e67293 [Frontend] Make filters render faster
Huge performance gains!?
2025-06-12 18:26:39 +03:00
Santeri Rajamäki 983390040e [Frontend] Massively improve render times for some filter components
This tweak improves the render time of FilterTray by 5 fold.
2025-06-12 17:05:40 +03:00
Santeri Rajamäki 277b97327e Update CI serve version to 14.x
This hasn't been updated and was hardcoded to 12.0.0.
The is no comment on why it *shouldn't* be updated.
2025-06-12 15:16:47 +03:00
Santeri Rajamäki 3dab357396 Fix skill issue 2025-06-12 15:15:25 +03:00
Santeri Rajamäki a39648f380 [Frontend] Offload cheap calculatoin to child components
This avoids some prop drilling and has positive performance impact at least in development.
2025-06-12 15:03:16 +03:00
Santeri Rajamäki ccb1865853 Update sentry to version 9.28
Closes #4830
2025-06-11 14:00:01 +03:00
Santeri Rajamäki 81da089798 Update scheduler to version 0.26
Closes #4725
2025-06-11 13:56:41 +03:00
Santeri Rajamäki 785b7d5d74 Update minor version of dependencies
Closes #4762
Closes #4783
2025-06-10 17:51:07 +03:00
Santeri Rajamäki aca70d5442 Revert optimistic eslint fixes 2025-06-10 15:29:47 +03:00
Santeri Rajamäki 41d93c9b43 Fix lint errors triggered by linter updates 2025-06-10 14:33:03 +03:00
Santeri Rajamäki 835c9e580b Update lint tools 2025-06-09 16:38:39 +03:00
Santeri Rajamäki 9c9f317ecb Update eslint-import-resolver-typescript to version 4.4
Cloess #4769
2025-06-09 16:19:08 +03:00
Santeri Rajamäki 3ee6758c72 Update eslint-plugin-cypress to version 5.x
Closes #4826
2025-06-09 16:16:49 +03:00
Santeri Rajamäki 88764f83de Update react-router to version 7.5
Closes #4794
2025-06-09 14:54:57 +03:00
Santeri Rajamäki e8908d28fc Update axios to version 1.9
Closes #4791
2025-06-09 14:54:02 +03:00
Santeri Rajamäki a6d7da315f Update sentry monorepo to versoin 9.x
Closes #4736
2025-06-09 14:45:04 +03:00
santeri0200andGitHub e72e0bb5ad Merge pull request #4821 from UniversityOfHelsinkiCS/filtering
[Frontend] Filtering cleanup
2025-06-09 13:43:22 +03:00
Santeri Rajamäki 15b0286eda [Frontend] Force failure-state instead of crash with faulty courses 2025-06-09 13:32:55 +03:00
Santeri Rajamäki 1104483cac [Frontend] Simplified filter action and selector structure 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 604bcf4998 [Frontend] Remove actions and selectors from filters
Actions and selectors are still attached to the filter factories
2025-06-09 13:32:42 +03:00
Santeri Rajamäki cc839a9e14 [Frontend] Removed extra parameter from filter calls 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 2a076ef6e8 [Frontend] Täsäfy rest of the FilterView 2025-06-09 13:32:42 +03:00
Santeri Rajamäki b05e1c22e5 [Frontend] Minor improvments to filters and filter context 2025-06-09 13:32:26 +03:00
Santeri Rajamäki b9800c857f [Frontend] Converted all filter components to render functions
This should be non-breaking as all the components were functions anyways
2025-06-09 13:31:33 +03:00
Santeri Rajamäki 6d535bf4bb [Frontend] Täsäfy filters
Added FilterTrayProps to FilterTray.tsx
Fixed Filter context typings
Reworked createFilter types
Also reword createFilter to work a little better
2025-06-09 13:30:55 +03:00
joonas-a 714f85d5c0 [Updater-worker] fix winston-sentry 2025-06-07 01:38:04 +03:00
joonas-a 01af4cfd2c [Updater-worker] ES6 2025-06-07 01:02:11 +03:00
Santeri Rajamäki fe55b770e9 [Updater] Update umzug to v3
Closes #4610
2025-06-06 15:25:17 +03:00
Santeri Rajamäki 43e4e605d6 [Cypress] Make OpenUni test use pathname and params instead of hardcoded path 2025-06-06 15:09:52 +03:00
Santeri Rajamäki 15a54f1e26 [Frontend] Replace query-string dependency with two typed utility functions 2025-06-06 14:35:00 +03:00
Santeri Rajamäki 631b5be00a Update shared model definition for SISStudyRight model 2025-06-06 12:01:53 +03:00
joonas-a b8d9648b8c [Student statistics] Infer studyright state from validity period if applicable 2025-06-06 01:22:30 +03:00
joonas-a d52562330b [Backend] Add studyright expiration urns to db model 2025-06-06 00:18:51 +03:00
joonas-a f4c9137a05 [Updater] Add study right expiration rule urns 2025-06-05 23:48:46 +03:00
santeri0200andGitHub 9a1802b6b4 [Frontend] Populus optimus (#4824)
* [Frontend] Täsäfy PopulationStatistics

* [Frontend] Reorganized PopulationStatistics

* [Redux] Removed dangling reducer actions left back in 51e5846 and further in history
This commit is 4 years due...

* Convert to RTKQuery for populationStatistics

* Removed useSelector for state.population

* Reset PopulationsStatistics params based on the new query param formatting

* Format semesters to array instead of spreading strings

* Force array formating with single key values

* Stop lying to the users
Progressbar is no more
2025-06-05 16:04:40 +03:00
Santeri Rajamäki fd2d95a832 [Frontend] Format RTKApi query params in Express friendly manner 2025-06-04 19:27:10 +03:00
Joonas Ahovalli dfb557d4e1 hotfix: undefined studyright.... 2025-05-30 16:37:11 +03:00
Joonas Ahovalli 22e2ccf562 [MRT] Adjust default col width + minor changes
Have admissiontype be either value or null, no boolean
Hide email and phone from table, show only in excel
2025-05-30 15:52:57 +03:00
Joonas Ahovalli 68480a7211 [General tab] Add new column for TVEX bitti 2025-05-30 15:25:13 +03:00
Joonas Ahovalli bb4edef070 [Backend] Fetch tvex info along with studyrights 2025-05-30 15:20:04 +03:00
Joonas Ahovalli 6877716468 [Updater] Add tvex info to sis_study_rights 2025-05-30 13:18:47 +03:00
Santeri Rajamäki a21e91fbaa [Updater] added loki 2025-05-30 12:03:36 +03:00
Santeri Rajamäki 62e7de5fef [Backend] added loki 2025-05-28 13:05:33 +03:00
santeri0200andGitHub 785b84b14c Merge pull request #4820 from UniversityOfHelsinkiCS/OOD-89
Edited shared mounting in fd compose
2025-05-28 11:00:06 +03:00
Matti Räsänen e488327fd4 Edited shared mounting in fd compose 2025-05-26 15:09:25 +03:00
Santeri Rajamäki 2642e15479 Fk go back 2025-05-21 19:00:01 +03:00
Santeri Rajamäki 547d1c7d35 [Frontend] Prepare PopulationStatistics for changes 2025-05-21 18:36:38 +03:00
Santeri Rajamäki d57ecb2aa9 [Frontend] MUI the right way
According to MUI minimizing bundle size. Closes #4816
2025-05-21 18:19:04 +03:00
Santeri Rajamäki ef76b0f350 [Frontend] Preliminary FilterView täsäfication
This commit includes the most rudimentary implementation of TS types for the view.
We need more types from backend to advance these files.
2025-05-21 16:08:25 +03:00
Joonas Ahovalli 3ba2bd8d5b [Custom population] indicate if graduated or inactive primary study programme 2025-05-21 13:18:35 +03:00
Santeri Rajamäki 12976a961b [Frontend] Fix deepCopy issue 2025-05-19 22:59:54 +03:00
Santeri Rajamäki 01c6159ffa [Frontend] Make FilterView great again 2025-05-19 22:32:13 +03:00
Santeri Rajamäki a6c1e3891d [Frontend] Remove unused directive to make lint happy again 2025-05-19 22:28:19 +03:00
Santeri Rajamäki 6c4b42c98d [Shared] Add keyBy function to utils 2025-05-19 22:13:34 +03:00
Joonas Ahovalli 10941259a2 [Redux] Implement hooks useAppDispatch and useAppSelector
Allows for more streamlined typing
2025-05-19 22:08:44 +03:00
Joonas Ahovalli b7563cd4ec [Modules tab] Improve typing 2025-05-19 17:11:58 +03:00
Joonas Ahovalli 8d261aae51 Fix typo 2025-05-19 17:10:03 +03:00
Joonas Ahovalli ccf70ed420 [General tab] improve typing 2025-05-19 16:23:36 +03:00
Joonas Ahovalli 20f4bd32f8 [General tab] Fix tags broken 2025-05-16 11:45:55 +03:00
Joonas Ahovalli d62140e97d Add scripts to clean install local/docker/both 2025-05-16 11:43:55 +03:00
Joonas AhovalliandGitHub 0e510f039f Material UI: General tab (#4797)
* [General tab] Create skeleton with MUI

* [General tab] Add logic for columns based on view

* [General tab] Add semesters present column

* [General tab] Fix email column missing data

* [General tab] Fix credit columns

* [Utils] rename and use new date formatter fn

* [Utils] Add improved dateformatter

* [General tab] Fix admin columns and date formatting

* [Utils] Use local time instead of UTC to mimic moment behaviour

* [General tab] Other programmes column visual upgrade

* [General tab] Add study track column

* [General tab] add studyguidancegroups support

* [General tab] Fix credits since x column text with studyguidancegroups

* [General tab] Ditch redundant query result

* [General tab] Fix option col to consider if viewing bsc/msc programme

* [General tab] Fix undefined sisu id

* [General tab] custom population implementation

* [General tab] show more cols in custom population if programme chosen

* [General tab] Hide studytrack if no one has any

* [General tab] fix edge case when no creditssince filter is set

* [General tab] Show more fields on custom population with no associated programme

* [General tab] infobox betternment for custom pop

* [General tab] Course population implementation

* [General tab] Add tooltips and tweak custom pop primary programme col

* [General tab] Add admissiontype to custom pop, add phone number col

* [General tab] Make it work with combined programmes

* [General tab] Cleanup and small qol

* Chore: delete unused components

* [Cypress] fix population statistics test

* [Cypress] fix studyguidancegroup tests

* [General tab] Make tests happy
2025-05-15 13:45:21 +03:00
santeri0200andGitHub 893d3847e2 [Backend] Rework backend models (#4805)
Models are now typed in `shared` and build in `backend` making it possible to use the model types in `frontend` or `shared` definitions.
2025-05-13 14:01:53 +03:00
Santeri Rajamäki 0c46fcdd09 [Backend] Update route typings for courseExclusion 2025-05-10 14:41:18 +03:00
Santeri Rajamäki 4397a542c2 [Backend] Update route typings for completedCoursesSearch 2025-05-10 14:21:14 +03:00
Santeri Rajamäki 25a4975265 [Backend] Updated route typings for changelog 2025-05-10 13:09:49 +03:00
Santeri Rajamäki b89967fb17 [Backend] Updated route typings for tags 2025-05-10 11:29:45 +03:00
Santeri Rajamäki 36ce4f1405 [Backend] Moved CanError to shared 2025-05-10 11:02:05 +03:00
Santeri Rajamäki 71d5849360 Add shared install to "npm run install:local" 2025-05-09 19:32:56 +03:00
Santeri Rajamäki 6f335a3555 Reword .github/actions lint to be ran on all packages instead of named ones 2025-05-09 19:19:58 +03:00
Santeri Rajamäki e7eab4741e [Cleanup] apply app-root changes to files not caught by tests 2025-05-09 19:14:22 +03:00
Santeri Rajamäki 64a67151d6 Avoid using "cd" in install scripts, use "--prefix" instead 2025-05-09 18:41:31 +03:00
santeri0200andGitHub 229d155b7c Promote shared to a package (#4799)
* Promote shared to a package

* Add build script to shared

* Rework env values in Dockerfile

* Rework Dockerfiles

* Remove symlinks from the project
2025-05-09 18:31:33 +03:00
Santeri Rajamäki 42ef2b0245 [Config] Setup stricter isolation between frontend and backend tsconfigs 2025-04-30 02:10:41 +03:00
Santeri Rajamäki 79bfb690b7 [Backend] Further declutter formatStatisticsForApi.ts 2025-04-28 18:01:11 +03:00
Santeri Rajamäki 1b8b71075a [Backend] Add omitKey'd types to optimizedStatisticsOf and formatStudentForAPI 2025-04-28 14:25:12 +03:00
Santeri Rajamäki a77bba0881 [Shared] Added omitKeys shared utility function 2025-04-28 14:25:12 +03:00
Santeri Rajamäki eda8dcac97 [Backend] Optimized *ByStudent iterators 2025-04-28 14:25:12 +03:00
Santeri Rajamäki b0e0bae727 [Backend] renamed getStudentNumbersWithAllStudyRightElements to getStudentNumbersWithStudyRights 2025-04-28 14:25:12 +03:00
Santeri Rajamäki 47d5faec81 [Backend] Displace formatStudentsForApi 2025-04-28 14:25:07 +03:00
Santeri Rajamäki 8f90824d8b [Backend] moved getStudentTags back to getStudentData 2025-04-28 13:58:59 +03:00
Santeri Rajamäki e16f7ddf85 [Backend] removed getStudentsIncludeCoursesBetween function 2025-04-28 13:58:59 +03:00
Santeri Rajamäki 0c02d25257 [Backend] Moved getStudentsIncludeCoursesBetween to getStudentData 2025-04-28 13:58:59 +03:00
Santeri Rajamäki 68484728d2 [Backend] Move around tag logic 2025-04-28 13:58:59 +03:00
joonas-a 1d3fbdf869 Revert change to workflow 2025-04-17 11:10:54 +03:00
Santeri Rajamäki c22b415b85 [Backend] Refactor studentNumbersWithAllElements 2025-04-15 18:46:06 +03:00
Santeri Rajamäki 920c1aac5f [Backend] Move population tag filtering to the query 2025-04-15 17:53:07 +03:00
Joonas Ahovalli 31787e6df0 [Updater] Add default for REDIS_HOST 2025-04-15 16:14:56 +03:00
Joonas Ahovalli 82a4ea9465 [Updater] Try to fix image 2025-04-15 15:41:20 +03:00
santeri0200andGitHub bb7f965299 Merge pull request #4777 from UniversityOfHelsinkiCS/OOD-84-updater-failing-on-programme-module-children
OOD-84: using try catch logic when inserting programme module children [UPDATER-WORKER]
2025-04-15 14:14:09 +03:00
Jan Rundt ff5fac8f0d OOD-84: modern things such as ternary 2025-04-15 13:35:22 +03:00
Santeri Rajamäki cede895135 [Backend] refactor completedCoursesSearch to use a single tryCatch wrapper 2025-04-14 22:09:56 +03:00
Santeri Rajamäki 0ebff9dd39 [Backend] Ignore importerClient call if importerClient is null 2025-04-14 22:00:55 +03:00
Santeri Rajamäki ce94943d54 [Backend] Refactor completedCoursesSearch routes 2025-04-14 17:31:57 +03:00
Santeri Rajamäki 98f388a8a3 [Backend] Refactor completedCoursesSearch route types 2025-04-14 17:19:58 +03:00
Joonas Ahovalli 9b366e2974 [Student matrix] Display latest active study programme over newer inactive one 2025-04-14 16:19:20 +03:00
Joonas Ahovalli 452fb93b2c [Backend] Early return to avoid useless db query 2025-04-14 16:15:27 +03:00
santeri0200andGitHub b9cd6aab7f Merge pull request #4778 from UniversityOfHelsinkiCS/OOD-84-getting-latest-active-organisation
OOD-84: getting latest active organisation [UPDATER-WORKER]
2025-04-14 16:08:58 +03:00
Joonas Ahovalli 42e76e25e6 [Updater] Remove unused code 2025-04-08 15:42:51 +03:00
Joonas Ahovalli 5ae4f80fab [Updater] Fix includedModules of some students
If a student has graduated and moved to a new programme, the study
modules in their previous hops were overwritten by those in the new
programme #4787
2025-04-08 15:34:54 +03:00
Joonas Ahovalli d441f3a6b3 [Updater] Fix all study modules getting labeled as unknown 2025-04-04 17:12:54 +03:00
Joonas Ahovalli d22e746e02 [Updater] Rename module to studyModule to avoid shadowing 2025-04-03 14:03:27 +03:00
Joonas Ahovalli 5b5292d53a [Modules tab] Unify font size 2025-04-03 13:45:53 +03:00
Joonas Ahovalli 12f90192c6 [Modules tab] Make summary row visually consistent if showing student names 2025-04-02 12:35:27 +03:00
Joonas Ahovalli 94d38fecc1 [Class statistics] Add summary rows to modules tab 2025-04-01 16:04:08 +03:00
Santeri Rajamäki 6a649b5f00 [Backend] Refactor customOpenUniSearch route 2025-03-24 22:18:30 +02:00
Jan Rundt ab391472a6 OOD-84: parameter for findBy property 2025-03-24 15:20:18 +02:00
Santeri Rajamäki 267067e83a [Backend] Introduced CanError type for routes 2025-03-24 12:26:42 +02:00
Santeri Rajamäki 17f376a681 [Backend] Refactor users route to use tryCatch 2025-03-24 11:38:47 +02:00
Santeri Rajamäki 9d83a06377 [Backend] Refactor safeJSONParse to use tryCatch 2025-03-24 11:35:10 +02:00
Santeri Rajamäki 0f5c2e3160 [Shared] Introduce error as value tryCatch function 2025-03-24 11:13:43 +02:00
Jan Rundt 364ba0bca3 OOD-84: fixing divergence from master 2025-03-24 10:38:03 +02:00
Jan Rundt 9d5f2a51e3 OOD-84: fixing divergence from master 2025-03-24 10:34:11 +02:00
Jan Rundt d9260c17f8 OOD-84: get latest *active* organisation 2025-03-24 09:07:50 +02:00
Jan Rundt 612cb08689 merge master 2025-03-21 16:21:06 +02:00
Jan Rundt ae90dfa006 OOD-84: using try catch logic when inserting programme module children 2025-03-21 15:11:39 +02:00
Santeri Rajamäki a4635d8d5b [Backend] Refactor getOptionsForStudents 2025-03-21 11:43:42 +02:00
Santeri Rajamäki 2a48dc5a77 [Backend] Move parseQueryParams out of shared 2025-03-21 11:32:19 +02:00
Santeri Rajamäki 778807ecd0 [Backend] Redefine populationstatistics studyRights 2025-03-20 16:22:37 +02:00
Santeri Rajamäki eca6b643bd [Backend] Refactored criteria and options of formatStatisticsForApi 2025-03-20 15:44:39 +02:00
Santeri Rajamäki cde59177ed [Chore] Use corresponding tye for MedianTimeBarChart data 2025-03-20 14:02:22 +02:00
Santeri Rajamäki c229e4f030 [Refactor] Seed graduationStatsResponse to a root type 2025-03-20 12:57:24 +02:00
Riku Rauhala cff0cbef04 [PopulationStudents] Fix an awkward typo 2025-03-20 12:37:50 +02:00
Riku RauhalaandGitHub c0a4e9d8e5 Merge pull request #4774 from UniversityOfHelsinkiCS/OOD-88-only-disable-role-alternatives-for-hy
OOD-88 & OOD-87: disable role alternatives only for HY & avoid stack exceeded in updater
2025-03-20 11:48:59 +02:00
Santeri Rajamäki 3cd2bcefea [Backend] Move formatStudentsForApi into a self-contained file 2025-03-19 22:37:59 +02:00
Riku RauhalaandGitHub 4b40011f6b Merge pull request #4773 from UniversityOfHelsinkiCS/material-ui-study-programme-programme-courses-by-credit-type
Material UI: Study programme overview - Programme courses tab - By credit type
2025-03-19 20:18:00 +02:00
Riku Rauhala b8c147ceec [Study programme] Update tests 2025-03-19 19:50:24 +02:00
Santeri Rajamäki 5d169b3321 [Backend] Added rest of the types for getStudentsIncludeCoursesBetween 2025-03-19 19:01:24 +02:00
Jan Rundt 1b86aada0e OOD-87: avoiding call stack exceeded by avoiding loop in data 2025-03-19 16:56:14 +02:00
Jan Rundt f3b027a609 OOD-88: lint fix 2025-03-19 14:51:27 +02:00
Riku Rauhala 3797fed1a9 [Study programme] Extract BySemesterTab 2025-03-19 14:40:16 +02:00
Jan Rundt ec3f36a9e4 OOD-88: check for default service provider before disabling role alternatives 2025-03-19 14:40:02 +02:00
Riku Rauhala ed7677d126 [Study programme] Improve API typing 2025-03-19 14:33:05 +02:00
Riku Rauhala bcbeeaceb5 [Study programme] Refactor columns 2025-03-19 14:33:05 +02:00
Riku Rauhala f80200d481 [Study programme] Rewrite OverallStatsTable with MRT 2025-03-19 14:33:05 +02:00
Riku Rauhala e64e0d04fd [Study programme] Extract component OverallStatsTable
Let's rewrite this separately
2025-03-19 14:33:05 +02:00
Riku Rauhala 85432092fa [Study programme] Rename OverallStatsTable to ByCreditTab
This component contains everything on the tab, the table is only one part of it
2025-03-19 14:33:05 +02:00
Riku Rauhala b6a39c829e [Study programme] Rewrite CourseYearFilter with MUI 2025-03-19 14:33:05 +02:00
Riku Rauhala 0a6edf6638 [Study programme] Delete component Toggle as unused 2025-03-19 14:33:05 +02:00
Riku Rauhala 2d44506c55 [Study programme] Type API request for programme course stats 2025-03-19 14:33:05 +02:00
Riku Rauhala d5dc511b39 [Study programme] Use MUI tabs for 'By credit type' / 'By semester' 2025-03-19 14:33:05 +02:00
Riku Rauhala 1e9c2857c5 [Hooks] Allow an optional prefix for tabs
Some views may contain multiple tabs, in that case the different tabs have to be identified
2025-03-19 14:33:05 +02:00
Riku Rauhala 58190f8757 [Study programme] Convert ProgrammeCoursesTab into TypeScript 2025-03-19 14:33:05 +02:00
Santeri Rajamäki caa715677b [Backend] Add acual typing to getStudents function of getStudentsIncludeCoursesBetween 2025-03-19 13:45:51 +02:00
Santeri Rajamäki 94c34a10eb [Backend] Refactor optimizedStatisticsOf chain 2025-03-19 12:21:52 +02:00
Joonas Ahovalli f86c90ff8c [Class statistics] Add completed study modules to modules tab 2025-03-17 16:26:23 +02:00
Riku Rauhala 617379e63f [GitHub Actions] Fix study programme overview name 2025-03-17 12:31:29 +02:00
Riku RauhalaandGitHub cb1b44d4a3 Merge pull request #4770 from UniversityOfHelsinkiCS/material-ui-study-programme-degree-courses
Material UI: Study programme overview - Degree courses tab
2025-03-17 12:17:39 +02:00
Riku Rauhala 2ae9d662dd [Study programme] Update tests 2025-03-17 12:07:34 +02:00
Riku Rauhala cd73900749 [Study programme] Return only status code for some requests
This data is not used in the frontend so there is no need to return it
2025-03-17 10:44:08 +02:00
Riku Rauhala 60a03b8643 [Study programme] Implement criterion label selection menu 2025-03-17 10:26:27 +02:00
Riku Rauhala 35ffc21b91 [Study programme] Rewrite visibility toggle with MUI 2025-03-17 10:26:26 +02:00
Riku Rauhala e2886c9e35 [Study programme] Create component VisibilityChip 2025-03-17 10:26:26 +02:00
Riku Rauhala c50d365dcb [Course exclusions] Type API
The modified data is not used in the frontend so we can simply return a status code
2025-03-17 10:26:26 +02:00
Riku Rauhala 6843b587cd [StudyProgramme] Extract util isMedicalProgramme 2025-03-17 10:26:26 +02:00
Riku Rauhala d1d4e3105e [Study programme] Extract util isBachelorOrLicentiateProgramme 2025-03-17 10:26:26 +02:00
Riku Rauhala 0fe018bb96 [Types] Rename Curriculum to CurriculumOption
This type is only used for a less detailed version of the Curriculum object to display available options
2025-03-17 10:26:26 +02:00
Riku Rauhala d2685b13d7 [Curriculum] Format for frontend 2025-03-17 10:26:26 +02:00
Riku Rauhala ec2bdcc8ea [Study programme] Improve curriculum typing 2025-03-17 10:26:26 +02:00
Riku Rauhala 37518f554a [Study programme] Rename DegreeCoursesTable 2025-03-17 10:26:26 +02:00
Riku Rauhala 5b33803d9f [InfoBox] Add tooltips for credit criteria 2025-03-17 10:26:26 +02:00
Riku Rauhala 5dd534e4d9 [Redux] Move curriculum to separate file 2025-03-17 10:26:26 +02:00
Riku Rauhala 589bcb6128 [Study programme] Rewrite Degree courses tab Credit criteria section with MUI 2025-03-17 10:26:26 +02:00
Riku Rauhala a5af6ebd47 [StatusNotification] Increase autoHideDuration and move to top right 2025-03-17 10:26:26 +02:00
Riku Rauhala 08b3d29b91 [CurriculumPicker] Extract component 2025-03-17 10:26:26 +02:00
Riku Rauhala 8ee1cac66f [Criteria] Extract shared type ProgressCriteria 2025-03-17 10:26:26 +02:00
Riku Rauhala 32a9cdbea6 [Redux] Convert progressCriteria into TypeScript 2025-03-17 10:18:35 +02:00
Riku Rauhala 372dbd4769 [Study programme] Convert DegreeCoursesTab into TypeScript 2025-03-17 10:18:35 +02:00
Santeri Rajamäki 49089975db [Backend] Remove moment from populations and related files 2025-03-14 16:47:42 +02:00
Santeri Rajamäki e4ff103cb1 [Backend] Remove traces of moment from getStudentsIncludeCoursesBetween 2025-03-14 16:47:42 +02:00
Joonas Ahovalli ef683f8fe1 [MRT Options] Switch to using shorthands for colors
This might help when spreading the config to override some settings
later
2025-03-14 15:19:46 +02:00
Joonas Ahovalli 6e3da7e1af [Class statistics] Add vertical columns to modules tab 2025-03-14 15:05:23 +02:00
Santeri Rajamäki c9fe222dd3 [Backend] Fix LSP crash in getStudentsIncludeCoursesBetween 2025-03-14 14:15:57 +02:00
Santeri Rajamäki 084be9df1b [Backend] Removed unused tag query from populationstatistics 2025-03-13 18:07:33 +02:00
Santeri Rajamäki 0b86f82eb7 [Backend] Making code climate less unhappy with populations/shared 2025-03-13 17:19:00 +02:00
Santeri Rajamäki 993d5e842e [Class statistics] Consolidated parts of value/type checking 2025-03-13 16:27:35 +02:00
Santeri Rajamäki 6ab7b9bbc4 [Class statistics] Moved error return values from "*statisticsOf" to the caller 2025-03-13 16:25:07 +02:00
Riku RauhalaandGitHub 0ccb1e79c8 Merge pull request #4768 from UniversityOfHelsinkiCS/study-programme-year-selector
[Study programme overview] Add an option to select a subset of years
2025-03-12 22:03:24 +02:00
Riku Rauhala 24ade5b222 [Study programme] Add placeholder for testing the year selector 2025-03-12 21:50:12 +02:00
Riku Rauhala ae9fbf182c [Study programme] Handle study tracks for the new year selector 2025-03-12 21:48:48 +02:00
Riku Rauhala 4be53e7707 [PopulationLink] Create a new button variant instead 2025-03-12 21:40:59 +02:00
Riku Rauhala c980560f53 [Study programme] Limit year selector visibility 2025-03-12 21:32:26 +02:00
Riku Rauhala b1928a7843 [Study programme] Implement a custom year selector 2025-03-12 21:26:46 +02:00
Riku Rauhala 2e60df5804 [PopulationLink] Move functionality to util 2025-03-12 21:12:41 +02:00
Riku Rauhala 2ccd526805 [Study programme] Update tests 2025-03-12 19:16:40 +02:00
Riku Rauhala 92b6b3cb60 [Study programme] Improve styling 2025-03-12 18:49:43 +02:00
Riku Rauhala fbc842cac5 [Study programme] Extract access checking for population links 2025-03-12 18:37:35 +02:00
Riku RauhalaandGitHub cb334e2865 Merge pull request #4767 from UniversityOfHelsinkiCS/refactor-population-link
[PopulationLink] Refactor to eliminate copy-paste
2025-03-12 17:29:40 +02:00
Riku Rauhala 24bd750763 [PopulationLink] Refactor to eliminate copy-paste 2025-03-12 16:16:59 +02:00
Riku Rauhala eb07173f36 [Study programme] Test info boxes 2025-03-12 15:25:05 +02:00
Riku RauhalaandGitHub 815d85d8e2 Merge pull request #4763 from UniversityOfHelsinkiCS/material-ui-study-programme-study-tracks
Material UI: Study programme - Study tracks and class statistics tab
2025-03-12 15:06:12 +02:00
Riku Rauhala a4a3c0f2ca [Study programme] Update tests 2025-03-12 14:41:08 +02:00
Santeri Rajamäki 20c3632972 Fix missing/shadowed variable in populations 2025-03-11 20:02:44 +02:00
santeri0200andGitHub 7af6374e7b Populus optimus (#4766)
- Reworked `populationstatistics/courses`
  - `/coursesbytag` and `coursesbystudentnumberlist` now use the same API
    - Updated frontend to use `/courses` instead
  - Updated cypress to call the new `v4/populationstatistics/courses` instead of `v2`
- Added types to request queries, request- and response bodies for multiple `populationstatistics/` routes
- Simplified parts of `optimizedStatisticsOf` call and its callers
- Cleaned up `bottlenecksOf`
- Made `studentNumbersWithAllElements` more verbose
- Simplified `CourseStatsCounter`
  - Moved functions that were only used for course statistics from `utils` to `courseStatsCounter`
- Moved `populationCourseStatMerger` into the only function it is ever called from
- Made `hasFullAccessToStudentData` explicit boolean return value
- Added `safeJSONParse` function to server utils to stop server from throwing (still needs to be implemented more widely)
- Added `formatToArray` and made `Array.isArray?val:[val]` disappear
- Made `CurriculumPicker` easier to read and more type safe
- Forced `formattedQueryParams` to be passed into `fetchPopulations` to avoid duplicate processing
2025-03-11 17:52:59 +02:00
Riku Rauhala 9accbc299a [GraduationTimes] Remove unused component 2025-03-11 13:53:39 +02:00
Riku Rauhala 7a03455c8e [Study programme] Include programme name in page title 2025-03-11 13:46:13 +02:00
Riku Rauhala a72c5eeaea [Study programme] Update loading/error status 2025-03-11 13:46:13 +02:00
Riku Rauhala 1f2d381039 [StudyTrackSelector] Disable selector if there are no study tracks 2025-03-11 13:46:13 +02:00
Riku Rauhala a31ef9af07 [Study programme] Improve typing 2025-03-11 13:46:13 +02:00
Riku Rauhala fcd855a3e2 [Shared] Extract type GraduationStatistics 2025-03-11 13:46:13 +02:00
Riku Rauhala 6a31bcf4d7 [Study programme] Rewrite average graduation times seciton with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala d3de30bdec [GraduationTimes] Move to components/material for reuse in Study programme 2025-03-11 13:46:13 +02:00
Riku Rauhala 1ee4fb8bcf [Study programme] Rewrite progress section with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala 3b58c13f4a [Faculties] Make the ProgressBarChart reusable for study programmes 2025-03-11 13:46:13 +02:00
Riku Rauhala 9ca46021aa [Util] Extract function generateGradientColors 2025-03-11 13:46:13 +02:00
Riku Rauhala 87ade36e4b [DataTable] Update styling 2025-03-11 13:46:13 +02:00
Riku Rauhala 32a670a8d1 [Study programme] Rewrite StudyTrackDataTable with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala f2478fbe24 [InfoBox] Set mini variant color to info
The mini variant should have the same color
2025-03-11 13:46:13 +02:00
Riku Rauhala f81a5ee1a4 [Util] Extract getCalendarYears 2025-03-11 13:46:13 +02:00
Riku Rauhala bf780d09d7 [Backend] Extract types for StudyTrackStats 2025-03-11 13:46:13 +02:00
Riku Rauhala 3bbee5c0b3 [Study programme] Delete css 2025-03-11 13:46:12 +02:00
Riku Rauhala 880f5997c1 [Study programme] Rewrite StudyProgrammeSelector with MUI 2025-03-11 13:46:12 +02:00
Riku Rauhala 44e2bad801 [Study programme] Convert StudyTracksAndClassStatisticsTab into TypeScript 2025-03-11 13:46:12 +02:00
Valtteri KantanenandGitHub 18a5eadbcc Merge pull request #4757 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-03-10 16:30:26 +02:00
renovate[bot]andGitHub e871b77192 Update all non-major dependencies 2025-03-10 14:14:39 +00:00
Valtteri KantanenandGitHub 4772ca99c2 Merge pull request #4760 from UniversityOfHelsinkiCS/renovate/npm-axios-vulnerability
Update dependency axios to v1.8.2 [SECURITY]
2025-03-10 15:48:35 +02:00
Valtteri Kantanen 6f6b059d83 [Documentation] Change NATS to BullMQ in architecture graph 2025-03-10 15:04:30 +02:00
Valtteri Kantanen 2cc016e439 [Updater] Fix bug preventing insertion of attainments referencing non-existent modules into the db (#4761) 2025-03-10 13:39:08 +02:00
renovate[bot]andGitHub 7670dbd50f Update dependency axios to v1.8.2 [SECURITY] 2025-03-10 08:03:05 +00:00
Rajamäki Santeri J 86a3081a1c [Population statistics] Fixed return data encryption step for multi year populations 2025-03-09 13:33:23 +02:00
Santeri Rajamäki 01b39926b6 Converted "encrypt" utility to typescript and added "EncryptedData" type 2025-03-07 15:00:24 +02:00
Valtteri KantanenandGitHub 790e3d3afd Merge pull request #4743 from UniversityOfHelsinkiCS/renovate/cron-4.x
Update dependency cron to v4
2025-03-06 16:14:57 +02:00
renovate[bot]andGitHub b4fbe1b47d Update dependency cron to v4 2025-03-06 14:02:26 +00:00
Valtteri Kantanen 0d2701269a [Updater] Update hourly job to also run on Saturdays and Sundays
This will decrease the number of students to be updated on Monday
2025-03-06 14:09:38 +02:00
Valtteri Kantanen 8c6a634df0 [Updater] Set timezone to Europe/Helsinki in all Dockerfiles 2025-03-06 14:03:30 +02:00
Joonas Ahovalli a0117c0de1 [Class statistics] Make studentnumber column sticky in modules tab 2025-03-06 13:00:58 +02:00
Joonas Ahovalli 3e12a4df14 [Course statistics] Fix courses excel import not containing hops information 2025-03-06 12:03:15 +02:00
Valtteri KantanenandGitHub 5aaa343720 Merge pull request #4742 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-03-06 11:50:53 +02:00
renovate[bot]andGitHub f2f8d5d6d0 Update all non-major dependencies 2025-03-06 09:41:43 +00:00
Valtteri Kantanen ede3748c09 [SortableTable] Remove unnecessary eslint-disable comments for hooks
These will be unnecessary after the waiting dependency update is merged.
2025-03-06 11:38:21 +02:00
Valtteri KantanenandGitHub 6cc230512c Merge pull request #4744 from UniversityOfHelsinkiCS/renovate/globals-16.x
Update dependency globals to v16
2025-03-06 10:52:16 +02:00
renovate[bot]andGitHub 24e42c3b63 Update dependency globals to v16 2025-03-06 08:42:55 +00:00
Valtteri Kantanen c3e222c155 [Course statistics] Fix parsing of 'separate' query param to handle undefined values 2025-03-06 10:40:39 +02:00
Valtteri KantanenandGitHub 1fb81f4bbf Merge pull request #4753 from UniversityOfHelsinkiCS/renovate/getsentry-action-release-3.x
Update getsentry/action-release action to v3
2025-03-06 10:40:05 +02:00
Valtteri Kantanen e3e0a24b9a [Updater] Update scheduler's README to clarify update processes 2025-03-06 09:01:01 +02:00
Joonas Ahovalli 66bed7f88a [Backend] Improve class statistics load times for large populations 2025-03-04 16:09:14 +02:00
Riku Rauhala c59b0a3b58 [Study programme] Add keys to tabs to disable console warnings 2025-03-04 15:09:15 +02:00
Riku RauhalaandGitHub 37a5b17578 Merge pull request #4754 from UniversityOfHelsinkiCS/material-ui-study-programme-tags
Material UI: Study programme overview - Tags tab
2025-03-04 14:36:09 +02:00
Riku Rauhala 16d938a423 [Study programme] Update tests 2025-03-04 14:20:05 +02:00
Riku Rauhala 0132a98733 [Study programme] Update tests 2025-03-04 10:45:35 +02:00
Riku Rauhala d99f3b326f [Updater] Import tooltips from index 2025-03-03 21:34:55 +02:00
Riku Rauhala 61bc346923 [Tags] Fix one more thing 2025-03-03 21:27:19 +02:00
Riku Rauhala 358eac4de2 [Study programme] Add status messages for Tabs tag functionality 2025-03-03 21:27:18 +02:00
Riku Rauhala 863a7ac157 [Tags] Fix functionality 2025-03-03 21:27:18 +02:00
Riku Rauhala 0faef48179 [Study programme] Rewrite Tags tab with MUI 2025-03-03 21:27:18 +02:00
Riku Rauhala b29eb0c8e6 [Backend] Refactor tags routes 2025-03-03 21:27:18 +02:00
Riku Rauhala 2f8b38617b [Redux] Type tags API 2025-03-03 21:27:18 +02:00
Riku Rauhala 0489155da7 [InfoToolTips] Add study programme tags 2025-03-03 21:27:18 +02:00
Riku Rauhala 8bf1da6047 [InfoToolTips] Export updaterToolTips from index
For consistency with other exports
2025-03-03 21:27:18 +02:00
Riku Rauhala e0bb37a75a [Shared] Add types Tag and NewTag 2025-03-03 21:26:15 +02:00
Riku Rauhala 27d4962fcb [PopulationLink] Add option for a link with tags 2025-03-03 21:26:15 +02:00
Riku Rauhala 6ca346a00a [Resources] Add MUI X Date pickers link 2025-03-03 21:26:15 +02:00
Riku Rauhala d1bbd9966e [Frontend] Install MUI DatePickers 2025-03-03 21:26:15 +02:00
Riku Rauhala 25e21ecb14 [Redux] Convert tags into TypeScript 2025-03-03 21:26:15 +02:00
Riku Rauhala 72cdbcb871 [Study programme] Convert TagsTab into TypeScript 2025-03-03 21:26:15 +02:00
Valtteri Kantanen 1a9759f77f [Documentation] Update READMEs of updater 2025-03-03 16:58:20 +02:00
Joonas Ahovalli b1e65803d3 [Class statistics] Fix criteria modifying link pointing to wrong page on students->progress tab 2025-03-03 16:04:59 +02:00
Santeri Rajamäki e19697a9c0 [Study guidance groups] Fix inflated response sizes 2025-03-03 14:34:38 +02:00
renovate[bot]andGitHub a584581141 Update getsentry/action-release action to v3 2025-03-03 11:27:38 +00:00
Valtteri Kantanen b7a98483e4 [Updater] Include student in hourly updates if there are new enrollments and ignore study right primalities 2025-03-03 13:26:51 +02:00
Valtteri Kantanen 20c197f3a7 [Updater] Update job removal options in queue configuration to use age limits 2025-03-03 13:12:16 +02:00
Valtteri Kantanen d07ba795df [Updater] Log more info when a new job is started 2025-03-03 13:07:06 +02:00
Valtteri Kantanen b17b714be2 [Updater] Add missing models to tableToModel 2025-03-03 13:06:19 +02:00
Valtteri Kantanen 22a5e58fe3 [Updater] Remove unused env variables and define all env variables in the config file 2025-03-03 13:01:54 +02:00
Valtteri Kantanen 9a0fbd50bd [Updater] Remove postUpdate function and refactor updateMsgHandler to handle student updates directly 2025-03-03 12:53:47 +02:00
Valtteri Kantanen 51572ebeed Remove sis-updater-nats service from Docker Compose files 2025-03-03 12:05:40 +02:00
Valtteri Kantanen 139e253d6a [Updater] Uninstall node-nats-streaming 2025-03-03 12:04:26 +02:00
Valtteri Kantanen 0df918b47a [Updater] Remove NATS related code 2025-03-03 11:59:53 +02:00
Joonas Ahovalli 781bc5da3d [Class statistics] Only show include substitutions -toggle in courses tab under students 2025-03-01 18:50:40 +02:00
Joonas Ahovalli 67297e536c [Class statistics] Rewrite includeSubstitutionsToggle in MUI 2025-03-01 18:46:05 +02:00
Valtteri Kantanen dcdacc7187 [Updater] Remove unused dependency 2025-02-28 16:43:25 +02:00
Valtteri Kantanen 684a90c60c [Frontend] Make "Stop updating" section on Updater page clearer 2025-02-28 16:40:39 +02:00
Valtteri Kantanen 0c1a1e81cb [Backend] Return detailed response message from abortUpdate endpoint 2025-02-28 16:39:25 +02:00
Valtteri Kantanen 8c8eb34a05 [Frontend] Fix ExternalLink taking up full row by using inline-flex instead of flex 2025-02-28 16:37:39 +02:00
Valtteri Kantanen f1ebbeb0e9 [Updater] Fix error logging messages for prepurge and purge scheduling 2025-02-28 16:35:27 +02:00
Valtteri Kantanen c176552326 [Updater] Add sis_study_right_elements and sis_study_rights to purge tables 2025-02-28 16:34:21 +02:00
Valtteri Kantanen 7419cc169d [Updater] Fix sendToSlack call to await its completion 2025-02-28 16:33:51 +02:00
Valtteri Kantanen c21ffe9a74 [Updater] Implement reloading redis cache and removing waiting jobs with BullMQ 2025-02-28 15:59:53 +02:00
Valtteri Kantanen 2b15168342 [Updater] Implement updating meta and students with BullMQ 2025-02-28 15:03:22 +02:00
Valtteri Kantanen 3e296e33bd [Class statistics] Fix number of HOPS credits not showing in the Studens table 2025-02-28 13:21:41 +02:00
Valtteri Kantanen 37df5276f7 [Updater] Use REDIS_HOST env variable instead of REDIS_URI 2025-02-28 12:38:24 +02:00
Valtteri Kantanen 7a26501d72 [Updater] Use similar loggers in updater-scheduler and updater-worker 2025-02-28 10:47:00 +02:00
Valtteri Kantanen 561e41cab1 [Updater] Implement prepurge and purge logic with BullMQ 2025-02-28 10:45:10 +02:00
Valtteri Kantanen 4409f60195 Disable deprecated ESLint rule
See https://eslint.org/docs/latest/rules/no-return-await
2025-02-28 10:12:05 +02:00
Valtteri Kantanen 323aae2600 [Backend] Fix attempt number for BullMQ "started job" logs 2025-02-28 10:10:40 +02:00
Valtteri Kantanen fa28d86638 [Faculties] Initialize classSizes[level] correctly to prevent undefined property access 2025-02-27 19:24:00 +02:00
Riku RauhalaandGitHub 1976d2914d Merge pull request #4750 from UniversityOfHelsinkiCS/material-ui-study-programme-basic-information
Material UI: Study programme overview - Basic information tab
2025-02-27 16:09:23 +02:00
Riku Rauhala 52e6a49fd3 [Students] Update tests 2025-02-27 15:56:01 +02:00
Riku Rauhala fc4e48aaa0 [University] Update tests 2025-02-27 15:43:42 +02:00
Riku Rauhala ae83dc3563 [Study programme] Update tests 2025-02-27 14:14:16 +02:00
Riku Rauhala 2109cfcf87 [Study programme] Update Basic information tab to MUI 2025-02-27 14:14:16 +02:00
Riku Rauhala 1a3facf680 [Study programme] Convert BasicInformationTab into TypeScript 2025-02-27 14:14:16 +02:00
Valtteri Kantanen d016cec63c [Updater] Use BullMQ to schedule student updates 2025-02-26 18:02:09 +02:00
Valtteri Kantanen 74bfceaf72 [Updater] Add initial BullMQ setup 2025-02-26 17:27:15 +02:00
Valtteri Kantanen a549ae5095 [Updater] Make CHUNK_SIZE configurable via environment variable 2025-02-26 14:27:47 +02:00
Valtteri Kantanen 863cb9d134 [Backend] Make cron job schedule configurable 2025-02-26 14:27:02 +02:00
Riku RauhalaandGitHub eb2576bfad Merge pull request #4749 from UniversityOfHelsinkiCS/OOD-82
[UserPage] Added service provider check to show IamGroupsCard
2025-02-26 14:04:41 +02:00
Matti Räsänen 77831b3bcf [UserPage] Added service provider to IamGroupsCard 2025-02-26 13:51:10 +02:00
Valtteri Kantanen 99ff14b13f [Updater] Add REDIS_PORT env variable to updater-worker and updater-scheduler 2025-02-26 13:25:55 +02:00
Riku Rauhala e3c2427885 [Faculties] Remove unnecessary Section wrappers
Having the tables wrapped in a Section without any loading status leads to a more cluttered UI and unnecessary borders
2025-02-26 12:23:05 +02:00
Riku Rauhala a6e52a690f [Users] Improve user page responsiveness 2025-02-26 12:05:14 +02:00
Riku RauhalaandGitHub eac4a03ea8 Merge pull request #4748 from UniversityOfHelsinkiCS/material-ui-study-programme-update-statistics
Material UI: Study programme overview - Update statistics tab
2025-02-26 10:42:08 +02:00
Riku Rauhala def64d5612 [Study programme] Update tests 2025-02-26 10:30:42 +02:00
Riku Rauhala 9ddd42aced [Study programme] Update the Update statistics tabs to MUI 2025-02-26 09:31:11 +02:00
Riku Rauhala 60aa43d520 [Faculties] Extract component UpdateButton
This is also needed in StudyProgramme
2025-02-26 09:08:59 +02:00
Riku Rauhala 49be4b4f54 [Study programme] Refactor tabs to handle Funidata/admin 2025-02-26 09:05:02 +02:00
Riku Rauhala 2f5c68f439 [PageTitle] Display a skeleton when loading the title
In some cases the title comes from the API and there is a slight delay. From a UX perspective, it is better to display a skeleton for a brief second than flashing another text such as Faculties/Programmes
2025-02-25 21:41:50 +02:00
Riku Rauhala 54321f349c [Languages] Export type for GetTextIn
It's getting annoying to always type this mess separately, let's just export the type and use it instead
2025-02-25 20:12:29 +02:00
Riku Rauhala 9aba7ab5c5 [Study programme] Upgrade tabs to MUI 2025-02-25 19:56:44 +02:00
Riku Rauhala 059ccbd42b [Faculties] Use Stack for layout 2025-02-25 19:42:52 +02:00
Joonas Ahovalli 733cfac1be [Cypress] Add class statistics student tab tests 2025-02-25 17:26:46 +02:00
Joonas Ahovalli bcf1bca28b [Cypress] Change population statistics test structure to mimic that of the actual page 2025-02-25 14:19:23 +02:00
Riku RauhalaandGitHub 025aca0c15 Merge pull request #4747 from UniversityOfHelsinkiCS/material-ui-study-programme-selector
Material UI: Study programme overview - Programme selector
2025-02-24 20:07:19 +02:00
Riku Rauhala 00c4a6b409 [Study programme] Write tests for study programme selector 2025-02-24 19:53:25 +02:00
Riku Rauhala 9be9509562 [Study programme] Rename test file for consistency 2025-02-24 18:57:02 +02:00
Riku Rauhala 9e6a92c114 [Loading] Extract new component for loading spinner 2025-02-24 18:41:21 +02:00
Riku Rauhala 178ff11b48 [Study programme] Rewrite StudyProgrammeSelector with MUI 2025-02-24 18:27:56 +02:00
Riku Rauhala e7a1cdf098 [Util] Extract comparator functions 2025-02-24 11:11:48 +02:00
Riku Rauhala 60f10faf9e [Util] Extract getCombinedProgrammeName
Let's also rename this from unified programme to combined programme for consistency
2025-02-24 10:35:25 +02:00
Riku Rauhala b31b3543bd [Redux] Type studyProgrammePins 2025-02-24 10:14:22 +02:00
Riku Rauhala b4263f29c5 [Study programme] Convert StudyProgrammeSelector into TypeScript 2025-02-24 09:49:35 +02:00
Riku Rauhala 6a69a3b98b [Study programme] Move to pages 2025-02-24 09:22:20 +02:00
Joonas Ahovalli 921fa2d1de [Class statistics] Add loading indicator to modules tab if no data, prevent page crash 2025-02-21 15:59:21 +02:00
Riku Rauhala c4724f8c07 [Lint-staged] Check for type errors before commit 2025-02-21 15:08:00 +02:00
Joonas Ahovalli 44a7f8bcee [Class statistics] Fix page crash when opening courses tab before data has finished loading 2025-02-21 14:48:10 +02:00
Riku Rauhala a632f3757d [Teachers] Move access checking to access utils 2025-02-21 14:23:26 +02:00
Riku Rauhala a84ad67b76 [Hooks] Remove useStatusNotification 2025-02-21 14:06:40 +02:00
Riku Rauhala 29331e13d3 [Languages] Simplify context
No reason to wrap LanguageContext in a CommonContext
2025-02-21 13:48:55 +02:00
Riku Rauhala 0358cb49a6 [StatusNotification] Switch to context 2025-02-21 13:48:05 +02:00
Valtteri Kantanen 78719ee09a [Updater] Add new REDIS_HOST env variable to updater-worker and updater-scheduler 2025-02-21 13:41:08 +02:00
Valtteri Kantanen e66542fe89 [Updater] Add indexes to enrollment and studyplan tables to make updating students significantly faster 2025-02-21 13:38:05 +02:00
Riku Rauhala ca9d43b4a9 [Hooks] Extract a new hook for using StatusNotification 2025-02-21 13:16:45 +02:00
Riku Rauhala 286b4a90b4 [Users] Allow filtering by IAM groups in the Users table 2025-02-21 12:20:45 +02:00
Joonas AhovalliandGitHub cfa65da634 Merge pull request #4746 from UniversityOfHelsinkiCS/material-ui-users
Material UI: Users
2025-02-21 12:00:26 +02:00
Riku Rauhala 3c81a8cbcd [Users] Update tests 2025-02-21 10:12:43 +02:00
Riku Rauhala 444a261261 [Frontend] Fix TypeScript issues 2025-02-20 19:45:12 +02:00
Riku Rauhala a5928b0dba [Users] Remove EmailNotification 2025-02-20 19:36:34 +02:00
Riku Rauhala 27ab354a70 [Users] Only copy email addresses of visible users 2025-02-20 19:30:54 +02:00
Riku Rauhala e293eb8471 [Users] Rewrite email preview with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 4dcacbb2ce [Users] Extract component CardHeader 2025-02-20 19:30:54 +02:00
Riku Rauhala 122eb29068 [Users] Add IAM groups card to UserPage 2025-02-20 19:30:54 +02:00
Riku Rauhala d5e6b52316 [Users] Rewrite StudyProgrammesCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 601d69cc3d [Users] Update info box text 2025-02-20 19:30:54 +02:00
Riku Rauhala e8f73adc81 [Populations] Type studyProgramme endpoint 2025-02-20 19:30:54 +02:00
Riku Rauhala c0187ad18e [Users] Prevent crashing and improve typing 2025-02-20 19:30:54 +02:00
Riku Rauhala f877e1187d [Users] Extract component EditButton 2025-02-20 19:30:54 +02:00
Riku Rauhala 3d86be5b9a [Users] Improve RolesCard 2025-02-20 19:30:54 +02:00
Riku Rauhala fe9aad8043 [Backend] Use term roles instead of access groups
The term access group seems outdates and the same thing is referred to as roles elsewhere
2025-02-20 19:30:54 +02:00
Riku Rauhala 34d9855782 [Users] Rewrite RolesCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala e1213bd768 [Constants] Add roles 2025-02-20 19:30:54 +02:00
Riku Rauhala 5d6df13d8e [Users] Move RoleChip to components
This will be used on the UserPage too
2025-02-20 19:30:54 +02:00
Riku Rauhala bf08700807 [Users] Rewrite InfoCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala c95b5a9cef [Users] Move MockButton under components/material 2025-02-20 19:30:54 +02:00
Riku Rauhala cd7766982b [Users] Move UserPage into a subfolder 2025-02-20 19:30:54 +02:00
Riku Rauhala 02932036fe [Users] Rewrite NewUserSection with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 7b3bea1f9c [Users] Display a status message when loading/error 2025-02-20 19:30:54 +02:00
Riku Rauhala 09dec983aa [Users] Rewrite UsersTable with MUI and MRT 2025-02-20 19:30:54 +02:00
Riku Rauhala 3e8ac19af1 [Redux] Type getUsers 2025-02-20 19:30:54 +02:00
Riku Rauhala 93aacd3e16 [Theme] Add role colors 2025-02-20 19:30:54 +02:00
Riku Rauhala 29108ca2b2 [Users] Rewrite UsersTable with MUI/MRT 2025-02-20 19:30:54 +02:00
Riku Rauhala bfa14c9959 [Users] Rewrite index with MUI 2025-02-20 19:30:53 +02:00
Riku Rauhala 836f797da9 [Users] Convert to TypeScript 2025-02-20 19:30:53 +02:00
Riku Rauhala 45ca6f0083 [Users] Move to pages 2025-02-20 19:30:53 +02:00
Joonas Ahovalli 2b44529ed4 [Modules Tab] Add ordering by module code 2025-02-20 17:40:55 +02:00
Joonas Ahovalli e27d162921 [Modules Tab] Fix displayed modules not matching those shown in 'courses of class' in some cases 2025-02-20 17:21:50 +02:00
Valtteri Kantanen 7ebddf076f [Class statistics] Fix course queries being fired endlessly for users with only IAM rights 2025-02-20 15:03:08 +02:00
Joonas Ahovalli d0283bbc34 [Cypress] Fix broken study guidance group test 2025-02-20 13:27:04 +02:00
Valtteri Kantanen facbb2da44 [Filters] Update tooltip text for enrollment status filter (#4224) 2025-02-20 13:24:10 +02:00
Valtteri Kantanen cafc09f805 [Filters] Remove unused 'label' property from tool tip texts 2025-02-20 13:24:10 +02:00
Valtteri Kantanen c3a61ab7b3 [Filters] Remove future semesters from semester options 2025-02-20 13:24:10 +02:00
Joonas Ahovalli 2a0c39ab05 [Students tab] Add info about modules tab to info box 2025-02-20 12:43:28 +02:00
Joonas Ahovalli c83646512e [Study guidance groups] Add modules tab 2025-02-20 12:05:24 +02:00
Joonas Ahovalli 82c6181cd5 [Modules Tab] Switch to square icon for consistency 2025-02-20 11:50:00 +02:00
Joonas AhovalliandGitHub cc5f5e01c6 Merge pull request #4734 from UniversityOfHelsinkiCS/studyplan-selected-modules
[Modules Tab] New view to Class Statistics > Students. This view displays which study modules a student has selected in their study plan for that programme.
2025-02-19 18:50:58 +02:00
Joonas AhovalliandGitHub 7e04951e08 Merge branch 'master' into studyplan-selected-modules 2025-02-19 18:25:06 +02:00
Joonas Ahovalli 5cabca6311 [Modules Tab] Fix updated import aliasing 2025-02-19 18:05:11 +02:00
Joonas Ahovalli 96b181d3af [Modules Tab] Fix use of wrong studyplan in case of multiple studyplans or active studyrights 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 72acdf9a3b [Modules Tab] Improve how displayed modules are selected 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 0b29b6aa58 [Class statistics > students] Add tab for study modules 2025-02-19 18:00:04 +02:00
Joonas Ahovalli c272f1fde3 [Backend] Add included modules to getStudents query 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 7174ed55c1 [Filters] Fix usememo dependencies 2025-02-19 18:00:04 +02:00
Joonas Ahovalli f958ba6701 [Updater] Add chosen modules to studyplan 2025-02-19 18:00:04 +02:00
renovate[bot]andJoonas Ahovalli 93264fef4d Update all non-major dependencies 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli a4baf8ae6d [Course statistics] Change 'Both' to 'University + Open university' for clarity
It was discussed that Both is not that explicit and it should be immediately clear what the option means
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli f3aa59fe1f [Course statistics] Fix Excel exports 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 322f19826a [Course statistics] Revert gradeCategories
The order of the grades acually matters so let's not do this
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 2a2388cbf6 [Course statistics] Extract common table options 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli d2c283c1b9 [Course statistics] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 3d84fb6a74 [Course statistics] Convert Attempts table to MaterialReactTable 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 6a53e30a94 [Course statistics] Refactor utils
Move around util functions closer to the components they are only used in
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli f8616ba1c4 [Course statistics] Convert AttemptsTable into TypeScript 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli a87c35069d [Course statistics] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli fe87b0b960 [Students] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 1a8b88dd0e [Course statistics] Disable population link for empty rows 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli b09cb8d574 [Course statistics] Simplify obfuscated cells 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 630df92e9d [Course statistics] Fix grade column order 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 948808ce11 [Course statistics] Remove SortableTable from Students tab 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 30e822b3f6 [Course statistics] Use MaterialReactTable 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 2769d0999e [ExternalLink] Move under components/material
This component is used by two components now so it should be moved up
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 86a1ef7b9f [Student search] Simplify API call
Only return the data actually used on the page. Use proper camelCase naming
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli b4b6d45463 [Student details] Include student number in the page title 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli dc7ad404cb [Student search] Fix search 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli dec0087de0 [Students] Type API requests for student search 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 61f91d1cfe [StudentNameVisibilityToggle] Move to components/material
This components is already written using MUI so it should go under /material
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli c11f905a2e [Students table] Convert into TypeScript 2025-02-19 18:00:02 +02:00
renovate[bot]andJoonas Ahovalli b2e433a79b Update dependency eslint-config-prettier to v10 2025-02-19 18:00:02 +02:00
renovate[bot]andJoonas Ahovalli 1e7160dcf5 Update dependency stylelint-config-standard to v37 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli da973fe74a [Filters] Fix hover color on filter clear icon 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 309d3128e4 [Backend] Remove unused dependency 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 4add189096 [Filters] Fix ESLint warnings in tests 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli f5ddf6291b [Class statistics] Write tests for Bachelor + master view 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 3989e0da94 [Documentation] Add instructions for reverting migrations locally 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 3035b846a4 [Course statistics] Refactor pass rate series calculation
Eliminate copy-paste
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli c88aa37b68 [Course statistics] Update tests 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 884191f352 [Course statistics] Limit pass/fail rate to 100%
In some cases the pass rate could go over 100% if there are more passed grades than attempts logged. This should not be possible
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 35af223c32 [Students close to graduation] Use title 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 71e639efa1 [Languages] Extract function getFullLanguage 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 546c683c8b [Course statistics] Include name of the selected course in the url 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 1c5c7781a5 [Course statistics] Update tests
Include empty rows
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 99b08020db [Backend] Convert group keys to integers
The min-max does not work for strings in some cases, better to handle the values as numbers instead
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 8c2efa9199 [Course statistics] Fix show grades on the attempts tab
Not a full fix yet but a step in the correct direction
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 7c50c80b15 [Backend] Insert empty rows for missing courseStat years/semesters 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli 9290fbf32b [Updater] Change included_modules column from varchar(255) to text[] in studyplan table 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli ffaa3eb05b [Students] Prevent crash when study rights lack semester enrollments 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 77b159739f [Course statistics] Fix separate by semesters state
The toggle state wouldn't update if separate was already set to true in the query params
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 0e63b970f7 [Grade distribution] Refactor getGradeSeries 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 7bedbe6451 [Grade distribution] Use new colors and update relative view
Relative view should stack the bars like in the pass rate chart. The new colors help make this more clear to read
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli dacf5cc51b [Theme] Update grade colors
Use a unique color for each grade
2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli de5fdb7eaf [Class statistics] Make 'Show Bachelor + Master' toggle disabled if no programme is selected or the selected programme is not a bachelor's or master's programme 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli c35a632469 [Class statistics] Show more options in 'Study right status' filter if Bachelor + Master is selected 2025-02-19 18:00:00 +02:00
Valtteri KantanenandJoonas Ahovalli 691b6d6cb3 [Class statistics] Make header better if Bachelor + Master is selected 2025-02-19 18:00:00 +02:00
Valtteri KantanenandJoonas Ahovalli c8a69a15b4 [Close to graduation] Add 'Semesters used' column (#4491) 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 665b95a6f8 [Students] Fix content alignment if there are no enrollments 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d6ab10bed2 [Course statistics] Extract type ViewMode and refactor 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli a7fb7eda97 [Users] Acually add retries to a flaky test 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 44ce8ac298 [Users] Add retries to a flaky test 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 7312416e2b [Course statistics] Combine graph options for grade distribution
The same options can be used for the grade distribution chart as well
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d967bce32b [Course statistics] Combine attempts/students graph options
These are identical except for yAxis title
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 4716299771 [Course statistics] Extract component TotalsDisclaimer 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 1036b47523 [Highcharts] Disable titles globally
This was also done individually for most charts. If a title is needed, it will overwrite the global default setting
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 317fb6f655 [Highcharts] Disable credits globally 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d6d804d288 [Course statistics] Move grade colors to theme
This change gives more meaningful names to the colors used for grades, gets rid of the legacy color file and its unused colors
2025-02-19 18:00:00 +02:00
Valtteri KantanenandGitHub 8b3c408700 Merge pull request #4729 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-02-18 14:33:07 +02:00
renovate[bot]andGitHub 7a94c92181 Update all non-major dependencies 2025-02-18 12:21:53 +00:00
Riku RauhalaandGitHub 2feeaddb8b Merge pull request #4741 from UniversityOfHelsinkiCS/material-ui-attempt-statistics
Material UI: Course statistics - Attempts table
2025-02-17 18:38:17 +02:00
Riku Rauhala 6d9c4b0b0a [Course statistics] Change 'Both' to 'University + Open university' for clarity
It was discussed that Both is not that explicit and it should be immediately clear what the option means
2025-02-17 18:26:05 +02:00
Riku Rauhala c9abbf958a [Course statistics] Fix Excel exports 2025-02-17 17:39:10 +02:00
Riku Rauhala 47692de1ce [Course statistics] Revert gradeCategories
The order of the grades acually matters so let's not do this
2025-02-17 15:23:00 +02:00
Riku Rauhala ad6260a313 [Course statistics] Extract common table options 2025-02-17 14:19:08 +02:00
Riku Rauhala 298e43a89a [Course statistics] Update tests 2025-02-17 13:21:13 +02:00
Riku Rauhala 055d3b4eac [Course statistics] Convert Attempts table to MaterialReactTable 2025-02-17 12:48:13 +02:00
Riku Rauhala 5a831cdc86 [Course statistics] Refactor utils
Move around util functions closer to the components they are only used in
2025-02-14 14:43:18 +02:00
Riku Rauhala c23ee470d8 [Course statistics] Convert AttemptsTable into TypeScript 2025-02-14 14:06:59 +02:00
Riku RauhalaandGitHub 59b4aad1ab Merge pull request #4740 from UniversityOfHelsinkiCS/material-ui-student-statistics
Material UI: Course statistics - Students table
2025-02-14 13:57:42 +02:00
Riku Rauhala de3e0f5e6b [Course statistics] Update tests 2025-02-14 13:47:41 +02:00
Riku Rauhala 7ede62760e [Students] Update tests 2025-02-14 12:50:46 +02:00
Riku Rauhala d2fdeeb830 [Course statistics] Disable population link for empty rows 2025-02-14 10:44:06 +02:00
Riku Rauhala 220952153f [Course statistics] Simplify obfuscated cells 2025-02-14 09:05:49 +02:00
Riku Rauhala bace3c80af [Course statistics] Fix grade column order 2025-02-14 09:03:10 +02:00
Riku Rauhala d9457bf855 [Course statistics] Remove SortableTable from Students tab 2025-02-13 19:16:13 +02:00
Riku Rauhala 2d6d12f95f [Course statistics] Use MaterialReactTable 2025-02-13 18:57:34 +02:00
Valtteri KantanenandGitHub 93a3014209 Merge pull request #4714 from UniversityOfHelsinkiCS/renovate/eslint-config-prettier-10.x
Update dependency eslint-config-prettier to v10
2025-02-13 17:15:30 +02:00
renovate[bot]andGitHub 3d6bc23658 Update dependency eslint-config-prettier to v10 2025-02-13 15:02:52 +00:00
Valtteri KantanenandGitHub 1906827760 Merge pull request #4713 from UniversityOfHelsinkiCS/renovate/stylelint-config-standard-37.x
Update dependency stylelint-config-standard to v37
2025-02-13 16:54:55 +02:00
renovate[bot]andGitHub 4b8f389dd3 Update dependency stylelint-config-standard to v37 2025-02-13 14:42:16 +00:00
Valtteri Kantanen 5d69a9b53b [Filters] Fix hover color on filter clear icon 2025-02-13 16:40:38 +02:00
Valtteri Kantanen 5c0c267606 [Backend] Remove unused dependency 2025-02-13 16:21:41 +02:00
Valtteri Kantanen 00c7e45672 [Filters] Fix ESLint warnings in tests 2025-02-13 16:06:41 +02:00
Valtteri Kantanen b28d43c7f8 [Class statistics] Write tests for Bachelor + master view 2025-02-13 16:04:06 +02:00
Riku Rauhala c0f15c9dd9 [ExternalLink] Move under components/material
This component is used by two components now so it should be moved up
2025-02-13 13:51:57 +02:00
Valtteri Kantanen 64240c9f6f [Documentation] Add instructions for reverting migrations locally 2025-02-13 12:00:20 +02:00
Riku Rauhala cbbcdd0e40 [Student search] Simplify API call
Only return the data actually used on the page. Use proper camelCase naming
2025-02-12 17:31:47 +02:00
Riku Rauhala dc7a6805a7 [Student details] Include student number in the page title 2025-02-12 17:13:27 +02:00
Riku Rauhala 0321dbed62 [Student search] Fix search 2025-02-12 17:00:01 +02:00
Riku Rauhala 88a46a8f23 [Students] Type API requests for student search 2025-02-12 16:58:11 +02:00
Riku Rauhala 5c26f04436 [StudentNameVisibilityToggle] Move to components/material
This components is already written using MUI so it should go under /material
2025-02-12 16:13:50 +02:00
Riku Rauhala e80fa25fb3 [Students table] Convert into TypeScript 2025-02-12 10:27:12 +02:00
Riku Rauhala 12f11183cf [Course statistics] Refactor pass rate series calculation
Eliminate copy-paste
2025-02-12 09:35:11 +02:00
Riku RauhalaandGitHub abfefa8c3e Merge pull request #4739 from UniversityOfHelsinkiCS/course-statistics-pass-rate-fix
[Course statistics] Limit pass/fail rate to 100%
2025-02-11 22:57:46 +02:00
Riku Rauhala eb82eee286 [Course statistics] Update tests 2025-02-11 22:47:15 +02:00
Riku Rauhala 5ff9b3b788 [Course statistics] Limit pass/fail rate to 100%
In some cases the pass rate could go over 100% if there are more passed grades than attempts logged. This should not be possible
2025-02-11 22:27:54 +02:00
Riku Rauhala f4d73b649f [Students close to graduation] Use title 2025-02-11 21:16:09 +02:00
Riku Rauhala 4136c0d3b5 [Languages] Extract function getFullLanguage 2025-02-11 21:15:38 +02:00
Riku Rauhala 2b8cf76053 [Course statistics] Include name of the selected course in the url 2025-02-11 21:04:35 +02:00
Riku RauhalaandGitHub e23c60cb38 Merge pull request #4737 from UniversityOfHelsinkiCS/course-statistics-empty-rows
[Course statistics] Insert empty rows for years/semesters without attainments
2025-02-11 20:16:43 +02:00
Riku Rauhala 676804b770 [Course statistics] Update tests
Include empty rows
2025-02-11 20:05:18 +02:00
Riku Rauhala 2785742934 [Backend] Convert group keys to integers
The min-max does not work for strings in some cases, better to handle the values as numbers instead
2025-02-11 19:14:37 +02:00
Riku Rauhala f82638763f [Course statistics] Fix show grades on the attempts tab
Not a full fix yet but a step in the correct direction
2025-02-11 17:36:39 +02:00
Riku Rauhala 7253ba1e17 [Backend] Insert empty rows for missing courseStat years/semesters 2025-02-11 17:36:39 +02:00
Valtteri Kantanen e2847a68ea [Updater] Change included_modules column from varchar(255) to text[] in studyplan table 2025-02-11 16:47:28 +02:00
Valtteri Kantanen 687e231e65 [Students] Prevent crash when study rights lack semester enrollments 2025-02-11 14:55:57 +02:00
Riku Rauhala 0629614ed8 [Course statistics] Fix separate by semesters state
The toggle state wouldn't update if separate was already set to true in the query params
2025-02-10 22:10:05 +02:00
Riku Rauhala 843e04f154 [Grade distribution] Refactor getGradeSeries 2025-02-10 18:22:20 +02:00
Riku Rauhala d7c3423a3f [Grade distribution] Use new colors and update relative view
Relative view should stack the bars like in the pass rate chart. The new colors help make this more clear to read
2025-02-10 18:22:20 +02:00
Riku Rauhala 8be238d4cc [Theme] Update grade colors
Use a unique color for each grade
2025-02-10 18:22:20 +02:00
Valtteri Kantanen ecbaa35802 [Class statistics] Make 'Show Bachelor + Master' toggle disabled if no programme is selected or the selected programme is not a bachelor's or master's programme 2025-02-10 16:57:48 +02:00
Valtteri Kantanen 732e14cb2d [Class statistics] Show more options in 'Study right status' filter if Bachelor + Master is selected 2025-02-10 16:49:53 +02:00
Valtteri Kantanen e98ef1b94f [Class statistics] Make header better if Bachelor + Master is selected 2025-02-10 16:47:53 +02:00
Valtteri Kantanen e254de4df3 [Close to graduation] Add 'Semesters used' column (#4491) 2025-02-10 12:52:23 +02:00
Riku Rauhala 1d169ee70a [Students] Fix content alignment if there are no enrollments 2025-02-07 15:43:04 +02:00
Riku Rauhala 0380cab715 [Course statistics] Extract type ViewMode and refactor 2025-02-07 15:06:54 +02:00
Riku Rauhala 4391c06f00 [Users] Acually add retries to a flaky test 2025-02-07 15:02:42 +02:00
Riku Rauhala 36a3044b16 [Users] Add retries to a flaky test 2025-02-07 14:53:07 +02:00
Joonas Ahovalli db08330cb9 [Updater] Fix studyplanmapper for students with multiple programmes 2025-02-07 14:48:12 +02:00
Joonas Ahovalli 0a071ee071 [Updater] Add chosen modules to studyplan 2025-02-07 14:48:12 +02:00
Riku Rauhala 2354b696a5 [Course statistics] Combine graph options for grade distribution
The same options can be used for the grade distribution chart as well
2025-02-07 14:36:27 +02:00
Riku Rauhala f6db0f2fe8 [Course statistics] Combine attempts/students graph options
These are identical except for yAxis title
2025-02-07 14:22:26 +02:00
Riku Rauhala e40c219e78 [Course statistics] Extract component TotalsDisclaimer 2025-02-07 14:04:06 +02:00
Riku Rauhala 2e7ca4d960 [Highcharts] Disable titles globally
This was also done individually for most charts. If a title is needed, it will overwrite the global default setting
2025-02-07 13:42:43 +02:00
Riku Rauhala c3fd874559 [Highcharts] Disable credits globally 2025-02-07 13:29:52 +02:00
Riku Rauhala 80768b4d15 [Course statistics] Move grade colors to theme
This change gives more meaningful names to the colors used for grades, gets rid of the legacy color file and its unused colors
2025-02-07 13:21:49 +02:00
Joonas Ahovalli 7de955398c [Updater] Fix studyplanmapper for students with multiple programmes 2025-02-07 12:47:39 +02:00
Joonas Ahovalli 2510903d63 [Updater] Add chosen modules to studyplan 2025-02-06 17:01:34 +02:00
Riku Rauhala ef3aa1faf2 [Course statistics] Test info boxes 2025-02-06 14:34:05 +02:00
Riku Rauhala 7444567d64 [Course statistics] Update tests for ProviderOrganizationSelect 2025-02-06 14:14:27 +02:00
Riku Rauhala a559dab709 [Frontend] Update icon imports
Update the remaining MUI icon imports for consistency
2025-02-06 13:49:53 +02:00
Riku Rauhala 34feb51d7b [Course statistics] Turn Provider organization selector into a dropdown 2025-02-06 13:36:36 +02:00
Riku Rauhala a206a1a9b3 [Close to graduation] Align numeric columns to the right 2025-02-06 12:58:09 +02:00
923 changed files with 33384 additions and 28891 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"
]
}
+2 -5
View File
@@ -6,13 +6,10 @@ runs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: 'Install dependencies'
shell: bash
run: npm ci && cd services/frontend && npm ci && cd ../backend && npm ci
- name: 'Create symlink for shared'
shell: bash
run: ln -s $PWD/services/shared $PWD/services/backend/src/shared && ln -s $PWD/services/shared $PWD/services/frontend/src/shared
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
- name: 'Run Stylelint for CSS files'
shell: bash
run: npm run stylelint "services/frontend/src/**/*.css"
+11 -11
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Get current time'
id: get_current_time
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -85,7 +85,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -95,10 +95,10 @@ jobs:
run: docker create --name frontend quay.io/toska/oodikone-frontend:production
- name: Copy assets from the frontend container
run: docker cp frontend:/opt/app-root/src/build ./build
run: docker cp frontend:/opt/app-root/frontend/build ./build
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
@@ -119,7 +119,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -129,10 +129,10 @@ jobs:
run: docker create --name backend quay.io/toska/oodikone-backend:production
- name: Copy assets from the backend container
run: docker cp backend:/opt/app-root/src/dist ./dist
run: docker cp backend:/opt/app-root/backend/dist ./dist
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
@@ -142,7 +142,7 @@ jobs:
environment: production
set_commits: 'skip'
sourcemaps: './dist'
url_prefix: '~/opt/app-root/src/dist/'
url_prefix: '~/opt/app-root/backend/dist/'
version: ${{ steps.extract_version.outputs.VERSION }}
report_success:
@@ -157,7 +157,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -178,7 +178,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+8 -8
View File
@@ -13,18 +13,18 @@ on:
- 'package*.json'
jobs:
lint_backend_and_frontend:
name: 'Lint backend and frontend'
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint backend and frontend
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_end_to_end:
runs-on: ubuntu-latest
needs:
- lint_backend_and_frontend
- lint_packages
strategy:
fail-fast: false
matrix:
@@ -41,8 +41,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test studyprogramme overview'
spec: cypress/e2e/Studyprogramme_overview.js
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
@@ -59,7 +59,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
@@ -64,7 +64,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build backend image
id: build-image
@@ -99,7 +99,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -118,7 +118,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+16 -16
View File
@@ -12,12 +12,12 @@ on:
jobs:
# === Static tests / linting ===
lint_backend_and_frontend:
name: 'Lint backend and frontend'
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint backend and frontend
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
# === Build database images ===
@@ -25,7 +25,7 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build image and export it'
uses: docker/build-push-action@v6
@@ -50,7 +50,7 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
@@ -76,7 +76,7 @@ jobs:
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -120,9 +120,9 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: backend-image
- name: 'Load built backend image'
@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: check_migrations
with:
@@ -157,7 +157,7 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: backend-image
- name: 'Load built backend image'
@@ -199,8 +199,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test studyprogramme overview'
spec: cypress/e2e/Studyprogramme_overview.js
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
@@ -217,7 +217,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
@@ -236,7 +236,7 @@ jobs:
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
+9 -9
View File
@@ -20,12 +20,12 @@ jobs:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
- name: 'Install dependencies'
run: npm ci && cd updater/sis-updater-scheduler && npm ci && cd ../sis-updater-worker && npm ci
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
run: npm run eslint -- updater
@@ -33,7 +33,7 @@ jobs:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -54,7 +54,7 @@ jobs:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -79,7 +79,7 @@ jobs:
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
@@ -124,7 +124,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build worker image
id: build-image
@@ -154,7 +154,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build scheduler image
id: build-image
+6 -3
View File
@@ -1,11 +1,14 @@
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',
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
+2 -1
View File
@@ -6,5 +6,6 @@
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"trailingComma": "es5"
"trailingComma": "es5",
"endOfLine": "lf"
}
+3 -5
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/).
@@ -70,12 +71,9 @@ graph TB
end
server -->|Read| sisdb[(sis-db)]
subgraph Updater
updaterscheduler[Scheduler] --> |Push jobs| nats
nats(NATS server)
updaterworker --> |Fetch jobs| nats
updaterscheduler[Scheduler] --> |Push BullMQ jobs| redis(Redis)
updaterworker[Worker] -->|Write| sisdb
updaterscheduler --> redis(Redis)
updaterworker --> redis
updaterworker --> |Fetch BullMQ jobs| redis
end
end
+1 -1
View File
@@ -60,7 +60,7 @@ set_custom_select_prompt() {
# Run scripts and set the prompt
show_welcome
init_dirs
get_username
get_s3_config
set_custom_select_prompt
while true; do
+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
+66 -66
View File
@@ -56,10 +56,10 @@ describe('Faculty statistics', () => {
})
it('Correct tabs are shown', () => {
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('contain', 'Update statistics')
})
})
@@ -71,66 +71,66 @@ describe('Faculty statistics', () => {
describe('Info boxes', () => {
it('Students of the faculty', () => {
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
cy.cs('students-of-the-faculty-info-box-button').click()
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
})
it('Graduated of the faculty', () => {
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
cy.cs('graduated-of-the-faculty-info-box-button').click()
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Thesis writers of the faculty', () => {
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Credits produced by the faculty', () => {
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').contains('Sisältää opintopisteet')
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
})
})
it('All graphs and tables are shown', () => {
cy.get('[data-cy="StudentsOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="GraduatedOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="ThesisWritersOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="CreditsProducedByTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('be.visible')
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
})
it('Correct tabs are shown', () => {
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
})
it('Toggle years works', () => {
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="YearToggle"]').click()
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
})
it('Toggle programmes works', () => {
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
cy.get('[data-cy="ProgrammeToggle"]').click()
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
cy.cs('faculty-programmes-shown-info').should('not.exist')
cy.cs('programme-toggle').click()
cy.cs('faculty-programmes-shown-info').should('be.visible')
})
it('Toggle study rights works', () => {
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('not.exist')
cy.get('[data-cy="StudentToggle"]').click()
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('be.visible')
cy.cs('faculty-exclude-specials-info').should('not.exist')
cy.cs('study-right-toggle').click()
cy.cs('faculty-exclude-specials-info').should('be.visible')
})
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
@@ -142,7 +142,7 @@ describe('Faculty statistics', () => {
'CreditsProducedByTheFaculty',
]
sections.forEach(section => {
cy.get(`[data-cy="${section}Graph"]`).within(() => {
cy.cs(`${section}Graph`).within(() => {
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
@@ -161,14 +161,14 @@ describe('Faculty statistics', () => {
})
it('Export button', { retries: 2 }, () => {
cy.get('[data-cy=FacultyStudentTableExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info box', () => {
cy.get('[data-cy=FacultyStudentTableInfoBoxButton]').click()
cy.get('[data-cy=FacultyStudentTableInfoBoxContent]').contains('Opiskelijat, joiden')
cy.cs('faculty-student-table-info-box-button').click()
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
})
it.skip('Study right toggle', () => {
@@ -192,10 +192,10 @@ describe('Faculty statistics', () => {
})
it('Percentage toggle works', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '92.3 %')
cy.get('[data-cy="HidePercentagesToggle"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '92.3 %')
cy.cs('faculty-student-stats-table').should('be.visible')
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
cy.cs('percentage-toggle').click()
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
})
})
@@ -208,33 +208,33 @@ describe('Faculty statistics', () => {
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it.skip('Info boxes', () => {
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan')
cy.cs('FacultyProgress-info-box-button').click()
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
// TODO: Click open the accordion first
cy.get('[data-cy=BachelorMastersProgressInfoBoxButton]').click()
cy.get('[data-cy=BachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
cy.cs('BachelorMastersProgress-info-box-button').click()
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
})
it.skip('Progress bar charts exist', () => {
// TODO: Click open the accordion first
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorMastersProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyMastersProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
})
it.skip('Progress tables exist', () => {
// TODO: Click open the accordion first
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
cy.cs('FacultyMastersProgressTable').should('be.visible')
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
})
it.skip('Graduated toggle', () => {
@@ -255,14 +255,14 @@ describe('Faculty statistics', () => {
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.get('[data-cy=AverageGraduationTimesExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info boxes', () => {
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset')
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
})
it.skip('Study times toggle', () => {
@@ -278,10 +278,10 @@ describe('Faculty statistics', () => {
})
it('Graphs are visible', () => {
cy.get('[data-cy="bachelorGraduationTimes"]').should('be.visible')
cy.cs('bachelor-graduation-times-section').should('be.visible')
// ! No test data for bachelor + master
cy.get('[data-cy="masterGraduationTimes"]').should('be.visible')
cy.get('[data-cy="doctorGraduationTimes"]').should('be.visible')
cy.cs('master-graduation-times-section').should('be.visible')
cy.cs('doctor-graduation-times-section').should('be.visible')
})
})
})
+372 -319
View File
@@ -1,416 +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) => {
const card = cy.cs(`${filterName}-filter-card`)
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', () => {
const card = cy.cs('StudyTrack-filter-card')
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
checkFilteringResult(15)
programmeDropdown.get('i.delete').click()
})
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 0, true)
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', () => {
const card = cy.cs('studyRightTypeFilter-filter-card')
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.skip('Study right type filter is not visible', () => {
cy.contains('Study right type').should('not.exist')
})
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.')
// 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)
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
const card = cy.cs('TransferredToProgramme-filter-card')
card.get('[data-cy="option-havenot"] input').should('be.checked')
})
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', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
it('Graduation filter works', () => {
cy.cs('graduatedFromProgrammeFilter-header').click()
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()
})
})
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
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()
})
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('Enrollment filter works', () => {
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
checkFilteringResult(25)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
cy.cs('enrollmentStatusFilter-header').click()
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
checkFilteredStudentCount(25)
cy.cs('enrollmentStatusFilter-clear').click()
cy.cs('enrollmentStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Credit filter works', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('CreditsEarned', () => {
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
})
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('Age filter works', { retries: 3 }, () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 23, 30, 21)
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 21)
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(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')
})
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(6)
checkFilteringResult(21)
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', () => {
runTestStepWithPreAndPostParts('AdmissionType', () => {
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(18)
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(6)
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
})
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', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-course-dropdown').click().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().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())
})
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('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()
})
})
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', { 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(5)
checkFilteringResult(8)
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()
checkFilteringResult(defaultAmountOfStudents)
})
it('Study right type filter is not visible', () => {
cy.contains('Study right type').should('not.exist')
cy.cs('genderFilter-header').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
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)
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(() => {
cy.init(pathToLimits2021)
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', () => {
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()
})
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 defaults to "Attainment" mode', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
})
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('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
const card = cy.cs('Programme-filter-card')
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(33)
programmeDropdown.get('i.delete').click()
})
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
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(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', () => {
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', 10)
checkFilteredStudentCount(24)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-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()
})
})
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', () => {
let runTestStepWithPreAndPostParts
describe('Custom Population Statistics', { testIsolation: false }, () => {
let defaultAmountOfStudents
beforeEach(() => {
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)
cy.cs('student-number-input').click().type(students.join('\n'))
defaultAmountOfStudents = students.length
cy.cs('student-number-input').click()
cy.cs('student-number-input').type(students.join('{enter}'))
cy.cs('search-button').click()
})
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', () => {
const card = cy.cs('Programme-filter-card')
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
programmeDropdown.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().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().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]
+35 -27
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,27 +23,37 @@ 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')
cy.contains('End of the search for enrollments:')
cy.contains('01.01.2020')
cy.url().should(
'include',
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
)
cy.location('pathname').should('eq', '/openunipopulation')
cy.location('search').then(search => {
const params = new URLSearchParams(search)
const courseCodes = params.getAll('courseCode')
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
expect(params.get('startdate')).to.eq('01-01-2015')
expect(params.get('enddate')).to.eq('01-01-2020')
})
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])
})
@@ -73,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])
})
@@ -92,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'))
+284 -237
View File
@@ -2,57 +2,65 @@
// Now "Class statistics" in UI
const selectStudyProgramme = programme => {
cy.contains('Select study programme')
cy.get('[data-cy=select-study-programme]').click().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().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')
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')
@@ -60,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')
})
})
})
@@ -83,97 +95,13 @@ 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')
cy.cs('filtered-students')
})
it('Courses of class is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
})
it('Courses of class curriculum selection works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
it('New fetch of courses data is done when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v2/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)
})
})
it('New fetch of courses data is done when filtered students change', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v2/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)
})
})
})
it("Empty 'tags' tab has a link to the page where tags can be created", { retries: 2 }, () => {
cy.visit(pathToMathBSc2020)
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
cy.get('[data-cy=student-table-tabs]').within(() => {
cy.contains('Tags').click()
})
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.url().should('include', '/study-programme/KH50_001?p_m_tab=0&p_tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create tags for study programme')
cy.contains('button', 'Create a new tag').should('be.disabled')
})
// This test sometimes fails on headless mode. It seems that the click on the
// 'Fetch class with new settings' button doesn't always trigger history.push()
// so the page doesn't reload. This is why waiting also doesn't help.
@@ -200,121 +128,244 @@ describe('Population statistics tests', () => {
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
})
it("'Credits gained' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit(pathToMathBSc2020)
const totalStudents = 27
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
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()
const getTableData = (selector, numbersOfStudents) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `[data-cy='credits-gained-table-${selector}']`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
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.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')
})
for (const category of [
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
]) {
category.forEach(({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(1).contains(value)
cy.get('td').eq(2).contains(students)
cy.get('td').eq(3).contains(percentage)
})
describe('Credit statistics', () => {
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit(pathToMathBSc2020)
const totalStudents = 27
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.contains('Credits gained').click()
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
const getTableData = (selector, numbersOfStudents) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `[data-cy='credits-gained-table-${selector}']`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
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')
})
}
cy.get(
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
).then(tds => {
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
expect(sum).to.equal(totalStudents)
})
})
it("'Statistics' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.visit(pathToMathBSc2020)
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h3', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
for (const category of [
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
]) {
category.forEach(({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(0).contains(text)
cy.get('td').eq(1).contains(data[index])
cy.get('td').eq(1).contains(value)
cy.get('td').eq(2).contains(students)
cy.get('td').eq(3).contains(percentage)
})
})
})
}
cy.get(
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
).then(tds => {
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
expect(sum).to.equal(totalStudents)
})
}
})
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.visit(pathToMathBSc2020)
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.cs('credit-statistics-tab').click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h5', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(0).contains(text)
cy.get('td').eq(1).contains(data[index])
})
})
})
})
}
})
})
describe('Courses of class', () => {
it('Is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
})
it('Curriculum selection works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
it('Courses data is changed when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
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('Courses data is changed when filtered students change', () => {
cy.visit(pathToMathBSc2020)
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)')
})
})
describe('Students', () => {
beforeEach(() => {
cy.visit(pathToMathBSc2020)
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
})
it("'General tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
cy.contains('Taloustieteen maisteriohjelma')
cy.contains('Todistusvalinta')
cy.contains('Saksa')
cy.contains('Female')
})
})
// FIXME: Flaky
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
cy.contains('MAT12001')
cy.contains('MAT21001')
})
})
it("'Modules tab' Displays correct modules based on the selected programme", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Modules').click()
cy.contains('MAT011')
cy.contains('MAT110')
})
cy.contains('Courses of class').click()
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click()
cy.contains('Courses of class').click()
cy.cs('student-table-tabs').within(() => {
cy.contains('MAT011').should('not.exist')
cy.contains('MAT110')
})
})
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
// TODO: This fails in the pipeline, but works locally. Investigate.
cy.cs('student-table-tabs').within(() => {
cy.contains('Tags').click()
cy.contains('No tags defined. You can define them here.').find('a').click()
})
cy.url().should('include', '/study-programme/KH50_001?tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create new tag')
cy.cs('create-button').should('be.disabled')
})
})
})
@@ -332,7 +383,9 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.get('textarea').type(existing).type('{enter}').type(nonExisting)
cy.get('textarea').type(existing)
cy.get('textarea').type('{enter}')
cy.get('textarea').type(nonExisting)
cy.contains('button', 'Check students').click()
cy.contains('#checkstudentsresults', 'Results').within(() => {
cy.contains('Student numbers in list and in Sisu').click()
@@ -351,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')
})
})
+66 -50
View File
@@ -1,15 +1,15 @@
/// <reference types="cypress" />
const student = {
firstnames: 'Varpu Roope',
lastname: 'Mårtensson',
studentnumber: '550003',
sis_person_id: 'hy-hlo-115926826',
firstNames: 'Varpu Roope',
lastName: 'Mårtensson',
studentNumber: '550003',
sisPersonId: 'hy-hlo-115926826',
email: 'sisutestidata134902@testisisudata.fi',
}
const typeToSearch = text => {
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
cy.cs('student-search').type(text)
}
const typeStudentNumberAndClick = studentNumber => {
@@ -17,79 +17,95 @@ const typeStudentNumberAndClick = studentNumber => {
cy.contains('td', studentNumber).click()
}
const clearSearch = () => {
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").clear()
}
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)
typeToSearch(student.lastName)
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains(student.firstnames).should('not.exist')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.firstnames)
cy.contains(student.firstNames)
cy.contains('Student number')
cy.contains('Last name')
cy.contains('First names')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames)
cy.contains(student.lastName)
cy.cs('toggleStudentNames').click()
cy.contains(student.firstnames).should('not.exist')
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
})
it('Search term must be at least 4 characters long', () => {
typeToSearch(student.lastname.slice(0, 3))
typeToSearch(student.lastName.slice(0, 3))
cy.contains('Search term is not accurate enough')
typeToSearch(student.lastname.slice(3))
typeToSearch(student.lastName.slice(3))
cy.get('table tbody tr').should('have.length', 1)
})
it('Can search with studentnumber too', () => {
typeToSearch(student.studentnumber)
cy.contains(student.studentnumber)
it('Can search with student number too', () => {
typeToSearch(student.studentNumber)
cy.contains(student.studentNumber)
})
it('Can get student specific page by clicking student', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
cy.contains(student.lastname).should('not.exist')
cy.contains(student.firstnames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.lastname)
cy.contains(student.firstnames)
cy.contains(student.lastName)
cy.contains(student.firstNames)
cy.contains(student.email)
cy.cs('toggleStudentNames').click()
cy.contains(student.lastname).should('not.exist')
cy.contains(student.firstnames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
})
it("'Update student' button is not shown", () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=student-info-card]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student').should('not.exist')
})
})
it('Can get back to search menu', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.go('back')
cy.contains('Student number').should('not.exist')
cy.contains('Credits').should('not.exist')
})
it('Can jump to course', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Tilastollinen päättely I (MAT12004)')
.parent()
.siblings()
@@ -104,20 +120,20 @@ describe('Students tests', () => {
})
it('Has correct Sisu link', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=sisu-link]')
typeStudentNumberAndClick(student.studentNumber)
cy.cs('sisu-link')
.should('have.attr', 'href')
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
})
it('Semester enrollments can be toggled', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
const programmes = [
'Oikeustieteen tohtoriohjelma',
'Matemaattisten tieteiden kandiohjelma',
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
]
cy.get('[data-cy=student-info-card]').within(() => {
cy.cs('student-info-card').within(() => {
cy.contains('Enrollments').click()
cy.get('table tbody tr')
.should('have.length', 3)
@@ -129,38 +145,38 @@ describe('Students tests', () => {
})
})
it("Searching with bad inputs doesn't yield results", () => {
it("Searching with bad name doesn't yield results", () => {
typeToSearch('SWAG LITTINEN')
cy.contains('Student number').should('not.exist')
clearSearch()
})
it("Searching with bad student number doesn't yield results", () => {
typeToSearch('01114')
cy.contains('Student number').should('not.exist')
})
it('Can jump to population page', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=study-rights-section]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
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', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('button', 'Grade graph').click()
cy.contains('button', 'Show group mean').click()
cy.get('[data-cy=group-size-input]').within(() => {
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').should('have.value', '5')
})
cy.contains('.highcharts-container text', "Nov '22")
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
cy.get('[data-cy=group-size-input]').within(() => {
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
@@ -215,7 +231,7 @@ describe('Students tests', () => {
})
it("Shows 'Has not graduated' when the student has not graduated", () => {
cy.visit(`students/${student.studentnumber}`)
cy.visit(`students/${student.studentNumber}`)
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
@@ -229,14 +245,14 @@ describe('Students tests', () => {
cy.init('/students', 'admin')
})
it('Does not crash if student has no studyright or courses', () => {
it('Does not crash if student has no study rights or courses', () => {
typeStudentNumberAndClick('450730')
cy.contains('Credits: 0')
})
it("'Update student' button is shown", () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=student-info-card]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student')
})
})
+34 -32
View File
@@ -66,27 +66,30 @@ 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', () => {
const firstRowHeadings = ['Student number', 'Credits', 'Start year at uni', 'Study programmes', 'Tags']
const secondRowHeadings = ['All', 'Since 1.1.1970']
cy.cs('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
'Credits since 1.1.1970',
'Start year at uni',
'Study programmes',
'Tags',
]
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
@@ -107,31 +110,36 @@ 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', 'Progress'])
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
})
})
it('general tab of the students table has the correct columns', () => {
const firstRowHeadings = [
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'Credits',
'All credits',
'Credits in HOPS',
'Credits since 1.8.2020',
'Start of study right',
'Started in programme',
'Semesters present',
@@ -146,15 +154,9 @@ describe('Study guidance group tests', () => {
'Latest attainment date',
'Tags',
]
const secondRowHeadings = ['All', 'HOPS', 'Since 1.8.2020']
cy.cs('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replaceAll('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children)
.filter(elem => elem.style.display !== 'none')
.map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
+882
View File
@@ -0,0 +1,882 @@
/// <reference types="cypress" />
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()}`
const selectYear = year => {
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
cy.contains('Associated start year (optional)').click({ force: true })
cy.get('[placeholder=YYYY]').type(year)
}
const deleteTag = tagName => {
cy.cs(`delete-tag-${tagName}-button`).click()
cy.contains('Delete tag')
cy.contains('Are you sure you want to delete tag')
cy.cs('confirm-delete-tag-button').click()
}
describe('Study programme overview', () => {
describe('Study programme selector', () => {
beforeEach(() => {
cy.init('/study-programme', 'admin')
})
it('Correct categories', () => {
cy.contains('Bachelor programmes')
cy.contains('Master programmes')
cy.contains('Combined programmes')
cy.contains('Doctoral programmes')
})
it('Study programme search filter', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
})
it('Old and specialized programmes filter', () => {
cy.contains('Biotekniikka (ylempi)').should('not.exist')
cy.cs('filter-old-programmes-toggle').click()
cy.contains('Biotekniikka (ylempi)').should('exist')
})
})
describe('Basic information tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information tab loads', () => {
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('programmes-before-or-after-section')
cy.cs('average-graduation-times-section')
})
it('Basic information contains correct students', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 0, 0],
[2022, 25, 26, 48, 1, 4],
[2021, 29, 32, 47, 0, 2],
[2020, 26, 27, 12, 1, 3],
[2019, 28, 34, 1, 0, 1],
[2018, 40, 45, 0, 0, 0],
[2017, 41, 47, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
})
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 209],
[2021, 5133, 5108, 0, 25, 428],
[2020, 5801, 5796, 0, 5, 94],
[2019, 5305, 5305, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 21],
[2017, 1211, 1211, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it("Toggling 'Show special categories' on displays additional information", () => {
cy.cs('special-categories-toggle').click()
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 0, 0, 209],
[2021, 5133, 5108, 0, 25, 0, 0, 428],
[2020, 5801, 5796, 0, 5, 0, 0, 94],
[2019, 5305, 5305, 0, 0, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 0, 0, 21],
[2017, 1211, 1211, 0, 0, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it('Basic information contains correct thesis writers and graduates', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 48, 13],
[2021, 47, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
})
it('Special study rights can be excluded and basic data changes accordingly', () => {
cy.cs('study-right-toggle').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated]
...years.map(year => [year, 0, 0, 0]),
[2023, 8, 8, 24],
[2022, 25, 26, 44],
[2021, 29, 32, 46],
[2020, 26, 27, 11],
[2019, 28, 34, 1],
[2018, 40, 45, 0],
[2017, 41, 47, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 44, 12],
[2021, 46, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.cs('year-toggle').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 0, 0],
['2022 - 2023', 25, 26, 36, 0, 0],
['2021 - 2022', 29, 32, 57, 1, 6],
['2020 - 2021', 26, 27, 30, 0, 3],
['2019 - 2020', 28, 34, 7, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0],
['2017 - 2018', 41, 47, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 67],
['2022 - 2023', 2725, 2720, 0, 5, 337],
['2021 - 2022', 4092, 4042, 0, 50, 198],
['2020 - 2021', 5420, 5415, 0, 5, 321],
['2019 - 2020', 6043, 6043, 0, 0, 101],
['2018 - 2019', 4856, 4851, 0, 5, 107],
['2017 - 2018', 2350, 2345, 0, 5, 26],
]
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
cy.cs('year-toggle').click()
})
it('Basic information graphs render', () => {
cy.cs('students-of-the-study-programme-line-graph-section')
.should('contain', 'Started studying')
.should('contain', 'Accepted')
.should('contain', 'Graduated')
.should('contain', 'Transferred away')
.should('contain', 'Transferred to')
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
.should('contain', 'Degree students')
.should('contain', 'Transferred')
.should('contain', 5796)
.should('contain', 428)
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
.should('contain', 'Graduated students')
.should('contain', 'Wrote thesis')
.should('contain', 47)
.should('contain', 76)
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
cy.contains('Graduation year')
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 44.5 months')
cy.contains('22 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('14 graduated over year late')
})
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
.should('contain', 32)
.should('contain', 21)
.should('contain', 17)
})
})
describe('Graduation times of master programmes', () => {
it('are split into two graphs', () => {
cy.init('/study-programme')
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('master-breakdown-bar-chart-section')
cy.cs('graduation-time-toggle').click()
cy.cs('master-median-time-bar-chart-section').within(() => {
cy.contains('Master study right')
cy.contains('Graduation year')
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
})
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
cy.contains('Bachelor + master study right')
cy.contains('Graduation year')
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 69 months')
cy.contains('3 graduated on time')
cy.contains('7 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
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()
})
describe('Info boxes', () => {
it('Study track overview section', () => {
cy.cs('study-track-overview-info-box-button').click()
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
})
it('Progress of students section', () => {
cy.cs('progress-of-students-info-box-button').click()
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
})
it('Average graduation times section', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
})
})
it('All sections are visible', () => {
cy.cs('study-track-selector-section')
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it('Students of the study programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
// TODO: Fix this test
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.right.icon').click()
})
const dataForStudyTracks = [
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
]
dataForStudyTracks.forEach((data, index) => {
cy.get('tbody tr.regular-row')
.eq(index)
.within(() => {
cy.get('td').each((cell, i) => {
cy.wrap(cell).contains(data[i])
})
})
})
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.down.icon').click()
})
cy.get('tbody tr.regular-row').should('not.exist')
})
})
describe('Population link button works for', () => {
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')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 227 students')
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('show-study-tracks-button').first().click()
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
it('Student progress data is shown correctly', () => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
]
cy.checkTableStats(tableContents, 'study-programme-progress')
})
it('Progress section', () => {
cy.cs('programme-progress-bar-chart-section')
.should('contain', 'Less than 30 credits')
.should('contain', '30–60 credits')
.should('contain', 'At least 180 credits')
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
})
it('Average graduation times section', () => {
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
cy.contains('On time: 15')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
})
cy.cs('graduation-time-toggle').click()
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('15 graduated on time')
cy.contains('3 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
describe('Study track can be changed', () => {
beforeEach(() => {
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')
cy.contains('Matematiikka').click()
})
it('Students of the study track are shown correctly', () => {
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it('Links to class statistics page with study track info included work', () => {
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('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the study programme.')
})
it.skip('Average graduation times are displayed correctly', () => {
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
cy.contains('Graduated On time: 9 students')
})
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('12 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
cy.contains('median study time: 34 months')
cy.contains('9 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
})
describe('Programme courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('programme-courses-tab').click()
cy.cs('by-credit-type-section')
cy.cs('by-credit-type-tab')
cy.cs('by-semester-tab')
})
describe('By credit type tab', () => {
beforeEach(() => {
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('341')
})
})
})
it('time range selection works', () => {
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2021').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2022').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('121')
})
})
})
it('year toggle works', () => {
cy.cs('year-toggle').click()
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2017').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2023').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('311')
})
})
})
// TODO: Fix sorting / filters
it.skip('different sorting options work', () => {
cy.cs('CoursesSortableTable').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('odgi-zef0')
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
})
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT-yht')
cy.get('td').eq(1).contains('Muut opinnot')
})
// Course name
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20003')
cy.get('td').eq(1).contains('Äidinkielen opinnot')
})
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
})
// Total credits
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT110')
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
})
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20001')
cy.get('td').eq(1).contains('Kypsyysnäyte')
})
})
})
it("'Show credits/Show students' toggle works", () => {
cy.get('thead tr').within(() => {
cy.get('th').should('have.length', 8)
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.cs('show-credits-students-toggle').click()
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 10)
const headers = [
'Type',
'Code',
'Name',
'Total',
'Passed',
'Not',
'Major',
'Non-major',
'Non-degree',
'Transferred',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('341')
})
})
})
})
describe('By semester tab', () => {
beforeEach(() => {
cy.cs('by-semester-tab').click()
})
// FIXME: Flaky
it('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
})
})
describe('Degree courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('degree-courses-tab').click()
})
it('Curriculum section', () => {
cy.cs('curriculum-section')
cy.contains('Select curriculum to edit:')
cy.cs('curriculum-picker').contains('2023–2026')
// TODO: Test changing the curriculum
})
describe('Credit criteria section', () => {
it('info box', () => {
cy.cs('credit-criteria-section')
cy.cs('credit-criteria-info-box-button').click()
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
})
it.skip('changing the credit criteria works', () => {
// TODO: Test using the inputs
// TODO: Button status
// TODO: Test "previously set to _" text changing when saving
})
})
it('Degree course table', () => {
cy.cs('degree-course-table')
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
})
})
describe('Tags tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('tags-tab').click()
})
it('info box', () => {
cy.cs('create-new-tag-info-box-button').click()
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
})
describe('Adding tags to populations and removing them works', () => {
beforeEach(() => {
cy.contains('This study programme does not have any tags yet')
cy.cs('tag-name-text-field').type(tagName)
})
afterEach(() => {
deleteTag(tagName)
cy.contains('This study programme does not have any tags yet')
})
it('can create and delete tags without start year', () => {
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('No associated start year')
})
it.skip('can create and delete tags with start year', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('Associated start year 2022')
})
it.skip('population link works', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs('population-link-button').click()
cy.contains(`Tagged with: ${tagName}`)
cy.go('back')
})
it('can create personal tags', () => {
cy.cs('personal-tag-toggle').click()
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs(`${tagName}-visibility-icon`)
})
})
describe('Adding tags to students and removing them works', () => {
const studentInput = '477806,478275; 478953 479239\n 480080'
const studentNumbers = studentInput.match(/[^\s,;]+/g)
it.skip('can add tags to students', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
cy.cs('tag-name-text-field').type(tagName)
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs('add-students-button').click()
cy.cs('add-students-text-field').type(studentInput)
cy.cs('add-students-confirm-button').click()
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
})
for (const studentNumber of studentNumbers) {
cy.contains(studentNumber)
}
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags')
cy.contains(tagName)
}
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
deleteTag(tagName)
})
it('deleting a tag from tag view also removes it from students', () => {
cy.contains(tagName).should('not.exist')
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags').should('not.exist')
cy.contains(tagName).should('not.exist')
}
})
})
})
describe('IAM user', () => {
beforeEach(() => {
cy.init('/study-programme', 'onlyiamrights')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
})
it.skip('year selector', () => {
// TODO: Implement this test
// Things to test:
// - Clicking the button works
// - Selecting a study track works
// - The button is disabled if only one year is selected
// - The slider is not visible if the user does not have correct programme rights
})
it('can access programme and correct tabs are visible', () => {
cy.contains('Basic information')
cy.contains('Study tracks and class statistics')
cy.contains('Update statistics').should('not.exist')
cy.contains('Degree courses').should('not.exist')
})
it('can access basic information', () => {
cy.cs('basic-information-tab').click()
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('average-graduation-times-section')
cy.cs('programmes-before-or-after-section')
})
it('can access study tracks', () => {
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it("doesn't see other tabs", () => {
cy.cs('degree-courses-tab').should('not.exist')
cy.cs('tags-tab').should('not.exist')
cy.cs('update-statistics-tab').should('not.exist')
})
})
})
-804
View File
@@ -1,804 +0,0 @@
/// <reference types="cypress" />
const { getEmptyYears } = require('../support/commands')
const deleteTag = name => {
cy.contains('td', name).siblings().contains('Delete').click()
cy.contains('Are you sure you want to delete tag')
cy.contains('Confirm').click()
cy.contains('td', name).should('not.exist')
}
describe('Study programme overview', () => {
/* Basic information overview -tests */
describe('Basic information view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information tab loads', () => {
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
cy.get('[data-cy=Section-averageGraduationTimes]')
})
it('Basic information contains correct students', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 0, 0],
[2022, 25, 26, 48, 1, 4],
[2021, 29, 32, 47, 0, 2],
[2020, 26, 27, 12, 1, 3],
[2019, 28, 34, 1, 0, 1],
[2018, 40, 45, 0, 0, 0],
[2017, 41, 47, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
})
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 209],
[2021, 5133, 5108, 0, 25, 428],
[2020, 5801, 5796, 0, 5, 94],
[2019, 5305, 5305, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 21],
[2017, 1211, 1211, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
})
it("Toggling 'Show special categories' on displays additional information", () => {
cy.contains('Show special categories').click()
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 0, 0, 209],
[2021, 5133, 5108, 0, 25, 0, 0, 428],
[2020, 5801, 5796, 0, 5, 0, 0, 94],
[2019, 5305, 5305, 0, 0, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 0, 0, 21],
[2017, 1211, 1211, 0, 0, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
})
it('Basic information contains correct thesis writers and graduates', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 48, 13],
[2021, 47, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
})
it('Special study rights can be excluded and basic data changes accordingly', () => {
cy.get('[data-cy=StudentToggle]').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated]
...years.map(year => [year, 0, 0, 0]),
[2023, 8, 8, 24],
[2022, 25, 26, 44],
[2021, 29, 32, 46],
[2020, 26, 27, 11],
[2019, 28, 34, 1],
[2018, 40, 45, 0],
[2017, 41, 47, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 44, 12],
[2021, 46, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.get('[data-cy=YearToggle]').click({ force: true })
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 0, 0],
['2022 - 2023', 25, 26, 36, 0, 0],
['2021 - 2022', 29, 32, 57, 1, 6],
['2020 - 2021', 26, 27, 30, 0, 3],
['2019 - 2020', 28, 34, 7, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0],
['2017 - 2018', 41, 47, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 67],
['2022 - 2023', 2725, 2720, 0, 5, 337],
['2021 - 2022', 4092, 4042, 0, 50, 198],
['2020 - 2021', 5420, 5415, 0, 5, 321],
['2019 - 2020', 6043, 6043, 0, 0, 101],
['2018 - 2019', 4856, 4851, 0, 5, 107],
['2017 - 2018', 2350, 2345, 0, 5, 26],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
cy.get('[data-cy=YearToggle]').click()
})
it('Basic information graphs render', () => {
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
.should('contain', 'Started studying')
.should('contain', 'Accepted')
.should('contain', 'Graduated')
.should('contain', 'Transferred away')
.should('contain', 'Transferred to')
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
.should('contain', 'Degree students')
.should('contain', 'Transferred')
.should('contain', 5796)
.should('contain', 428)
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
.should('contain', 'Graduated students')
.should('contain', 'Wrote thesis')
.should('contain', 47)
.should('contain', 76)
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
cy.contains('Graduation year')
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 44.5 months')
cy.contains('22 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('14 graduated over year late')
})
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
.should('contain', 32)
.should('contain', 21)
.should('contain', 17)
})
})
describe('Graduation times of master programmes', () => {
it('are split into two graphs', () => {
cy.init('/study-programme')
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
cy.contains('Master study right')
cy.contains('Graduation year')
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
})
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
cy.contains('Bachelor + master study right')
cy.contains('Graduation year')
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 69 months')
cy.contains('3 graduated on time')
cy.contains('7 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
/* Study track overview tests */
describe('Study track overview works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Study tracks and class statistics').click()
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Study tracks and class statistics -tab loads', () => {
cy.get('[data-cy=Section-studyTrackOverview]')
cy.get('[data-cy=Section-studyTrackProgress]')
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
})
it('Students of the study programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'StudyTrackOverview')
})
it('Years in the students table can be expanded and study track data will be shown', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.right.icon').click()
})
const dataForStudyTracks = [
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
]
dataForStudyTracks.forEach((data, index) => {
cy.get('tbody tr.regular-row')
.eq(index)
.within(() => {
cy.get('td').each((cell, i) => {
cy.wrap(cell).contains(data[i])
})
})
})
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.down.icon').click()
})
cy.get('tbody tr.regular-row').should('not.exist')
})
})
it('Links to class statistics page work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.get('tbody tr.header-row')
.eq(1)
.within(() => {
cy.get('td i.level.up.alternate.icon').click()
})
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('class size 26 students')
})
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.get('td.total-row-cell a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 227 students')
})
it('Links to class statistics page with study track info included work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.right.icon').click()
})
})
cy.contains('td', 'Matematiikka, MAT-MAT').within(() => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('div.sub.header', 'studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
})
it('Student progress data is shown correctly', () => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
]
cy.checkTableStats(tableContents, 'StudyTrackProgress')
})
it('Study track overview graphs render', () => {
cy.get('[data-cy=Graph-StudyTrackProgress]')
.should('contain', 'Less than 30 credits')
.should('contain', '30–60 credits')
.should('contain', 'At least 180 credits')
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
cy.get('[data-cy=Graph-StudyTrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
cy.get("[data-cy='Section-KH50_001']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
cy.contains('On time: 15')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains("Click a bar to view that year's study track level breakdown").should('not.exist')
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
})
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get("[data-cy='Section-KH50_001']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('15 graduated on time')
cy.contains('3 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
describe('Study track can be changed', () => {
beforeEach(() => {
cy.get('.studytrack-selector').contains('All students of the programme, KH50_001').click()
cy.get('.studytrack-selector .visible.menu').contains('Matematiikka, MAT-MAT').click()
})
it('Students of the study track are shown correctly', () => {
cy.get("[data-cy='Section-studyTrackOverview']").contains(
'Students of the study track MAT-MAT by starting year'
)
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'StudyTrackOverview')
})
it('Links to class statistics page with study track info included work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.get('tbody tr.regular-row')
.eq(2)
.within(() => {
cy.get('a').click()
})
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('div.sub.header', 'studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
})
it('Info message about missing progress stats is displayed', () => {
cy.contains('.divider', 'Progress of students of the study track MAT-MAT by starting year')
cy.contains(
'.message',
'Currently progress data is only available for all students of the study programme. Please select ”All students of the programme” to view the progress data.'
)
})
it('Average graduation times are displayed correctly', () => {
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
cy.contains('Graduated On time: 9 students')
})
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('12 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
cy.contains('median study time: 34 months')
cy.contains('9 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
})
/* Programme courses -tests */
describe('Programme courses works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Programme courses').click()
})
it('content loads', () => {
cy.get('[data-cy=CoursesYearFilter]')
cy.get('[data-cy=CourseTabs]')
})
it('time range selection works', () => {
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
})
cy.get('[data-cy=fromYear]').click().contains('2018').click()
cy.get('[data-cy=toYear').click().contains('2019').click()
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('15')
})
})
})
it("'Calendar year/Academic year' toggle works", () => {
cy.get('[data-cy=calendarAcademicYearToggle]').click()
cy.get('[data-cy=fromYear]').contains('2017-2018')
cy.get('[data-cy=toYear').contains('2023-2024')
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
})
})
it('contains correct courses in alphabetical order', () => {
cy.get('[data-cy=CoursesSortableTable]').within(() => {
cy.get('tr').eq(1).contains('Advanced calculus')
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
})
})
it('different sorting options work', () => {
cy.get('[data-cy=CoursesSortableTable]').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('odgi-zef0')
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
})
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT-yht')
cy.get('td').eq(1).contains('Muut opinnot')
})
// Course name
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20003')
cy.get('td').eq(1).contains('Äidinkielen opinnot')
})
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
})
// Total credits
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT110')
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
})
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20001')
cy.get('td').eq(1).contains('Kypsyysnäyte')
})
})
})
it("'Show credits/Show students' toggle works", () => {
cy.get('[data-cy=CoursesSortableTable] thead tr').within(() => {
cy.get('th').should('have.length', 8)
const headers = [
'Code',
'Name',
'Total credits',
'Major credits',
'Non-major credits',
'Non-degree credits',
'Transferred credits',
'Type',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
})
cy.get('[data-cy=creditsStudentsToggle]').click()
cy.get('[data-cy=CoursesSortableTable] thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Code',
'Name',
'Total',
'Breakdown of total',
'Breakdown of passed students',
'Not included in total nor passed',
'Type',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('[data-cy=CoursesSortableTable] thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 6)
const headers = [
'Passed',
'Not completed',
'Major students',
'Non-major students',
'Non-degree students',
'Transferred students',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('23')
})
})
})
})
describe('Degree courses works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Degree courses').click()
})
it('content loads', () => {
cy.contains('h3', 'Select curriculum to edit:')
cy.get('[data-cy=curriculum-picker]').contains('2023–2026')
cy.contains('Change visibility of degree courses and select criteria for academic years')
cy.contains('form', 'First year (12 months) last set: 0')
cy.get('table').within(() => {
cy.contains('Muut opinnot')
cy.contains('Matematiikka, perusopinnot')
cy.contains('div.green.label', 'visible')
cy.contains('button', 'Set hidden')
})
})
})
/* Tag-tests */
describe('Tags view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.get('.attached').contains('Tags').click()
})
it('can create and delete tags for population', () => {
const name = `tag-${new Date().getTime()}`
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
cy.contains('Create a new tag').click()
cy.contains(name)
cy.contains('2022')
deleteTag(name)
})
it('can create personal tags', () => {
const name = `tag-${new Date().getTime()}`
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
cy.get('.ui > label').click()
cy.contains('Create a new tag').click()
cy.contains(name)
deleteTag(name)
})
describe('Adding tags to students and removing them works', () => {
const name = `tag-${new Date().getTime()}`
const studentInput = '477806,478275; 478953 479239\n 480080'
const studentNumbers = studentInput.match(/[^\s,;]+/g)
it('can add tags to students', () => {
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
cy.contains('Create a new tag').click()
cy.contains(name)
cy.contains('Add a tag to students').click()
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
cy.get('textarea').type(studentInput)
cy.contains('Add tags').click()
cy.contains('Successfully added tags to students.')
cy.contains('td', name).get('i.level.up.alternate.icon').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains(`Tagged with: ${name}`)
cy.contains('Students (5)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
})
for (const studentNumber of studentNumbers) {
cy.contains(studentNumber)
}
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags')
cy.contains(name)
}
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
deleteTag(name)
})
it('deleting a tag from tag view also removes it from students', () => {
cy.contains(name).should('not.exist')
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags').should('not.exist')
cy.contains(name).should('not.exist')
}
})
})
})
describe('IAM user', () => {
beforeEach(() => {
cy.init('/study-programme', 'onlyiamrights')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
})
it('can access programme and correct tabs are visible', () => {
cy.contains('Basic information')
cy.contains('Study tracks and class statistics')
cy.contains('Update statistics').should('not.exist')
cy.contains('Degree courses').should('not.exist')
})
it('can access basic information', () => {
cy.contains('Basic information').click()
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
cy.get('[data-cy=Section-averageGraduationTimes]')
})
it('can access study tracks', () => {
cy.get('.attached').contains('Study tracks and class statistics').click()
cy.get('[data-cy=Section-studyTrackOverview]')
cy.get('[data-cy=Section-studyTrackProgress]')
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
})
it("doesn't see other tabs", () => {
cy.get('div.ui.tabular.menu a').should('have.length', 2)
})
})
})
+19 -18
View File
@@ -1,26 +1,26 @@
/// <reference types="cypress" />
const progressLevels = ['Bachelors', 'BachelorMasters', 'Masters', 'Doctoral']
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
const checkProgressBarCharts = () => {
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressBarChart]`))
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
}
const checkProgressTables = () => {
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressTable]`))
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
const checkAverageGraduationTimesBreakdownBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.get(`[data-cy=${level}BreakdownBarChart]`)
cy.cs(`${level}-breakdown-bar-chart`)
})
}
const checkAverageGraduationTimesMedianBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.get(`[data-cy=${level}MedianBarChart]`)
cy.cs(`${level}-median-bar-chart`)
})
}
@@ -40,20 +40,21 @@ describe('University view', () => {
})
it("'Graduated included / Graduated excluded' toggle works", () => {
cy.get('[data-cy=GraduatedToggle]').click()
cy.cs('graduated-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
it("'All study rights / Special study rights excluded' toggle works", () => {
cy.get('[data-cy=StudyRightToggle]').click()
cy.cs('study-right-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
it('years in the tables can be clicked to show faculty level breakdown', () => {
cy.get('[data-cy=StudyRightToggle]').click()
cy.get('[data-cy=FacultyBachelorsProgressTableShowButton3]').click()
// 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 })
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
@@ -66,16 +67,16 @@ describe('University view', () => {
})
it('info boxes contain correct information', () => {
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan kuuluvien')
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
cy.cs('faculty-progress-info-box-button').click()
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
})
})
describe('Faculty graduations tab', () => {
beforeEach(() => {
cy.get('[data-cy=FacultyGraduationsTab]').click()
cy.cs('faculty-graduations-tab').click()
})
it('has all the correct median time bar charts', () => {
@@ -83,13 +84,13 @@ describe('University view', () => {
})
it("'Breakdown/Median times' toggle works", () => {
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.cs('graduation-time-toggle').click()
checkAverageGraduationTimesMedianBarCharts()
})
it('info boxes contain correct information', () => {
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset valmistumisajat')
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
})
+17 -12
View File
@@ -9,7 +9,7 @@ visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Progra
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const containsLinks = links => {
cy.get('[data-cy=nav-bar]').within(() => {
cy.cs('nav-bar').within(() => {
for (const link of links) {
cy.contains(link)
}
@@ -17,7 +17,7 @@ const containsLinks = links => {
}
const userButtonWorks = (username, mocking = false) => {
cy.get('[data-cy=nav-bar-user-button]').click()
cy.cs('nav-bar-user-button').click()
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
cy.contains('Language')
cy.contains('suomi')
@@ -61,10 +61,10 @@ describe('Users tests', () => {
userButtonWorks('admin')
})
describe('can mock as other users', () => {
describe('can mock as other users', { retries: 3 }, () => {
beforeEach(() => {
cy.cs('user-edit-button-basic').click()
cy.get('i.spy').click()
cy.cs('user-page-button-basic').click()
cy.cs('mock-button').click()
})
it('user button shows mocked user', () => {
@@ -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
+60 -44
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,31 +107,63 @@ 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) => {
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
correctStats.forEach((values, trIndex) => {
cy.get('tr')
.filter(':visible')
@@ -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
+2 -16
View File
@@ -118,18 +118,15 @@ services:
- redis
- sis-db
- sis-importer-db
- sis-updater-nats
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
profiles:
- updater
@@ -141,28 +138,17 @@ services:
depends_on:
- redis
- sis-importer-db
- sis-updater-nats
environment:
CI: 'true'
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
ports:
- 8082:8082
profiles:
- updater
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
profiles:
- updater
+11 -19
View File
@@ -49,9 +49,10 @@ services:
ports:
- 8080:8080
volumes:
- ./environments/ha:/opt/app-root/src/src/environment
- ./services/backend/src:/opt/app-root/src/src
- ./services/shared/:/opt/app-root/src/src/shared
- backend-nodemod:/opt/app-root/backend/node_modules
- ./environments/testu:/opt/app-root/backend/src/environment
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
@@ -66,8 +67,9 @@ services:
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
volumes:
- ./services/frontend/src/:/opt/app-root/src/src
- ./services/shared/:/opt/app-root/src/src/shared
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
@@ -106,12 +108,6 @@ services:
ports:
- 12345:5432
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
sis-updater-scheduler:
<<: *defaults
build:
@@ -122,17 +118,14 @@ services:
- 'host.docker.internal:host-gateway'
depends_on:
- redis
- sis-updater-nats
environment:
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
SERVICE_PROVIDER: fd
ports:
- 8082:8082
@@ -150,20 +143,17 @@ services:
depends_on:
- redis
- sis-db
- sis-updater-nats
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
ROOT_ORG_ID: ${ROOT_ORG_ID}
SERVICE_PROVIDER: fd
volumes:
@@ -183,6 +173,8 @@ services:
- 12347:5432
volumes:
backend-nodemod:
frontend-nodemod:
kone-db-data:
redis-data:
sis-db-data:
+2 -2
View File
@@ -1,5 +1,5 @@
services:
frontend:
volumes:
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/instrumented:/opt/app-root/src/src
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/instrumented:/opt/app-root/frontend/src
+8 -22
View File
@@ -44,9 +44,9 @@ services:
profiles:
- oodikone
volumes:
- backend-nodemod:/opt/app-root/src/node_modules
- ./services/backend/:/opt/app-root/src
- ./services/shared/:/opt/app-root/src/src/shared
- backend-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
@@ -59,9 +59,9 @@ services:
profiles:
- oodikone
volumes:
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/:/opt/app-root/src
- ./services/shared/:/opt/app-root/src/src/shared
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
@@ -103,14 +103,6 @@ services:
ports:
- 12346:5432
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
profiles:
- updater
sis-updater-scheduler:
<<: *defaults
build:
@@ -120,16 +112,13 @@ services:
depends_on:
- redis
- sis-importer-db
- sis-updater-nats
environment:
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
ports:
- 8082:8082
profiles:
@@ -147,17 +136,14 @@ services:
- redis
- sis-db
- sis-importer-db
- sis-updater-nats
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
profiles:
- updater
volumes:
+12 -2
View File
@@ -50,6 +50,17 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
### Reverting a migration locally
If you need to **revert a migration locally**, follow these steps:
1. Locate the `migrator.up()` function in the code:
- For `user-db` and `kone-db`: [`services/backend/src/database/connection.ts`](../services/backend/src/database/connection.ts)
- For `sis-db`: [`updater/sis-updater-worker/src/db/connection.js`](../updater/sis-updater-worker/src/db/connection.js)
1. Change `migrator.up()` to `migrator.down()` and save the file. The latest migration will be reverted automatically when the server reloads.
1. **Important:** After the migration is reverted, change `migrator.down()` back to `migrator.up()` to ensure future migrations run correctly.
1. For more instructions, see the [umzug documentation](https://github.com/sequelize/umzug?tab=readme-ov-file#usage).
### Deploying changes to production
- **Push changes** to the `master` branch and wait for the workflow to finish.
@@ -113,5 +124,4 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
1. **Push to master and release:**
- Wait for the CI workflow to pass.
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migration.
- Optionally, use `db-cli` to check the production database state (see `how_to_db_cli.md` in GitLab).
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
+1
View File
@@ -44,6 +44,7 @@ A collection of useful tools, documentation and other resources.
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
- [Material Icons](https://mui.com/material-ui/material-icons/)
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
+208 -136
View File
@@ -1,184 +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-return-await': 'error',
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
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',
'no-return-await': 'off',
},
},
{
settings: {
'import/internal-regex': '^@/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 = {
+3094 -3076
View File
File diff suppressed because it is too large Load Diff
+24 -22
View File
@@ -23,10 +23,13 @@
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
"flushredis": "docker exec -it redis redis-cli flushall",
"prepare": "husky",
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/src/node_modules/.bin/tsx /opt/app-root/src/src/rapodiff/rapodiff.js",
"install:local": "npm i && cd services/frontend && npm i && cd ../backend && npm i && cd ../../updater/sis-updater-scheduler && npm i && cd ../sis-updater-worker && npm i && cd ../..",
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
"install:both": "npm run install:local && npm run install:docker"
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
"type": "git",
@@ -38,30 +41,29 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.11",
"@eslint/js": "^9.19.0",
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^14.0.1",
"eslint": "^9.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-cypress": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"cypress": "^14.4.1",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.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",
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"lint-staged": "^16.1.0",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.4.2",
"stylelint": "^16.14.1",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.22.0"
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.30.1"
"lodash": "^4.17.21"
}
}
+69 -45
View File
@@ -9,7 +9,8 @@
## Folders
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
USER_DATA_FILE="$DUMP_DIR/hyuserdata"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
@@ -18,14 +19,16 @@ KONE_DB_NAME="kone-db"
SIS_DB_NAME="sis-db"
SIS_IMPORTER_DB_NAME="sis-importer-db"
USER_DB_NAME="user-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME")
JAMI_DB_NAME="jami-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME" "$JAMI_DB_NAME")
## Urls should be in same order as databases as both are iterated through by indexes.
KONE_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-kone-db.sql.gz"
SIS_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-sis-db.sql.gz"
SIS_IMPORTER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/importer.sql.gz"
USER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-user-db.sql.gz"
REAL_DUMP_URLS=("$KONE_DB_REAL_DUMP_URL" "$SIS_DB_REAL_DUMP_URL" "$SIS_IMPORTER_DB_REAL_DUMP_URL" "$USER_DB_REAL_DUMP_URL")
KONE_DB_S3_PATH="oodikone_kone"
SIS_DB_S3_PATH="oodikone_updater"
SIS_IMPORTER_DB_S3_PATH="sis_importer"
USER_DB_S3_PATH="oodikone_user"
JAMI_DB_S3_PATH="jami"
S3_PATHS=("$KONE_DB_S3_PATH" "$SIS_DB_S3_PATH" "$SIS_IMPORTER_DB_S3_PATH" "$USER_DB_S3_PATH" "$JAMI_DB_S3_PATH")
# Source utility functions
source "$PROJECT_ROOT"/scripts/utils.sh
@@ -55,9 +58,46 @@ retry() {
download_real_dump() {
local database=$1
local pannu_url=$2
local dump_destination="$DUMP_DIR/$database.sql.gz"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
local s3_path=$2
local selection_flag=$3
rm -f "$dump_destination"
if [ "$selection_flag" = "true" ]; then
local backup_files
mapfile -t backup_files < <(s3cmd -c "$S3_CONFIG_FILE" ls "$S3_BUCKET/$s3_path/" | sort -rk1,2 | awk '{print $4}')
if [ ${#backup_files[@]} -eq 0 ]; then
die "No backup files found in $S3_BUCKET bucket $s3_path path!"
fi
infomsg "Available backups:"
PS3="Select backup (or 0 to go back): "
select chosen_backup in "${backup_files[@]}"; do
if [ "$REPLY" = "0" ] || [ "$chosen_backup" = "Go back" ]; then
infomsg "Going back to previous menu."
break
elif [ -n "$chosen_backup" ]; then
infomsg "You selected: $chosen_backup"
local FILE_NAME
FILE_NAME=$(basename "$chosen_backup")
infomsg "Fetching the selected dump: $FILE_NAME"
s3cmd -c "$S3_CONFIG_FILE" get "$chosen_backup" "$dump_destination"
if [ ! -f "$dump_destination" ]; then
die "Download failed or file not found: $dump_destination"
fi
echo "$dump_destination"
break
else
warningmsg "Invalid selection. Please select a valid backup number."
fi
done
else
local current_date
current_date=$(date +"%Y%m%d")
s3cmd -c "$S3_CONFIG_FILE" get "s3://psyduck/${s3_path}/${s3_path}_${current_date}.sql.gz" "$dump_destination"
fi
}
check_if_postgres_is_ready() {
@@ -101,11 +141,12 @@ reset_databases() {
reset_jami_data() {
local database="jami-db"
local pannu_url="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/jami.sql.gz"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Downloading Jami data"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
infomsg "Removing database and related volume"
docker volume rm oodikone_jami-data || warningmsg "This is okay, continuing"
@@ -122,13 +163,12 @@ reset_jami_data() {
}
reset_all_real_data() {
infomsg "Downloading real data dumps, asking for pannu password when needed"
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
local url="${REAL_DUMP_URLS[$i]}"
download_real_dump "$database" "$url"
local url="${S3_PATHS[$i]}"
download_real_dump "$database" "$url" false
done
reset_jami_data
reset_databases "${DATABASES[@]}"
}
@@ -152,28 +192,30 @@ reset_single_database() {
local database=""
local url=""
local selection_flag=true
while true; do
select opt in "${options[@]}"; do
case $opt in
"kone-db")
local database=$KONE_DB_NAME
local url=$KONE_DB_REAL_DUMP_URL
local url=$KONE_DB_S3_PATH
break 2;;
"sis-db")
local database=$SIS_DB_NAME
local url=$SIS_DB_REAL_DUMP_URL
local url=$SIS_DB_S3_PATH
break 2;;
"sis-importer-db")
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
local url=$SIS_IMPORTER_DB_S3_PATH
break 2;;
"user-db")
local database=$USER_DB_NAME
local url=$USER_DB_REAL_DUMP_URL
local url=$USER_DB_S3_PATH
break 2;;
"jami-db")
reset_jami_data
local database=$JAMI_DB_NAME
local url=$JAMI_DB_S3_PATH
break 2;;
"Return to main menu.")
break 2;;
@@ -186,7 +228,7 @@ reset_single_database() {
if [ -n "$database" ]; then
infomsg "Downloading $database dump"
download_real_dump $database $url
download_real_dump $database $url $selection_flag
reset_databases $database
fi
}
@@ -225,21 +267,6 @@ set_up_oodikone_from_scratch() {
infomsg "Building images"
"$PROJECT_ROOT"/run.sh both anon build
TARGET_PATH="$PROJECT_ROOT/services/shared"
LINK_PATHS=(
"$PROJECT_ROOT/services/backend/src/shared"
"$PROJECT_ROOT/services/frontend/src/shared"
)
for SYMLINK_PATH in "${LINK_PATHS[@]}"; do
infomsg "Linking $TARGET_PATH to $SYMLINK_PATH"
if [ ! -L "$SYMLINK_PATH" ]; then
ln -s "$TARGET_PATH" "$SYMLINK_PATH"
else
infomsg "Symbolic link $SYMLINK_PATH already exists. Skipping creation."
fi
done
successmsg "Setup ready, Oodikone can be started! See README for more info."
}
@@ -257,14 +284,11 @@ init_dirs() {
# If username is not set, get username from data file.
# Ask user to provide username, if username was not found from data file.
get_username() {
if [ ! -f "$USER_DATA_FILE" ]; then
warningmsg "University username is needed to get database dumps from toska servers, please enter it now:"
read -r username
echo "$username" > "$USER_DATA_FILE"
successmsg "Succesfully saved username for later usage."
get_s3_config() {
if [ ! -f "$S3_CONFIG_FILE" ]; then
warningmsg "You have to set up s3 config for path ~/.s3cfg accessing the database dumps, config is visibe at the toska/dokumentaatio repo"
return 0
fi
username=$(head -n 1 < "$USER_DATA_FILE")
infomsg "Using your university username - $username - for getting database dumps"
infomsg "Using your config $S3_CONFIG_FILE - for getting database dumps"
}
+15 -13
View File
@@ -2,8 +2,6 @@ FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=4096
@@ -19,23 +17,22 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY ./backend/package*.json ./
RUN npm ci --include=dev
COPY ./shared ./src/shared
WORKDIR /opt/app-root/shared
COPY ./shared .
WORKDIR /opt/app-root/backend
COPY ./backend .
RUN npm run build
WORKDIR /opt/app-root
RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backend
RUN npm run build --prefix shared && npm run build --prefix backend
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=4096
@@ -51,9 +48,14 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY --from=build /opt/app-root/src/package*.json ./
COPY --from=build /opt/app-root/src/dist ./dist
WORKDIR /opt/app-root/shared
COPY --from=build /opt/app-root/shared/dist .
RUN npm ci
WORKDIR /opt/app-root/backend
COPY --from=build /opt/app-root/backend/package*.json .
COPY --from=build /opt/app-root/backend/dist ./dist
RUN npm ci --omit=dev
EXPOSE 8080
CMD ["node", "dist/index.js"]
+3 -6
View File
@@ -2,15 +2,12 @@ FROM node:22-alpine
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
COPY ./package* ./
RUN npm ci
WORKDIR /opt/app-root/backend
COPY . .
RUN npm ci
EXPOSE 8080
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
@@ -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 })
+1305 -919
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -5,41 +5,42 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.53.0",
"@sentry/profiling-node": "^8.53.0",
"axios": "^1.7.9",
"bullmq": "^5.39.1",
"compression": "^1.7.5",
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^3.5.0",
"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.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.2",
"pg": "^8.13.1",
"redis": "^4.7.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2"
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.15",
"@types/morgan": "^1.9.9",
"@types/node": "^22.12.0",
"@types/validator": "^13.12.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.17",
"@types/morgan": "^1.9.10",
"@types/node": "^22.15.31",
"@types/validator": "^13.15.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"overrides": {
"winston-gelf-transporter": {
@@ -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')
-103
View File
@@ -1,103 +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 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,
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',
+56 -56
View File
@@ -2,38 +2,38 @@ import EventEmitter from 'events'
import { Sequelize } from 'sequelize-typescript'
import { SequelizeStorage, Umzug } from 'umzug'
import conf from '../config'
import * as conf from '../config'
import {
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
CourseModel,
CourseProviderModel,
CourseTypeModel,
CreditModel,
CreditTeacherModel,
CreditTypeModel,
CurriculumPeriodModel,
EnrollmentModel,
OrganizationModel,
ProgrammeModuleModel,
ProgrammeModuleChildModel,
SemesterModel,
SISStudyRightModel,
SISStudyRightElementModel,
StudentModel,
StudyplanModel,
StudyrightExtentModel,
TeacherModel,
} from '../models'
import {
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
ProgressCriteriaModel,
StudyGuidanceGroupTagModel,
StudyProgrammePinModel,
TagModel,
TagStudentModel,
} from '../models/kone'
import { User } from '../models/user'
import { UserModel } from '../models/user'
import logger from '../util/logger'
@@ -60,24 +60,24 @@ class DbConnection extends EventEmitter {
logging: false,
password: conf.SIS_PASSWORD,
models: [
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
CourseModel,
CourseProviderModel,
CourseTypeModel,
CreditModel,
CreditTeacherModel,
CreditTypeModel,
CurriculumPeriodModel,
EnrollmentModel,
OrganizationModel,
ProgrammeModuleModel,
ProgrammeModuleChildModel,
SemesterModel,
SISStudyRightModel,
SISStudyRightElementModel,
StudentModel,
StudyplanModel,
StudyrightExtentModel,
TeacherModel,
],
})
}
@@ -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)
}
}
}
@@ -107,14 +107,14 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
logging: false,
password: conf.KONE_PASSWORD,
models: [
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
ProgressCriteriaModel,
StudyGuidanceGroupTagModel,
StudyProgrammePinModel,
TagModel,
TagStudentModel,
],
})
@@ -123,7 +123,7 @@ void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
logging: false,
password: conf.USER_PASSWORD,
models: [User],
models: [UserModel],
})
const initializeDatabaseConnection = async () => {
+9 -8
View File
@@ -1,6 +1,6 @@
import { CronJob } from 'cron'
import { isProduction, runningInCI, languageCenterViewEnabled } from './config'
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
import { getFaculties } from './services/faculty/facultyHelpers'
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
export const refreshProgramme = async (code: string) => {
await updateBasicView(code, '')
await updateStudyTrackView(code, '')
const combinedProgramme = combinedStudyProgrammes[code] || ''
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
await updateBasicView(code, combinedProgramme)
await updateStudyTrackView(code, combinedProgramme)
}
@@ -105,15 +105,16 @@ const dailyJobs = async () => {
export const startCron = () => {
if (isProduction && !runningInCI) {
logger.info('Cronjob for refreshing stats started: runs daily at 23:00')
schedule('0 23 * * *', async () => {
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
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')
+2 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/consistent-return */
import { NextFunction, Request, Response } from 'express'
import { Role } from '../shared/types'
import { Role } from '@oodikone/shared/types'
export const roles =
(requiredRoles: Role[], requiredIamGroups?: string[]) => (req: Request, res: Response, next: NextFunction) => {
@@ -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 }
)
+33 -12
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -12,29 +11,45 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { ExtentCode, SemesterEnrollment } from '../types'
import { Credit, Enrollment, Organization, SISStudyRightElement, Student, Studyplan, StudyrightExtent } from '.'
import type {
Credit,
Enrollment,
Organization,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
} from '@oodikone/shared/models'
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { OrganizationModel } from './organization'
import { SISStudyRightElementModel } from './SISStudyRightElement'
import { StudentModel } from './student'
import { StudyplanModel } from './studyplan'
import { StudyrightExtentModel } from './studyrightExtent'
@Table({
underscored: true,
tableName: 'sis_study_rights',
})
export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
facultyCode!: string
@BelongsTo(() => Organization, { foreignKey: 'facultyCode', targetKey: 'code' })
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
organization!: Organization
@HasMany(() => Studyplan)
@HasMany(() => StudyplanModel)
studyPlans!: Studyplan[]
@HasMany(() => SISStudyRightElement, { foreignKey: 'studyRightId' })
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
studyRightElements!: SISStudyRightElement[]
@Column(DataType.DATE)
@@ -52,10 +67,10 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
@Column(DataType.STRING)
studentNumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
student!: Student
@ForeignKey(() => StudyrightExtent)
@ForeignKey(() => StudyrightExtentModel)
@Column(DataType.INTEGER)
extentCode!: ExtentCode
@@ -65,12 +80,18 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
@Column(DataType.JSONB)
semesterEnrollments!: SemesterEnrollment[] | null
@HasMany(() => Credit, { foreignKey: 'studyright_id', sourceKey: 'id' })
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
credits!: Credit[]
@HasMany(() => Enrollment, { foreignKey: 'studyright_id', sourceKey: 'id' })
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
enrollments!: Enrollment[]
@Column(DataType.BOOLEAN)
tvex!: boolean
@Column(DataType.ARRAY(DataType.STRING))
expirationRuleUrns!: string[]
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,15 +10,17 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { DegreeProgrammeType, Phase, StudyTrack } from '../types'
import { SISStudyRight } from './SISStudyRight'
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import type { Name, StudyTrack } from '@oodikone/shared/types'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
tableName: 'sis_study_right_elements',
})
export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightElement>> {
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -36,11 +37,11 @@ export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightEle
@Column(DataType.INTEGER)
phase!: Phase
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyRightId!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyRightId' })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
studyRight!: SISStudyRight
@Column(DataType.STRING)
+18 -26
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -13,41 +12,43 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { CourseProvider } from './courseProvider'
import { CourseType } from './courseType'
import { Credit } from './credit'
import { Enrollment } from './enrollment'
import { Organization } from './organization'
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { CourseProviderModel } from './courseProvider'
import { CourseTypeModel } from './courseType'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { OrganizationModel } from './organization'
@Table({
underscored: false,
modelName: 'course',
tableName: 'course',
})
export class Course extends Model<InferAttributes<Course>> {
export class CourseModel extends Model<Course> implements Course {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
code!: string
@ForeignKey(() => CourseType)
@ForeignKey(() => CourseTypeModel)
@Column(DataType.STRING)
coursetypecode!: string
@BelongsTo(() => CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
courseType!: CourseType
@HasMany(() => Credit, { foreignKey: 'course_id', sourceKey: 'id' })
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
credits!: Credit[]
@HasMany(() => Enrollment, { foreignKey: 'course_id' })
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
enrollments!: Enrollment[]
@BelongsToMany(() => Organization, () => CourseProvider, 'coursecode')
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
organizations!: Organization[]
@Column(DataType.BOOLEAN)
@@ -63,17 +64,11 @@ export class Course extends Model<InferAttributes<Course>> {
min_attainment_date!: Date
@CreatedAt
@Column({
field: 'created_at',
type: DataType.DATE,
})
@Column({ field: 'created_at', type: DataType.DATE })
createdAt!: Date
@UpdatedAt
@Column({
field: 'updated_at',
type: DataType.DATE,
})
@Column({ field: 'updated_at', type: DataType.DATE })
updatedAt!: Date
@Column(DataType.JSONB)
@@ -82,9 +77,6 @@ export class Course extends Model<InferAttributes<Course>> {
@Column(DataType.STRING)
course_unit_type!: string
@Column({
field: 'main_course_code',
type: DataType.STRING,
})
@Column({ field: 'main_course_code', type: DataType.STRING })
mainCourseCode!: string
}
@@ -1,23 +1,24 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Course } from './course'
import { Organization } from './organization'
import type { CourseProvider } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { OrganizationModel } from './organization'
@Table({
underscored: true,
modelName: 'course_provider',
tableName: 'course_providers',
})
export class CourseProvider extends Model<InferAttributes<CourseProvider>> {
@ForeignKey(() => Course)
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
coursecode!: string
@Column(DataType.JSONB)
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organizationcode!: string
+6 -5
View File
@@ -1,20 +1,21 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { Course } from './course'
import type { Course, CourseType } from '@oodikone/shared/models'
import { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
@Table({
underscored: true,
modelName: 'course_type',
tableName: 'course_types',
})
export class CourseType extends Model<InferAttributes<CourseType>> {
export class CourseTypeModel extends Model<CourseType> implements CourseType {
@PrimaryKey
@Column(DataType.STRING)
coursetypecode!: string
@HasMany(() => Course)
@HasMany(() => CourseModel)
courses!: Course[]
@Column(DataType.JSONB)
+26 -14
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -12,15 +11,23 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { CreditTypeCode } from '../types'
import { Course, CreditTeacher, CreditType, Semester, SISStudyRight, Student, Teacher } from '.'
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
import { CreditTypeCode } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { CreditTeacherModel } from './creditTeacher'
import { CreditTypeModel } from './creditType'
import { SemesterModel } from './semester'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
import { TeacherModel } from './teacher'
@Table({
underscored: false,
modelName: 'credit',
tableName: 'credit',
})
export class Credit extends Model<InferAttributes<Credit>> {
export class CreditModel extends Model<Credit> implements Credit {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -28,24 +35,24 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.STRING)
grade!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
student_studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
student!: Student
@BelongsTo(() => Semester, { foreignKey: 'semester_composite' })
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
semester!: Semester
@Column(DataType.DOUBLE)
credits!: number
@ForeignKey(() => CreditType)
@ForeignKey(() => CreditTypeModel)
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
@BelongsTo(() => CreditType, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
creditType!: CreditType
@Column(DataType.DATE)
@@ -54,16 +61,16 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.DATE)
attainment_date!: Date
@BelongsToMany(() => Teacher, () => CreditTeacher, 'credit_id')
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
teachers!: Teacher[]
@Column(DataType.STRING)
course_code!: string
@BelongsTo(() => Course, { foreignKey: 'course_id' })
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
course!: Course
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
@@ -85,11 +92,11 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.BOOLEAN)
is_open!: boolean
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyright_id!: string
@BelongsTo(() => SISStudyRight)
@BelongsTo(() => SISStudyRightModel)
studyright!: SISStudyRight
@CreatedAt
@@ -108,3 +115,8 @@ export class Credit extends Model<InferAttributes<Credit>> {
static notUnnecessary: ({ credits }: { credits: number }) => boolean
}
CreditModel.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
CreditModel.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
CreditModel.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
CreditModel.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
+7 -6
View File
@@ -1,20 +1,21 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Credit } from './credit'
import { Teacher } from './teacher'
import type { CreditTeacher } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { TeacherModel } from './teacher'
@Table({
underscored: false,
modelName: 'credit_teacher',
tableName: 'credit_teachers',
})
export class CreditTeacher extends Model<InferAttributes<CreditTeacher>> {
@ForeignKey(() => Credit)
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
@ForeignKey(() => CreditModel)
@Column(DataType.STRING)
credit_id!: string
@ForeignKey(() => Teacher)
@ForeignKey(() => TeacherModel)
@Column(DataType.STRING)
teacher_id!: string
+3 -4
View File
@@ -1,15 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { CreditTypeCode } from '../types'
import type { CreditType } from '@oodikone/shared/models'
import { CreditTypeCode, Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'credit_type',
tableName: 'credit_types',
})
export class CreditType extends Model<InferAttributes<CreditType>> {
export class CreditTypeModel extends Model<CreditType> implements CreditType {
@PrimaryKey
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import type { CurriculumPeriod } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'curriculum_period',
tableName: 'curriculum_periods',
})
export class CurriculumPeriod extends Model<InferAttributes<CurriculumPeriod>> {
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
@Column({ type: DataType.STRING, primaryKey: true })
id!: string
+17 -13
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,24 +10,29 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { EnrollmentState } from '../types'
import { Course, Semester, SISStudyRight, Student } from '.'
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
import { EnrollmentState } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { SemesterModel } from './semester'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@Table({
underscored: false,
modelName: 'enrollment',
tableName: 'enrollment',
})
export class Enrollment extends Model<InferAttributes<Enrollment>> {
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
@Column(DataType.STRING)
@@ -40,15 +44,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
@Column(DataType.DATE)
enrollment_date_time!: Date
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
@ForeignKey(() => Semester)
@ForeignKey(() => SemesterModel)
@Column(DataType.STRING)
semester_composite!: string
@BelongsTo(() => Semester, { foreignKey: 'semester_composite', targetKey: 'composite' })
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
semester!: Semester
@Column(DataType.INTEGER)
@@ -57,15 +61,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
@Column(DataType.BOOLEAN)
is_open!: boolean
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column({ type: DataType.STRING, allowNull: true })
studyright_id!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
studyright!: SISStudyRight
@BelongsTo(() => Course, { foreignKey: 'course_id', targetKey: 'id' })
course!: Course
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
course!: CourseModel
@CreatedAt
@Column(DataType.DATE)
+18 -45
View File
@@ -1,45 +1,18 @@
import { CreditTypeCode } from '../types'
import { Course } from './course'
import { CourseProvider } from './courseProvider'
import { CourseType } from './courseType'
import { Credit } from './credit'
import { CreditTeacher } from './creditTeacher'
import { CreditType } from './creditType'
import { CurriculumPeriod } from './curriculumPeriod'
import { Enrollment } from './enrollment'
import { Organization } from './organization'
import { ProgrammeModule } from './programmeModule'
import { ProgrammeModuleChild } from './programmeModuleChild'
import { Semester } from './semester'
import { SISStudyRight } from './SISStudyRight'
import { SISStudyRightElement } from './SISStudyRightElement'
import { Student } from './student'
import { Studyplan } from './studyplan'
import { StudyrightExtent } from './studyrightExtent'
import { Teacher } from './teacher'
Credit.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
Credit.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
Credit.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
Credit.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
export {
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
}
export { CourseModel } from './course'
export { CourseProviderModel } from './courseProvider'
export { CourseTypeModel } from './courseType'
export { CreditModel } from './credit'
export { CreditTeacherModel } from './creditTeacher'
export { CreditTypeModel } from './creditType'
export { CurriculumPeriodModel } from './curriculumPeriod'
export { EnrollmentModel } from './enrollment'
export { OrganizationModel } from './organization'
export { ProgrammeModuleModel } from './programmeModule'
export { ProgrammeModuleChildModel } from './programmeModuleChild'
export { SemesterModel } from './semester'
export { SISStudyRightModel } from './SISStudyRight'
export { SISStudyRightElementModel } from './SISStudyRightElement'
export { StudentModel } from './student'
export { StudyplanModel } from './studyplan'
export { StudyrightExtentModel } from './studyrightExtent'
export { TeacherModel } from './teacher'
@@ -1,15 +1,17 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: false,
modelName: 'custom_population_search',
tableName: 'custom_population_searches',
})
export class CustomPopulationSearch extends Model<
InferAttributes<CustomPopulationSearch>,
InferCreationAttributes<CustomPopulationSearch>
> {
export class CustomPopulationSearchModel
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
implements CustomPopulationSearch
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,12 +1,14 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'excluded_course',
tableName: 'excluded_courses',
})
export class ExcludedCourse extends Model<InferAttributes<ExcludedCourse>, InferCreationAttributes<ExcludedCourse>> {
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
+8 -19
View File
@@ -1,19 +1,8 @@
import { CustomPopulationSearch } from './customPopulationSearch'
import { ExcludedCourse } from './excludedCourse'
import { OpenUniPopulationSearch } from './openUniPopulationSearch'
import { ProgressCriteria } from './progressCriteria'
import { StudyGuidanceGroupTag } from './studyGuidanceGroupTag'
import { StudyProgrammePin } from './studyProgrammePin'
import { Tag } from './tag'
import { TagStudent } from './tagStudent'
export {
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
}
export { CustomPopulationSearchModel } from './customPopulationSearch'
export { ExcludedCourseModel } from './excludedCourse'
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
export { ProgressCriteriaModel } from './progressCriteria'
export { StudyGuidanceGroupTagModel } from './studyGuidanceGroupTag'
export { StudyProgrammePinModel } from './studyProgrammePin'
export { TagModel } from './tag'
export { TagStudentModel } from './tagStudent'
@@ -1,16 +1,18 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'open_uni_population_search',
tableName: 'open_uni_population_searches',
})
export class OpenUniPopulationSearch extends Model<
InferAttributes<OpenUniPopulationSearch>,
InferCreationAttributes<OpenUniPopulationSearch>
> {
export class OpenUniPopulationSearchModel
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
implements OpenUniPopulationSearch
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,16 +1,18 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'progress_criteria',
tableName: 'progress_criteria',
})
export class ProgressCriteria extends Model<
InferAttributes<ProgressCriteria>,
InferCreationAttributes<ProgressCriteria>
> {
export class ProgressCriteriaModel
extends Model<ProgressCriteria, ProgressCriteriaCreation>
implements ProgressCriteria
{
@PrimaryKey
@Column(DataType.STRING)
code!: string
@@ -1,13 +1,18 @@
import { CreationOptional, InferAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'study_guidance_group_tag',
tableName: 'study_guidance_group_tags',
})
export class StudyGuidanceGroupTag extends Model<InferAttributes<StudyGuidanceGroupTag>> {
export class StudyGuidanceGroupTagModel
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
implements StudyGuidanceGroupTag
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,13 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: false,
modelName: 'study_programme_pin',
tableName: 'study_programme_pins',
})
export class StudyProgrammePin extends Model<InferAttributes<StudyProgrammePin>> {
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
@PrimaryKey
@Column(DataType.INTEGER)
userId!: number
+7 -5
View File
@@ -1,20 +1,22 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import { TagStudent } from './tagStudent'
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
import { TagStudentModel } from './tagStudent'
@Table({
modelName: 'tag',
tableName: 'tag',
})
export class Tag extends Model<InferAttributes<Tag>, InferCreationAttributes<Tag>> {
export class TagModel extends Model<Tag, TagCreation> implements Tag {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagStudent)
@ForeignKey(() => TagStudentModel)
@Column(DataType.BIGINT)
tag_id!: CreationOptional<string>
@BelongsTo(() => TagStudent)
@BelongsTo(() => TagStudentModel)
tagStudent!: CreationOptional<TagStudent>
@Column(DataType.STRING)
@@ -1,25 +1,27 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import { Student } from '../student'
import { Tag } from './tag'
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
import { StudentModel } from '../student'
import { TagModel } from './tag'
@Table({
modelName: 'tag_student',
tableName: 'tag_student',
})
export class TagStudent extends Model<InferAttributes<TagStudent>, InferCreationAttributes<TagStudent>> {
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => Tag)
@ForeignKey(() => TagModel)
@Column(DataType.BIGINT)
tag_id!: string
@PrimaryKey
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Tag)
@BelongsTo(() => TagModel)
tag!: CreationOptional<Tag>
}
+13 -9
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsToMany,
Column,
@@ -11,15 +10,20 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { Course, CourseProvider, ProgrammeModule, SISStudyRight } from '.'
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { CourseProviderModel } from './courseProvider'
import { ProgrammeModuleModel } from './programmeModule'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
modelName: 'organization',
tableName: 'organization',
})
export class Organization extends Model<InferAttributes<Organization>> {
export class OrganizationModel extends Model<Organization> implements Organization {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -33,17 +37,17 @@ export class Organization extends Model<InferAttributes<Organization>> {
@Column(DataType.STRING)
parent_id!: string
@HasMany(() => Organization, { foreignKey: 'parent_id', as: 'children' })
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
children!: Organization[]
@HasMany(() => ProgrammeModule, { foreignKey: 'organization_id' })
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
programmeModules!: ProgrammeModule[]
@HasMany(() => SISStudyRight, { foreignKey: 'facultyCode', sourceKey: 'code' })
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
SISStudyRights!: SISStudyRight[]
@BelongsToMany(() => Course, () => CourseProvider, 'organizationcode')
courses!: Course[]
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
courses!: CourseModel[]
@CreatedAt
@Column(DataType.DATE)
+10 -10
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -12,25 +11,26 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { DegreeProgrammeType } from '../types'
import { Organization } from './organization'
import { ProgrammeModuleChild } from './programmeModuleChild'
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
import { OrganizationModel } from './organization'
import { ProgrammeModuleChildModel } from './programmeModuleChild'
@Table({
underscored: true,
modelName: 'programme_module',
tableName: 'programme_modules',
})
export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'child_id')
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
parents!: ProgrammeModule[]
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'parent_id')
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
children!: ProgrammeModule[]
@Column(DataType.STRING)
@@ -51,11 +51,11 @@ export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
@Column(DataType.STRING)
studyLevel!: string
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organization_id!: string
@BelongsTo(() => Organization, { foreignKey: 'organization_id' })
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
organization!: Organization
@Column(DataType.DATE)
@@ -1,19 +1,20 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { ProgrammeModule } from './programmeModule'
import type { ProgrammeModuleChild } from '@oodikone/shared/models'
import { ProgrammeModuleModel } from './programmeModule'
@Table({
underscored: true,
modelName: 'programme_module_child',
tableName: 'programme_module_children',
})
export class ProgrammeModuleChild extends Model<InferAttributes<ProgrammeModuleChild>> {
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
@ForeignKey(() => ProgrammeModule)
@ForeignKey(() => ProgrammeModuleModel)
@Column(DataType.STRING)
parentId!: string
+3 -3
View File
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import type { Semester } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'semester',
tableName: 'semesters',
})
export class Semester extends Model<InferAttributes<Semester>> {
export class SemesterModel extends Model<Semester> implements Semester {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
+13 -9
View File
@@ -1,16 +1,20 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { GenderCode } from '../types'
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { GenderCode } from '@oodikone/shared/types'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { SISStudyRightModel } from './SISStudyRight'
import { StudyplanModel } from './studyplan'
@Table({
underscored: true,
modelName: 'student',
tableName: 'student',
})
export class Student extends Model<InferAttributes<Student>> {
export class StudentModel extends Model<Student> implements Student {
@PrimaryKey
@Column(DataType.STRING)
studentnumber!: string
@@ -24,16 +28,16 @@ export class Student extends Model<InferAttributes<Student>> {
@Column(DataType.STRING)
abbreviatedname!: string
@HasMany(() => Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
enrollments!: Enrollment[]
@HasMany(() => SISStudyRight, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
studyRights!: SISStudyRight[]
@HasMany(() => Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
studyplans!: Studyplan[]
@HasMany(() => Credit, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
credits!: Credit[]
@Column(DataType.DATE)
+12 -7
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,30 +10,33 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Student, SISStudyRight } from '.'
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@Table({
underscored: false,
modelName: 'studyplan',
tableName: 'studyplan',
})
export class Studyplan extends Model<InferAttributes<Studyplan>> {
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
sis_study_right_id!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
studyRight!: SISStudyRight
@Column(DataType.STRING)
@@ -43,6 +45,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
@Column(DataType.ARRAY(DataType.STRING))
included_courses!: string[]
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
includedModules!: string[]
@Column(DataType.STRING)
sisu_id!: string
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { ExtentCode } from '../types'
import type { StudyrightExtent } from '@oodikone/shared/models'
import { ExtentCode } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'studyright_extent',
tableName: 'studyright_extents',
})
export class StudyrightExtent extends Model<InferAttributes<StudyrightExtent>> {
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
@PrimaryKey
@Column(DataType.INTEGER)
extentcode!: ExtentCode
+6 -5
View File
@@ -1,15 +1,16 @@
import { InferAttributes } from 'sequelize'
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Credit } from './credit'
import { CreditTeacher } from './creditTeacher'
import type { Teacher, Credit } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { CreditTeacherModel } from './creditTeacher'
@Table({
underscored: true,
modelName: 'teacher',
tableName: 'teacher',
})
export class Teacher extends Model<InferAttributes<Teacher>> {
export class TeacherModel extends Model<Teacher> implements Teacher {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -17,7 +18,7 @@ export class Teacher extends Model<InferAttributes<Teacher>> {
@Column(DataType.STRING)
name!: string
@BelongsToMany(() => Credit, () => CreditTeacher)
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
credits!: Credit[]
@CreatedAt
+6 -4
View File
@@ -1,8 +1,10 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
import type { Language } from '@oodikone/shared/language'
import type { User, UserCreation } from '@oodikone/shared/models/user'
import type { Role } from '@oodikone/shared/types'
@Table({
underscored: true,
@@ -10,7 +12,7 @@ import { Role } from '../shared/types'
modelName: 'users',
tableName: 'users',
})
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
export class UserModel extends Model<User, UserCreation> implements User {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -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')
+5 -5
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)
@@ -53,9 +53,9 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/semesters/codes`, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(`${url}/v3/course_exclusions`, courseExclusions)
app.use(`${url}/v3/course-exclusions`, courseExclusions)
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/changelog`, changelog)
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
+4 -2
View File
@@ -1,14 +1,16 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { isDev } from '../config'
import { Release } from '../shared/types'
const router = Router()
const changelog: { data?: Release[] } = {}
router.get('/', async (_req: Request, res: Response) => {
export type ChangelogResBody = Release[]
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
if (changelog.data) {
return res.status(200).send(changelog.data)
}
@@ -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()
@@ -1,14 +1,17 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference, intersection } from 'lodash'
import { getCompletedCourses } from '../services/completedCoursesSearch'
import { CanError } from '@oodikone/shared/routes'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData } from '../util'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
import { getImporterClient } from '../util/importerClient'
import logger from '../util/logger'
@@ -16,17 +19,22 @@ const router = Router()
const importerClient = getImporterClient()
interface GetSearchRequest extends Request {
query: {
studentlist: string
courselist: string
}
export type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
>
export type SearchReqBody = never
export type SearchQuery = {
studentlist: string
courselist: string
}
router.get('/', async (req: GetSearchRequest, res: Response) => {
const studentNumbers = JSON.parse(req.query?.studentlist) || []
const courseCodes = JSON.parse(req.query?.courselist) || []
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { studentlist, courselist } = req.query
const studentNumbers: string[] = (await safeJSONParse(studentlist)) ?? []
const courseCodes: string[] = (await safeJSONParse(courselist)) ?? []
const { roles, studentsUserCanAccess, sisPersonId: teacherId } = req.user
if (!Array.isArray(studentNumbers)) {
return res.status(400).json({ error: 'Student numbers must be of type array' })
}
@@ -34,48 +42,60 @@ router.get('/', async (req: GetSearchRequest, res: Response) => {
return res.status(400).json({ error: 'Courses must be of type array' })
}
const answerTimeout = new Promise(resolve => setTimeout(resolve, 6000))
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
try {
// Teachers can also get rights to students via Importer if
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const teacherRightsToStudents = (await Promise.race([
importerClient!.post('/teacher-rights/', { teacherId, studentNumbers }),
answerTimeout,
])) as { data: string[] }
if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
studentsUserCanAccess.push(...teacherRightsToStudents.data)
}
} catch (error: any) {
// Teachers can also get rights to students via Importer if
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(
Promise.race([
importerClient.post('/teacher-rights/', { teacherId, studentNumbers }),
answerTimeout as Promise<never>, // This will always reject with an Error
])
)
: { data: null, error: null }
if (error) {
logger.error(`Importer teacher-rights request failed with message: ${error?.message}`)
} else if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
studentsUserCanAccess.push(...teacherRightsToStudents.data)
}
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
const completedCourses = await getCompletedCourses(filteredStudentNumbers, courseCodes)
const discardedStudentNumbers = difference(
studentNumbers,
completedCourses.students.map(student => student.studentNumber)
)
return res.json({ discardedStudentNumbers, ...completedCourses })
})
router.get('/searches', async (req: Request, res: Response) => {
export type GetSearchResBody = FoundSearch[]
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
interface CreateSearchRequest extends Request {
body: {
courselist: string[]
name: string
}
export type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
export type CreateSearchReqBody = {
courselist: string[]
name: string
}
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
const courseCodes = req.body?.courselist || []
const name = req.body?.name
const userId = req.user.id
@@ -98,26 +118,30 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
})
})
interface UpdateSearchRequest extends Request {
body: {
courselist: string[]
}
params: {
id: string
}
}
export type UpdateSearchParams = { id: string }
export type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
} | void>
export type UpdateSearchReqBody = { courselist: string[] }
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { courselist: courseCodes = [] } = req.body
const { id: userId } = req.user
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
const id = req.params?.id
const courseCodes = req.body?.courselist || []
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
@@ -127,22 +151,22 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
})
})
interface DeleteSearchRequest extends Request {
params: {
id: string
}
}
export type DeleteSearchParams = { id: string }
export type DeleteSearchResBody = CanError<string | void>
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { id: userId } = req.user
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
const id = req.params?.id
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
return res.json(id)
})
+12 -15
View File
@@ -1,39 +1,36 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
const router = Router()
interface ExcludedCoursesRequest extends Request {
body: {
courseCodes: string[]
curriculumVersion: string
}
params: {
code: string
}
export type ExcludedCoursesParams = { code: string }
export type ExcludedCoursesResBody = void
export type ExcludedCoursesReqBody = {
courseCodes: string[]
curriculumVersion: string
}
router.post('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
const result = await addExcludedCourses(programmeCode, courseCodes, curriculumVersion)
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
}
res.json(result)
res.status(201).end()
})
router.delete('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
const result = await removeExcludedCourses(programmeCode, courseCodes, curriculumVersion)
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
}
res.json(result)
res.status(204).end()
})
export default router
+84 -77
View File
@@ -1,102 +1,109 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import moment from 'moment'
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 (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (moment(mergedCourse.min_attainment_date).isAfter(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 '../models'
import { CurriculumPeriod } from '@oodikone/shared/models'
import { CurriculumPeriodModel } from '../models'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const result = await CurriculumPeriod.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,6 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
getOpenUniSearches,
createNewSearch,
@@ -7,59 +8,74 @@ import {
updateSearch,
} from '../services/openUni/openUniManageSearches'
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
import { safeJSONParse } from '../util'
const router = Router()
interface GetSearchRequest extends Request {
query: {
courselist: string
startdate: string
enddate: string
}
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
type SearchReqBody = never
type SearchQuery = {
courselist: string
startdate: string
enddate: string
}
router.get('/', async (req: GetSearchRequest, res: Response) => {
const courseCodes = JSON.parse(req.query?.courselist) || []
const startDate = req.query?.startdate !== 'null' ? new Date(req.query.startdate) : new Date(2017, 7, 1, 0, 0, 0)
const endDate = req.query?.enddate !== 'null' ? new Date(req.query.enddate) : new Date()
if (req.query?.enddate === 'null') {
endDate.setHours(23, 59, 59, 999)
}
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { courselist, startdate, enddate } = req.query
const courseCodes = (await safeJSONParse(courselist)) ?? []
if (!Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courses must be of type array' })
}
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
if (enddate === 'null') {
endDate.setHours(23, 59, 59, 999)
}
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
return res.json(customOpenUniSearches)
})
router.get('/searches', async (req: Request, res: Response) => {
const userId = req.user.id
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const { id: userId } = req.user
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
interface CreateSearchRequest extends Request {
body: {
courselist: string[]
name: string
}
type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type CreateSearchReqBody = {
courselist: string[]
name: string
}
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
const courseCodes = req.body?.courselist || []
const name = req.body?.name
const userId = req.user.id
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
const { courselist, name } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (courseCodes && !Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Course codes must be type of array' })
}
const createdSearch = await createNewSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create search' })
}
return res.status(201).json({
id: createdSearch.id,
userId: createdSearch.userId,
@@ -69,26 +85,36 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
})
})
interface UpdateSearchRequest extends Request {
body: {
courselist: string[]
}
params: {
id: string
}
type UpdateSearchParams = {
id: string
}
type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type UpdateSearchReqBody = {
courselist: string[]
}
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
const id = req.params?.id
const courseCodes = req.body?.courselist || []
const userId = req.user.id
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { courselist } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!id || !userId) {
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
@@ -98,22 +124,24 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
})
})
interface DeleteSearchRequest extends Request {
params: {
id: string
}
type DeleteSearchParams = {
id: string
}
type DeleteSearchResBody = CanError<string>
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { id: userId } = req.user
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
const id = req.params?.id
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json(id)
})
@@ -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
+5 -3
View File
@@ -1,5 +1,6 @@
import { Request, Response, Router } from 'express'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
import * as auth from '../middleware/auth'
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
@@ -22,7 +23,6 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
import logger from '../util/logger'
// Faculty uses a lot of tools designed for Study programme.
@@ -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,
})
})
+152 -392
View File
@@ -1,300 +1,96 @@
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 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 { SISStudyRight } from '../models'
import { SISStudyRightModel } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { encrypt, decrypt } from '../services/encrypt'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
import { populationStudentsMerger, populationCourseStatsMerger } from '../services/statMerger'
import { findByTag, findByCourseAndSemesters } from '../services/students'
import { mapToProviders } from '../shared/util'
import { GenderCode, ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
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 } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import { ApplicationError } from '../util/customErrors'
const router = Router()
const filterPersonalTags = (population: Record<string, any>, userId: string) => {
return {
...population,
students: population.students.map(student => {
student.tags = student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
return student
}),
}
}
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
return (student as EncryptedStudent)?.encryptedData !== undefined
}
type EncryptedStudent = { iv: string; encryptedData: string }
interface PostPopulationStatisticsRequest extends Request {
body: {
year: string
semesters: string[]
studyRights: string | string[]
months: number
selectedStudents: string[] | EncryptedStudent[]
selectedStudentsByYear?: { [year: string]: string[] }
studentStatuses?: string[]
years?: string[]
}
}
// POST instead of GET because of too long params and "sensitive" data
router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatisticsRequest, res: Response) => {
if (!req.body.year || !req.body.semesters || !req.body.studyRights) {
Sentry.captureException(new Error('The body should have a year, semester and study rights defined'))
return res.status(400).json({ error: 'The body should have a year, semester and study rights defined' })
}
if (!Array.isArray(req.body.studyRights)) {
req.body.studyRights = [req.body.studyRights]
}
if (req.body.months == null) {
req.body.months = 12
}
const encrypted = isEncryptedStudent(req.body.selectedStudents[0])
if (
!encrypted &&
!hasFullAccessToStudentData(req.user.roles) &&
req.body.selectedStudents.some(student => !req.user.studentsUserCanAccess.includes(student))
) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
if (!req.body.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
}
if (
req.body.studentStatuses &&
!req.body.studentStatuses.every(
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
)
) {
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
if (req.body.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiCourseStatPromises = Promise.all(
req.body.years.map(year => {
if (req.body.selectedStudentsByYear) {
req.body.selectedStudents = encrypted
? req.body.selectedStudents.filter(isEncryptedStudent).map(decrypt)
: req.body.selectedStudentsByYear[year]
}
const newMonths = (upperYearBound - Number(year)) * 12
const query = { ...req.body, year, months: newMonths, selectedStudents: req.body.selectedStudents as string[] }
const coursestatistics = bottlenecksOf(query, null, encrypted)
return coursestatistics
})
)
const multiCourseStats = await multiCourseStatPromises
const result = populationCourseStatsMerger(multiCourseStats)
if (result.error) {
return res.status(400).json(result)
const requiredFields = [years, semesters, programme]
if (requiredFields.some(field => !field)) {
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
}
return res.json(result)
}
if (encrypted) {
req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
}
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
try {
const result = await bottlenecksOf(query, null, encrypted)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
}
})
interface PostPopulationStatisticsByTagRequest extends Request {
body: {
tag: string
}
}
router.post(
'/v2/populationstatistics/coursesbytag',
async (req: PostPopulationStatisticsByTagRequest, res: Response) => {
const { roles, studentsUserCanAccess } = req.user
const { tag } = req.body
if (!tag) {
return res.status(400).json({ error: 'The body should have a tag defined' })
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
}
const studentNumbers = await findByTag(tag)
const studentNumberList = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
try {
const result = await bottlenecksOf(
{
year: '1900',
studyRights: [],
semesters: ['FALL', 'SPRING'],
months: 10000,
tag,
},
studentNumberList
)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
}
}
)
interface PostPopulationStatisticsByStudentNumberListRequest extends Request {
body: {
studentnumberlist: string[]
year: string
studyRights: string[]
semesters: string[]
months: number
}
}
router.post(
'/v2/populationstatistics/coursesbystudentnumberlist',
async (req: PostPopulationStatisticsByStudentNumberListRequest, res: Response) => {
const { roles, studentsUserCanAccess } = req.user
const {
body: { studentnumberlist, ...query },
} = req
if (!studentnumberlist) {
throw new ApplicationError('The body should have a studentnumberlist defined', 400)
const hasCorrectStatus = (studentStatuses: string[]) =>
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
const studentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
try {
const result = await bottlenecksOf(
{
year: query?.year ?? 1900,
studyRights: query?.studyRights ?? [],
semesters: query?.semesters ?? ['FALL', 'SPRING'],
months: query?.months ?? 10000,
},
studentNumbers
)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(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' })
}
}
)
interface GetPopulationStatisticsRequest extends Request {
query: {
year: string
semesters: string[]
studyRights: string
months: string
years?: string[]
}
}
router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsRequest, res: Response) => {
const { year, semesters, studyRights: studyRightsJSON } = req.query
const { roles, programmeRights, id: userId } = req.user
if (!year || !semesters || !studyRightsJSON) {
res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
return
}
const studyRights: { programme: string; combinedProgramme: string } = JSON.parse(studyRightsJSON)
const fullProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeRightsCodes = programmeRights.map(({ code }) => code)
try {
if (
!hasFullAccessToStudentData(roles) &&
!programmeRightsCodes.includes(studyRights.programme) &&
!programmeRightsCodes.includes(studyRights.combinedProgramme)
) {
return res.status(403).json([])
}
} catch (error) {
return res.status(400).json({ error: 'The query had invalid studyRights' })
}
if (req.query.months === null) {
req.query.months = '12'
}
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiPopulationStudentPromises = Promise.all(
req.query.years.map(year => {
const newMonths = (upperYearBound - Number(year)) * 12
const populationStudents = optimizedStatisticsOf({
...req.query,
year: Number(year),
studyRights: { programme: studyRights.programme },
months: newMonths,
})
return populationStudents
})
)
const multiPopulationStudents = await multiPopulationStudentPromises
const result = populationStudentsMerger(multiPopulationStudents)
res.json(filterPersonalTags(result, userId))
} else {
const result: any = await optimizedStatisticsOf({
const { startDate, endDate } = parseDateRangeFromParams({
...req.query,
year: Number(req.query.year),
studyRights: { programme: studyRights.programme },
months: Number(req.query.months),
years: req.query.years,
})
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
const studentNumbers = await getStudentNumbersWithStudyRights({
programmeCodes: [programme],
startDate,
endDate,
studentStatuses,
})
// Obfuscate if user has only limited study programme rights
if (
!hasFullAccessToStudentData(roles) &&
!fullProgrammeRights.includes(studyRights.programme) &&
!fullProgrammeRights.includes(studyRights.combinedProgramme)
) {
if (!('students' in result)) {
return
}
const studyRights = [programme]
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
// Obfuscate if user has only limited study programme rights and there are any students
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
// correct year for age distribution calculation but the date is always January 1st
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
return {
...student,
firstnames: '',
@@ -306,7 +102,6 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
email: '',
secondaryEmail: '',
sis_person_id: '',
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
@@ -314,58 +109,42 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
})
}
res.json(filterPersonalTags(result, userId))
return res.json(result)
}
})
)
interface GetPopulationStatisticsByCourseRequest extends Request {
query: {
coursecodes: string
from: string
to: string
separate: string
unifyCourses: UnifyStatus
}
}
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
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
const { id, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
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.
year: 1900,
studyRights: [],
semesters: ['FALL', 'SPRING'],
months: 10000,
},
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))
@@ -382,7 +161,7 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
courses,
gender_code,
}: {
studyRights: SISStudyRight[]
studyRights: SISStudyRightModel[]
studentNumber: string
courses: ParsedCourse[]
gender_code: GenderCode
@@ -402,99 +181,80 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
obfuscated: true,
})
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
if ('students' in result) {
result.students = result.students.map(student =>
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
)
}
res.json(filterPersonalTags(result, id))
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 { roles, id, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.includes('+')
? tags?.studyProgramme.split('+')[0]
: tags?.studyProgramme
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const result = await optimizedStatisticsOf(
{
year: tags?.year ? Number(tags.year) : 1900,
studyRights: studyProgrammeCode ? [studyProgrammeCode] : [],
semesters: ['FALL', 'SPRING'],
months: 10000,
},
filteredStudentNumbers
)
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(500).json({ error: result.error })
}
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
const { startDate } = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
: { startDate: undefined }
res.status(200).json({ ...filterPersonalTags(resultWithStudyProgramme, id), discardedStudentNumbers })
})
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
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)
})
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
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
@@ -1,23 +1,24 @@
import { Request, Response, Router } from 'express'
import { getCoursesAndModules, getCurriculumVersions } from '../services/programmeModules'
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
const router = Router()
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
const { code } = req.params
const result = await getCurriculumVersions(code)
const result = await getCurriculumOptions(code)
res.json(result)
})
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,
})
})
+9 -10
View File
@@ -1,5 +1,6 @@
import { Request, Response, Router } from 'express'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import {
getBasicStats,
setBasicStats,
@@ -18,7 +19,6 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import logger from '../util/logger'
import { logInfoForGrafana } from '../util/logInfoForGrafana'
@@ -27,16 +27,16 @@ const router = Router()
interface GetCreditStatsRequest extends Request {
query: {
codes: string
isAcademicYear: string
includeSpecials: string
specialGroups: SpecialGroups
yearType: YearType
}
}
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
const { codes, isAcademicYear, includeSpecials } = req.query
const { codes, specialGroups, yearType } = req.query
const stats = {}
for (const code of JSON.parse(codes)) {
stats[code] = await getCreditsProduced(code, isAcademicYear !== 'false', includeSpecials !== 'false')
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
}
return res.json({ stats })
})
@@ -105,20 +105,19 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
interface GetCourseStatsRequest extends Request {
query: {
academicyear: string
combined_programme: string
combinedProgramme: string
yearType: YearType
}
}
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
const code = req.params.id
const showByYear = req.query.academicyear
const combinedProgramme = req.query?.combined_programme
const { combinedProgramme, yearType } = req.query
const date = new Date()
date.setHours(23, 59, 59, 999)
void logInfoForGrafana(code, combinedProgramme)
try {
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, showByYear, combinedProgramme)
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, yearType, combinedProgramme)
return res.json(data)
} catch (error) {
logger.error({ message: `Failed to get code ${code} programme courses stats`, meta: `${error}` })
@@ -10,12 +10,12 @@ const router = Router()
interface StudyProgrammeCriteriaRequest extends Request {
query: {
programmecode: string
programmeCode: string
}
}
router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
const studyProgramme = req.query.programmecode
const studyProgramme = req.query.programmeCode
if (studyProgramme !== '' && !studyProgramme) {
return res.status(422).end()
}
@@ -37,7 +37,10 @@ router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
return res.json(studyProgrammeCriteria)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
return res.status(201).end()
})
interface CriteriaCreditsRequest extends Request {
@@ -53,7 +56,10 @@ router.post('/credits', async (req: CriteriaCreditsRequest, res: Response) => {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCreditCriteria(code, credits)
return res.json(studyProgrammeCriteria)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
return res.status(201).end()
})
export default router
+84 -94
View File
@@ -1,8 +1,9 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference } from 'lodash'
import { Tag, TagStudent } from '../models/kone'
import { filterStudentnumbersByAccessrights } from '../services/students'
import { CanError } from '@oodikone/shared/routes'
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
import { filterStudentNumbersByAccessRights } from '../services/students'
import {
findTagsByStudyTrack,
createNewTag,
@@ -11,20 +12,17 @@ import {
createMultipleStudentTags,
findTagsFromStudyTrackById,
deleteMultipleStudentTags,
TagFromFrontend,
StudentTagFromFrontend,
} from '../services/tags'
import { Role } from '../shared/types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
const filterRelevantTags = (tags: Tag[], userId: string) => {
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
return tags.filter(tag => !tag.personalUserId || tag.personalUserId === userId)
}
const filterRelevantStudentTags = (studentTags: TagStudent[], userId: string) => {
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
const filterRelevantStudentTags = (studentTags: StudentTag[], userId: string) => {
return studentTags.filter(({ tag }) => !tag?.personalUserId || tag.personalUserId === userId)
}
const userIsUnauthorized = (programmeRights: string[], programmeCodes: string[], roles: Role[]) => {
@@ -39,17 +37,14 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
return combinedProgramme ? `${studyTrack}-${combinedProgramme}` : studyTrack
}
interface GetTagsByStudyTrackRequest extends Request {
params: {
studytrack: string
}
}
export type GetTagsByStudyTrackParams = { studyTrack: string }
export type GetTagsByStudyTrackResBody = Tag[] | null
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
const { studytrack } = req.params
router.get<GetTagsByStudyTrackParams, GetTagsByStudyTrackResBody>('/tags/:studyTrack', async (req, res) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags
// This is to avoid unnecessary noise in Sentry
@@ -57,149 +52,144 @@ router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Res
return res.json(null)
}
const tags = await findTagsByStudyTrack(studytrack)
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface PostTagRequest extends Request {
body: {
tag: TagFromFrontend
}
}
export type PostTagResBody = CanError<Tag[] | void>
export type PostTagReqBody = { tag: NewTag }
router.post('/tags', async (req: PostTagRequest, res: Response) => {
router.post<never, PostTagResBody, PostTagReqBody>('/tags', async (req, res) => {
const {
tag: { studytrack, tagname, year, personal_user_id },
tag: { name, personalUserId, studyTrack, year },
} = req.body
const { roles, id, programmeRights } = req.user
if (!tagname || !studytrack || !year) {
if (!name || !studyTrack) {
return res.status(400).json({ error: 'Missing required fields' })
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) {
return res.status(403).end()
}
await createNewTag({ studytrack, tagname, year, personal_user_id })
const tags = await findTagsByStudyTrack(studytrack)
await createNewTag({ name, personalUserId, studyTrack, year })
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface DeleteTagRequest extends Request {
body: {
tag: {
studytrack: string
tag_id: string
personal_user_id: string
}
}
}
export type DeleteTagResBody = Tag[] | void
export type DeleteTagReqBody = { tag: Tag }
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
const { tag } = req.body
const { roles, id, programmeRights } = req.user
router.delete<never, DeleteTagResBody, DeleteTagReqBody>('/tags', async (req, res) => {
const {
tag: { id, personalUserId, studyTrack },
} = req.body
const { roles, id: userId, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(tag.studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id)) {
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(personalUserId === userId)) {
return res.status(403).end()
}
await deleteTag(tag.tag_id)
const tags = await findTagsByStudyTrack(tag.studytrack)
await deleteTag(id)
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface GetStudentTagsByStudyTrackRequest extends Request {
params: {
studytrack: string
export type GetStudentTagsByStudyTrackParams = { studyTrack: string }
export type GetStudentTagsByStudyTrackResBody = StudentTag[] | null
router.get<GetStudentTagsByStudyTrackParams, GetStudentTagsByStudyTrackResBody>(
'/studenttags/:studyTrack',
async (req, res) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studyTrack)
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
if (lacksProgrammeAccess && !roles?.includes('admin')) {
return res.json(null)
}
const tags = await getStudentTagsByStudyTrack(studyTrack)
const relevantTags = filterRelevantStudentTags(tags, id)
res.status(200).json(relevantTags)
}
)
export type PostStudentTagsResBody = CanError<void>
export type PostStudentTagsReqBody = {
combinedProgramme: string
studentTags: StudentTag[]
studyTrack: string
}
router.get('/studenttags/:studytrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
const { studytrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
if (lacksProgrammeAccess && !roles?.includes('admin')) {
return res.json(null)
}
const tags = await getStudentTagsByStudyTrack(studytrack)
res.status(200).json(filterRelevantStudentTags(tags, id))
})
interface PostStudentTagsRequest extends Request {
body: {
tags: StudentTagFromFrontend[]
studytrack: string
combinedProgramme: string
}
}
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
const { tags, studytrack, combinedProgramme } = req.body
router.post<never, PostStudentTagsResBody, PostStudentTagsReqBody>('/studenttags', async (req, res) => {
const { combinedProgramme, studentTags, studyTrack } = req.body
const { roles, programmeRights } = req.user
if (!studentTags || !studyTrack) {
return res.status(400).json({ error: 'Missing required fields' })
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
return res.status(403).end()
}
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
const existingTags = await findTagsByStudyTrack(studyTrackCode)
const existingTagids = existingTags.map(tag => tag.tag_id)
const tagIds = [...new Set(tags.map(tag => tag.tag_id))]
if (!tagIds.find(tag => existingTagids.includes(tag))) {
const existingTagIds = existingTags.map(tag => tag.id)
const tagIds = [...new Set(studentTags.map(studentTag => studentTag.tagId))]
if (!tagIds.find(tag => existingTagIds.includes(tag))) {
return res.status(400).json({ error: 'The tag does not exist' })
}
const studentNumbers = tags.map(tag => tag.studentnumber)
const studentFromProgrammes = combinedProgramme ? [studytrack, combinedProgramme] : [studytrack]
const students = await filterStudentnumbersByAccessrights(studentNumbers, studentFromProgrammes)
const studentNumbers = studentTags.map(studentTag => studentTag.studentNumber)
const studentFromProgrammes = combinedProgramme ? [studyTrack, combinedProgramme] : [studyTrack]
const students = await filterStudentNumbersByAccessRights(studentNumbers, studentFromProgrammes)
const missingStudents = difference(studentNumbers, students)
if (missingStudents.length !== 0) {
const errorMessage = `Could not find the following students from the programme: ${missingStudents.join(', ')}`
return res.status(400).json({ error: errorMessage })
}
await createMultipleStudentTags(tags)
await createMultipleStudentTags(studentTags)
res.status(204).end()
})
interface DeleteStudentTagsRequest extends Request {
body: {
tagId: string
studentnumbers: string[]
studytrack: string
combinedProgramme: string
}
export type DeleteStudentTagsResBody = CanError<void>
export type DeleteStudentTagsReqBody = {
combinedProgramme: string
tagId: string
studentNumbers: string[]
studyTrack: string
}
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
router.delete<never, DeleteStudentTagsResBody, DeleteStudentTagsReqBody>('/studenttags', async (req, res) => {
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
const { roles, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
return res.status(403).end()
}
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
const tags = await findTagsFromStudyTrackById(studyTrackCode, [tagId])
if (tags.length === 0) {
return res.status(403).json({ error: 'The tag does not exist' })
}
await deleteMultipleStudentTags(tagId, studentnumbers)
await deleteMultipleStudentTags(tagId, studentNumbers)
res.status(204).end()
})
+1 -1
View File
@@ -1,11 +1,11 @@
import { Request, Response, Router } from 'express'
import { mapToProviders } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
import { mapToProviders } from '../shared/util'
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
const router = Router()

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