Compare commits

...
184 Commits
Author SHA1 Message Date
joonas-a a6d69cbff5 [Backend][Updater] Add way to flush redis completely in prod
Vastuuvapauslauseke: käyttö omalla vastuulla
2026-06-08 16:10:51 +03:00
Aaro Koivukangas c0b3decaf7 [CompletedCoursesSearch] Refactor substitutions => substitution_groups 2026-06-06 13:41:42 +03:00
joonas-a 1258c0c55c [Backend][Study track stats] discard study rights that do not belong in any category
If viewing bs+ms study right, bs study rights granted before 2017 do not
belong to any category -> do not show them
2026-06-05 12:08:05 +03:00
Aaro Koivukangas 3a0cb6f0ef [PreCommit] Restore previous behaviour for husky pre-commit hook 2026-06-04 14:57:39 +03:00
joonas-a 33e8fb7a39 [Docker] Enable nodemon for dev-updater + fix error 2026-06-04 14:50:48 +03:00
joonas-a 39c78791c2 [Updater panel] layout tweak and fix eslint breaking functionality 2026-06-03 19:13:51 +03:00
joonas-a 54e0469a02 [Updater panel] Improvements
- Convert to TS
- Show status of ongoing and queued jobs
2026-06-03 19:08:39 +03:00
Aaro Koivukangas 9c9e120d8b [Meta] Remove one more instance of substitutions 2026-06-03 17:38:00 +03:00
Aaro Koivukangas 2466aab3a4 [StudyProgrammes] Replace one instance of substitutions with substitution_groups 2026-06-03 16:51:54 +03:00
Santeri Rajamäki 0ac0d8befa [Frontend] Use full width with ErrorBackground as it doesn't have a parent to fill 2026-06-03 13:24:46 +03:00
Santeri Rajamäki e8b46a8b56 [Frontend] FilterView improvements 2026-06-03 13:12:37 +03:00
Santeri Rajamäki 73ee57b6bc [Frontend] Filter rework 2026-06-03 13:11:14 +03:00
joonas-a 4b60f985ac [Curriculum picker] add translation to infobox 2026-06-03 12:33:39 +03:00
Aaro Koivukangas b975714c97 [Infoboxes] Refactor population infobox text object 2026-06-03 12:30:39 +03:00
joonas-a f7c22f4d6e [Filters] Clarify date of course part. infobox text 2026-06-03 12:19:45 +03:00
joonas-a f8cfe9c23a [Percentiles] add english infobox translation 2026-06-03 11:50:54 +03:00
Aaro Koivukangas e818ffa03a [Infoboxes] Refactor faculty infobox text object 2026-06-03 10:39:13 +03:00
Aaro Koivukangas 571a51f09f [Infoboxes] Refactor studyprogramme infobox text object 2026-06-03 10:14:05 +03:00
joonas-a 64deef0c27 [Programme overview][Percentiles] Use proper class sizes
... instead of the amount of months in the range oops.
2026-06-02 18:47:49 +03:00
joonas-a 0e7746184c [Percentiles chart] Fix HTML tag use 2026-06-02 18:39:20 +03:00
joonas-a 628a93e6df [Backend] add proper class sizes to studytrackstats response 2026-06-02 18:37:59 +03:00
joonas-a e4091d6b45 [Percentiles] Add data view feature to chart 2026-06-02 13:45:43 +03:00
joonas-a f57dce2259 [Percentiles][prog overview] add infobox
finnish only :d
2026-06-02 13:45:43 +03:00
Aaro Koivukangas 853912f25b [InfoBoxes] Fix remaining broken info-boxes 2026-06-02 12:37:22 +03:00
Aaro Koivukangas a55198464c [Meta] Remove unused 'substitutions' from types 2026-06-01 15:13:13 +03:00
Aaro Koivukangas b8e39aa29a [Faculty] Change Faculty-view, Update credit data -function to use substitution_groups 2026-06-01 15:09:02 +03:00
Aaro Koivukangas 94d0e54e8b [E2E] Course filter e2e fix 2026-06-01 13:22:02 +03:00
joonas-a ed90d13846 [META] remove safechain in favor of npmrc in docker images 2026-06-01 13:13:39 +03:00
joonas-a 7731a4c699 [META] add npmrcs 2026-06-01 12:52:51 +03:00
Aaro Koivukangas 486613febd [Filters][Courses] Course filter calculates enrolled, no grade correctly 2026-06-01 12:48:27 +03:00
Aaro Koivukangas d7e26d22d0 [Meta] Remove unused unifyOpenUniCourses query param from links 2026-05-29 15:25:54 +03:00
Santeri Rajamäki e069c315a5 [META] Remove open uni search 2026-05-29 11:54:02 +03:00
Aaro Koivukangas f4ac2b3806 [Class statistics][Filters] Course filter respects substitution_groups 2026-05-28 16:08:18 +03:00
Santeri Rajamäki 03756dfce0 [Frontend] Introduce expanding infoboxes 2026-05-28 13:00:36 +03:00
Santeri Rajamäki 1c26912ac7 [Frontend] Add HTML support to react markdown using "an official plugin" 2026-05-28 12:59:36 +03:00
Aaro Koivukangas 8a7b9b4894 [Navbar] Make dev version easily distinguishable from production 2026-05-28 12:40:22 +03:00
Aaro Koivukangas b4103ca779 [CourseSearch] Implement hover for Course search substitution groups 2026-05-27 15:55:15 +03:00
Aaro Koivukangas 4a68fe19e6 [CI] Disable using prebuilt frontend version 2026-05-27 13:52:15 +03:00
Santeri Rajamäki 69365473a4 [META] Use node user in local dev environments to avoid root devour 2026-05-27 13:37:18 +03:00
Aaro Koivukangas 2cfe0d1f6c [CI] Fix correct image name for frontend 2026-05-27 13:36:42 +03:00
Aaro Koivukangas 26d33c1201 [CI] Add missing uses from gh action 2026-05-27 13:20:10 +03:00
Aaro Koivukangas b1c8396098 [CI] Use same config for frontend as backend 2026-05-27 13:18:10 +03:00
Santeri Rajamäki 9d608dbbcc [META] Update docker dev builds 2026-05-27 13:09:02 +03:00
Aaro Koivukangas ae52189689 [CI] Utilize prebuild frontend image 2026-05-26 13:59:20 +03:00
Aaro Koivukangas ecfc8053a0 [CI] Use prebuilt backend image during e2e 2026-05-26 13:31:08 +03:00
Aaro Koivukangas 71bff0214e [CI] Don't pull buildable images eg. backend 2026-05-26 13:19:35 +03:00
Aaro Koivukangas 58bbe00171 [CI] Try acually utilizing the built backend image 2026-05-26 13:13:52 +03:00
Aaro Koivukangas 75302bdc14 [CI] Use more docker-image caching during testing 2026-05-26 12:54:52 +03:00
Aaro Koivukangas 55135030e0 [CI] Fix docker-image missing 2026-05-26 12:42:46 +03:00
Santeri Rajamäki a8b87eed3c [SGG] Remove extra Paper wrapper from Courses of population 2026-05-26 12:34:56 +03:00
Santeri Rajamäki 95f36ef7e8 [PopulationStatistics] Move curriculum selection to the top-level details panel 2026-05-26 12:34:56 +03:00
Aaro Koivukangas f8107bc475 [CI] Increase test parallelization 2026-05-26 12:14:07 +03:00
Aaro Koivukangas 391ea1cbaf [E2E] More fixes 2026-05-26 11:54:31 +03:00
Aaro Koivukangas 5f1d3a12b7 [E2E] e2e test typo fixes 2026-05-26 11:39:26 +03:00
Santeri Rajamäki c533c25bfd [Backend] Remove redundant FD user code 2026-05-26 11:11:31 +03:00
Santeri Rajamäki d4e2d856e8 [Frontend] Use better font 2026-05-26 11:11:31 +03:00
Aaro Koivukangas c5b3147f04 [CourseStats] Update tests to match page title changes 2026-05-26 11:09:04 +03:00
Aaro Koivukangas 8977c52225 [CoursePopulation] Add includeSubstitutions flag to links to Course populations instead of massive coursecode arrays 2026-05-25 22:45:58 +03:00
Aaro Koivukangas a8f48f24d1 [Meta] Add curl to alpine images 2026-05-24 22:31:34 +03:00
Aaro Koivukangas 714682c6c5 [CI] Properly skip e2e if api fails 2026-05-24 21:54:34 +03:00
Aaro Koivukangas 0659d162b5 [Meta] Change alpine images to use ash instead of bash 2026-05-24 21:54:34 +03:00
Aaro KoivukangasandAaro Koivukangas 7057e00ee2 [E2E] Disable the broken test entirely 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas adf64a3d1d [E2E] Disable incorrect check in test 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 36421cf5e7 [E2E] Now acually scroll the bar into view 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas a81b569ab9 [E2E] Scroll bar chart to view in e2e test 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 7641ca3ba1 [E2E] Fix remaining broken tests with new data 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 9515adb1be [ApiTests] Update data used in api tests 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 55668cc313 [E2E][CourseStats] Fix incorrect e2e test values 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 28b7cdfe6b [CI] Increase sleep time on failure logs 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 4b6bba653a [CourseStats] Safeguard all length checks 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas eea167df9b [CourseStats] Handle courses with incorrect substitution_groups better 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas ce946bcb0d [CI] Pull api-test docker images quietly, less logs 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 6580f42eab [CI] Wait for docker to output all error logs to stdout 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 043b0d2cbd [CourseStats] Add better handling for invalid values 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 25fb4bad84 [CourseStats][E2E] Fix broken e2e tests 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 53f3955427 [CourseStats][E2E] Fix e2e tests 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 49390cf92f [CourseStats] Lint fixes 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 128ee465ff [CourseStats] Fix provider organization selector 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 6ed84bee52 [CourseStats] Fix population link course lists 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas fe665cc8ad [CourseStats] Fix enrollments 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 413f2b4fda [CourseStats] Fix substitution group chips on combineSubstitutions=true 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas 8959204a37 [CourseStats] Fix attempts calculations 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas f069d705cb [CourseStats] Fix student statistics 2026-05-24 21:53:26 +03:00
Aaro KoivukangasandAaro Koivukangas adb8c9322b [CourseStats] Fix substitution calculations WIP 2026-05-24 21:53:26 +03:00
Aaro Koivukangas 3e83b63324 [Meta] Add safe-chain to all actions and Dockerfiles 2026-05-24 21:34:23 +03:00
joonas-a 4dcb6b4e37 [Percentiles] Add class size indicator to chart 2026-05-20 16:31:20 +03:00
joonas-a 7830a8abc3 [Backend][Math] fix interpolation logic 2026-05-20 16:23:40 +03:00
joonas-a 34e006e12a [Backend][Percentiles] guarantee obj keys in order
failsafe
2026-05-20 16:14:31 +03:00
joonas-a a33fe22bac [Percentiles] fix credit calculation for bsc+msc combos 2026-05-20 15:46:19 +03:00
joonas-a 4ac467fa7a [lint] moar fix lint error 2026-05-20 14:45:02 +03:00
joonas-a 6aad36d06a [lint] run eslint --fix 2026-05-20 14:32:35 +03:00
Santeri Rajamäki 8413369b89 [CoursePopulation] Fetch course details instead of relying on the fetched population 2026-05-20 13:51:45 +03:00
Santeri Rajamäki 4f2780bf00 [CoursePopulation] Clean up table utils 2026-05-20 13:51:45 +03:00
joonas-a 59120fa69c [Study tracks and class stats] Implement credit acc percentile view 2026-05-20 13:50:54 +03:00
joonas-a 5e502fa23a [Year selector] add slim option 2026-05-19 17:00:35 +03:00
joonas-a 28eec2db0f [Styled message] enhance 2026-05-19 17:00:09 +03:00
joonas-a 9df131b326 [Backend][Study tracks and class stats] include student credit gain percentiles in response 2026-05-19 15:58:57 +03:00
Santeri Rajamäki b9bd916b91 [META] Less lodash 2026-05-19 13:50:02 +03:00
Santeri Rajamäki c99034575f [CoursesOfClass] orderBy in the OT instance instead of setModules hook 2026-05-19 12:26:40 +03:00
Santeri Rajamäki dc12b0fd04 [CoursesOfClass] Add warning message when there are no courses/modules to show with the current student amount limit 2026-05-19 11:51:57 +03:00
Santeri Rajamäki 3ec2b5e5d1 [E2E] Use hover 2026-05-18 15:56:03 +03:00
Santeri Rajamäki 26461f5277 [E2E] Add hover command 2026-05-18 15:40:50 +03:00
Santeri Rajamäki 17b83a8574 [E2E][Students] Remove highcharts specific code 2026-05-18 14:06:40 +03:00
Santeri Rajamäki ec2e203e6b [E2E][DegreeProgramme] Fix mouseover for the rest of the failing tests 2026-05-18 14:00:33 +03:00
Santeri Rajamäki 6f6a732e7f [E2E][DegreeProgramme] Fix mouseover for split view 2026-05-18 13:37:51 +03:00
Santeri Rajamäki fbbf3830b3 [E2E][DegreeProgramme] Fix mouseover 2026-05-18 13:12:14 +03:00
Santeri Rajamäki 4cbccd29ba Merge remote-tracking branch 'origin/chart-refresh' 2026-05-16 01:30:37 +03:00
Santeri Rajamäki 4f4868a32b [E2E] Skip now implemented feature test 2026-05-15 13:04:17 +03:00
Santeri Rajamäki d45bddbeab [DegreeProgramme][StudyTracks] Add ability to view progress by study track 2026-05-15 12:35:29 +03:00
Santeri Rajamäki 2abda1de50 [Frontend] StudyTrack is study track 2026-05-13 14:47:48 +03:00
Santeri Rajamäki 333944d377 [Programme][StudyTracks] Add export option to tables, mirroring faculties view 2026-05-13 13:55:00 +03:00
Santeri Rajamäki 50c301fe82 [Degree programme] Replace study programme with degree programme 2026-05-13 11:34:11 +03:00
Santeri Rajamäki 35ea22e125 [Frontend] Convert ColorizedCoursesTable to typescript 2026-05-12 15:13:38 +03:00
Santeri Rajamäki c3c7b5b479 [Frontend] Rework useSemesters 2026-05-12 14:59:50 +03:00
Santeri Rajamäki b6494f88d0 [Frontend] Cleanup ColorizedCourseTable CSS rules 2026-05-12 14:14:09 +03:00
Santeri Rajamäki 87bc8b24a9 [Frontend] Rework StudentsByStartingYear StudentDataTable and exportStudentTable 2026-05-12 14:14:09 +03:00
Aaro Koivukangas e08d99c801 [Semesters] Remove type assertion and use reduce<type> 2026-05-12 11:47:11 +03:00
Aaro Koivukangas b5f7a8bd86 [ApiTests] Add initial test and rename incorrect test name 2026-05-12 11:45:32 +03:00
Aaro Koivukangas 7d7d71b6be [Admin][Updater] Re-enable all buttons in staging 2026-05-12 11:36:46 +03:00
Aaro Koivukangas c1bacd11cc [Updater] Add hack fix to mainCourseCode being array 2026-05-12 11:36:46 +03:00
Santeri Rajamäki 110994ea46 [Filters] Remove view restriction from actions 2026-05-06 12:27:01 +03:00
Santeri Rajamäki 11bde27811 [Filters] Apply students length filter to Credit Statistics filter buttons 2026-05-06 12:24:51 +03:00
Santeri Rajamäki 0f4ef4a3cd [ClassStatistics][Students] Add emails to courses table 2026-05-06 12:02:47 +03:00
Santeri Rajamäki a3bb84e169 [Frontend] Demangle FilterView 2026-05-06 11:50:20 +03:00
Santeri Rajamäki 915d5c097c [Backend] Remove unused eslint disable 2026-04-24 09:58:35 +03:00
Santeri Rajamäki 523f6aee3e [Backend] Add rudimentary types to studyProgramme and programmeModules routes 2026-04-21 12:28:52 +03:00
Santeri Rajamäki e874454e40 [Backend] Remove reflect-metadata from haunting us 2026-04-21 12:04:24 +03:00
Santeri Rajamäki 45289702d5 [Backend] Update express types 2026-04-21 11:23:17 +03:00
Aaro Koivukangas 1ab6366fd1 [CI] Can't docker:down without .env, reverting adding docker:down 2026-04-21 10:16:52 +03:00
Aaro Koivukangas a01b06b8bc [CI] Remove updater from api-test compose, missing .env 2026-04-21 10:00:27 +03:00
Aaro Koivukangas 11dfa4c6f1 [Updater] Fix logger typo 2026-04-20 15:43:49 +03:00
Aaro Koivukangas b94e4fc25d [Updater] Remove cron jobs in staging environment 2026-04-20 15:37:46 +03:00
Aaro Koivukangas c1bb44bd55 [CI] Restart docker when starting api-ci environment 2026-04-20 15:37:19 +03:00
Aaro Koivukangas 1d8f92c663 [ApiTests] Add api tests for /populationstatisticsbycourse 2026-04-20 15:34:04 +03:00
Aaro Koivukangas b5ee1f2190 [Eslint] Correct ignore rules for backend/tests 2026-04-17 13:28:37 +03:00
Aaro Koivukangas c5649e9d39 [ApiTests] Restructure tests 2026-04-17 13:25:14 +03:00
Aaro Koivukangas 2a979af974 [Lint] Lint fixes 2026-04-17 12:40:12 +03:00
Aaro Koivukangas 06964526dc [Husky] Enable front+back linting again :D 2026-04-17 12:37:12 +03:00
Aaro Koivukangas b2b471dab5 [ApiTests] Add api tests for /populationstatistics/studyprogrammes 2026-04-17 12:32:52 +03:00
Aaro Koivukangas 15b9551f7a [Husky] Lint all files in pre-commit hook and don't lint them twice 2026-04-15 17:17:13 +03:00
Aaro Koivukangas c6f4e6066b [Meta] Remove rapodiff + unused npm commands 2026-04-15 17:10:33 +03:00
Aaro Koivukangas 7ebb60e564 [Staging] Disable updater update buttons in staging, breaks things 2026-04-15 16:32:48 +03:00
Aaro Koivukangas b641cf9ca6 [Scripts] Add script to update staging databases with test data 2026-04-15 16:11:51 +03:00
Santeri Rajamäki 686697f202 [Frontend] Convert LanguageCenterView to tsx 2026-04-14 16:32:00 +03:00
Santeri Rajamäki 296299e635 [Frontend] Simplify ColorizedCoursesTable top-level usage 2026-04-14 16:30:24 +03:00
Santeri Rajamäki c8b8b033ed [Frontend] Drop ProgressBar support 2026-04-14 15:43:45 +03:00
Santeri Rajamäki f64f34b971 [Frontend] Convert DateSelector and DateRangeSelector to tsx 2026-04-14 15:31:22 +03:00
Santeri Rajamäki 9b4f689dd3 [Frontend] Convert LogOutButton to tsx 2026-04-14 15:14:48 +03:00
Santeri Rajamäki cd32860300 [Frontend] Convert FilterDegreeCoursesModal to tsx 2026-04-14 15:10:28 +03:00
Santeri Rajamäki 7ed58eb38a [Frontend] Default CreditCriteriaSection to previous value instead of showing "help text" 2026-04-14 15:06:04 +03:00
Santeri Rajamäki 291082e6c3 [Frontend] Convert ExternalGradeFilterToggle to tsx 2026-04-14 14:44:31 +03:00
Santeri Rajamäki 54f363be6e [META] Drop version numbers from api routes 2026-04-14 14:41:52 +03:00
Santeri Rajamäki 539ed1e3b8 [Backend] Actually return after sending response for close to graduation 2026-04-14 13:24:48 +03:00
Santeri Rajamäki 9d61b50db5 [Frontend] Better type integration for redux users path 2026-04-14 13:22:52 +03:00
Santeri Rajamäki 8ede9de315 [Shared] Move user typings and route types to shared from backend 2026-04-14 13:07:11 +03:00
Santeri Rajamäki 1cb43cee6b [Teachers] Add more handling 2026-04-14 12:38:06 +03:00
Santeri Rajamäki b2617c8933 [Backend] Add handleQueryArrays to other backend routes that crash without it 2026-04-14 12:19:03 +03:00
Santeri Rajamäki beb1130050 [Backend] Add handleQueryArrays to courseYearlyStats 2026-04-14 11:52:38 +03:00
Santeri Rajamäki 1c75204854 [Filters] Map "Koepisteet" to "Valintakoe" as intended 2026-04-14 10:46:14 +03:00
Santeri Rajamäki 80bba6e0fc [Backend] Upgrade from express v4 to v5 2026-04-13 17:27:45 +03:00
Santeri Rajamäki 47b0dabe75 [OT] Remove pre-emptive null returns
OT pagination now supports changing data models which makes these early returns redundant
2026-04-13 15:52:50 +03:00
Santeri Rajamäki da45c8c150 [Backend] Cull user route types 2026-04-13 15:34:57 +03:00
Aaro Koivukangas 8fd7383217 [Staging] Remove unnecessary staging iamgroups 2026-04-13 12:00:04 +03:00
Santeri Rajamäki baa6d87c2c [Filters] Fix student number filter allowlist 2026-04-13 11:48:13 +03:00
Santeri Rajamäki 5ae4c8c7a0 [OT] Change rowsPerPage provider 2026-04-10 14:15:51 +03:00
Santeri Rajamäki cbb7e82d23 [OT] Apply ellipsis to cells table-wide 2026-04-10 14:09:07 +03:00
Aaro KoivukangasandSanteri Rajamäki 2768e95920 [Class stats][Courses of class] Show Master-level modules by fixing incorrect nullish coalesing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5b7d641ad1 [E2E] More page title e2e test fixing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 37a71d7580 [E2E] Fix changed titles in tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki aaa7b5de27 [E2E] Fix tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 40bcbc59dc [Class stats][Courses of class] Change empty fields to be 0, not null 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5e46ce0cb0 [Class stats] Improve page title 2026-04-10 13:39:10 +03:00
Santeri Rajamäki 517c08091e [META] Update @types/pg 2026-04-10 13:39:09 +03:00
Santeri Rajamäki e25dfb2a36 [META] Update lodash-es 2026-04-10 13:38:24 +03:00
Santeri Rajamäki aa4e0abb44 [META] Update cypress 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 3e5dc9a9f0 [META] Update globals 2026-04-10 13:37:13 +03:00
Santeri Rajamäki afd9541151 [META] Update eslint-plugin-import 2026-04-10 13:37:13 +03:00
Santeri Rajamäki a5ab3b972c [META] Update stylelint-config-standard 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 4f3350e4f9 [META] Update stylelint 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 2b8e1ed98f [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 3f311dee69 [Frontend] Remove legacy RTKApi middleware 2026-04-01 13:57:39 +03:00
Santeri Rajamäki eb6b683296 [Frontend] Convert apiConnections to typescript 2026-04-01 13:56:02 +03:00
Aaro Koivukangas b2153ec3dd [ClassStats][Courses] Add better handling for null substitution_groups (only in test-data) 2026-04-01 13:34:40 +03:00
Aaro Koivukangas 8949e7baf0 [Class stats][Courses] Better substitution group type handling 2026-03-31 15:18:47 +03:00
315 changed files with 7293 additions and 5719 deletions
+1 -1
View File
@@ -47,4 +47,4 @@ runs:
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
-3
View File
@@ -8,7 +8,6 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'docker-compose.ci.yml'
- 'package*.json'
@@ -78,8 +77,6 @@ jobs:
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test open uni search'
spec: cypress/e2e/Openuni.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
-1
View File
@@ -11,7 +11,6 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml'
- 'package*.json'
+172 -68
View File
@@ -40,9 +40,11 @@ jobs:
outputs: type=docker,dest=/tmp/backend.tar
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error
name: backend-image
path: /tmp/backend.tar
@@ -51,23 +53,69 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image
uses: redhat-actions/buildah-build@v2
uses: docker/build-push-action@v7
with:
image: oodikone-frontend
tags: staging ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
BASE_PATH=/oodikone
STAGING=true
context: services
file: services/frontend/Dockerfile
push: false
tags: oodikone-frontend:staging
outputs: type=docker,dest=/tmp/frontend.tar
env:
DOCKER_BUILD_SUMMARY: false
# - name: 'Upload image for other jobs'
# uses: actions/upload-artifact@v7
# with:
# if-no-files-found: error
# name: frontend-image
# path: /tmp/frontend.tar
pull_docker_images:
name: 'Pull all needed docker images and cache them'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Pull required images'
run: docker compose --file docker-compose.ci.yml --profile oodikone --profile updater pull --ignore-buildable --quiet
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
@@ -94,7 +142,9 @@ jobs:
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
needs: check_migration_files
needs:
- pull_docker_images
- check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
@@ -105,15 +155,26 @@ jobs:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
run: |
docker compose --file docker-compose.ci.yml up --detach sis-updater-worker
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
sleep 30 # wait for sis-updater-worker to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
build_and_push_anon_kone_db:
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
needs:
- pull_docker_images
- build_backend
- build_frontend
- check_migration_files
@@ -121,18 +182,29 @@ jobs:
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v6
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
run: |
docker compose --file docker-compose.ci.yml up --detach backend
@@ -142,6 +214,7 @@ jobs:
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
needs:
- pull_docker_images
- build_backend
- build_frontend
- check_migration_files
@@ -156,82 +229,44 @@ jobs:
migrations:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
run: |
docker compose --file docker-compose.ci.yml up --detach backend
sleep 30 # wait for backend to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
# === Backend api-tests ===
test_api:
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, pull rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Check that exit statuses are not errors'
run: |
docker ps -a
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
echo 'Some process had nonzero exit code';
exit 1;
fi
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
# === Backend api-tests ===
test_api:
needs:
- test_service_exit_statuses
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
strategy:
fail-fast: false
runs-on: ubuntu-24.04
@@ -239,7 +274,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24 for cypress
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
@@ -261,7 +296,7 @@ jobs:
password: ${{ secrets.password }}
- name: Start oodikone (without frontend and backend)
run: npm run oodikone:ci:setup
run: npm run oodikone:ci:setup -- --quiet-pull
- name: Run api-tests
run: npm run oodikone:ci:api
@@ -269,9 +304,12 @@ jobs:
# === End-to-end tests ===
test_end_to_end:
needs:
- test_service_exit_statuses
- test_api
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled() && needs.lint_packages.result == 'success'
strategy:
fail-fast: false
matrix:
@@ -296,8 +334,6 @@ jobs:
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test open uni search'
spec: cypress/e2e/Openuni.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
@@ -321,9 +357,21 @@ jobs:
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
# - name: 'Download frontend image'
# uses: actions/download-artifact@v8
# with:
# name: frontend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
# - name: 'Load built frontend image'
# run: docker load --input frontend.tar
- name: 'Run tests'
uses: ./.github/actions/run-tests
@@ -332,3 +380,59 @@ jobs:
password: ${{ secrets.password }}
spec: ${{ matrix.spec }}
test_name: ${{ matrix.name }}
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
# - name: 'Download frontend image'
# uses: actions/download-artifact@v8
# with:
# name: frontend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
# - name: 'Load built frontend image'
# run: docker load --input frontend.tar
- name: 'Run services, build rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Check that exit statuses are not errors'
run: |
docker ps -a
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
echo 'Some process had nonzero exit code';
exit 1;
fi
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
+4
View File
@@ -17,6 +17,10 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Install safe-chain
run: set -o pipefail && curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+1 -3
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/).
@@ -129,7 +130,6 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run docker:down`: stops the whole environment
- `npm run testupdater`: runs tests for updater
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
- `npm run rapodiff`: runs [Rapodiff](./services/backend/src/rapodiff/)
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
- `npm run install:docker`: installs `node_modules` for each package inside containers
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
@@ -187,8 +187,6 @@ Continuous integration (CI) works with Github actions and is defined in workflow
- Updater is deployed to production when pushed if tests pass
- Test databases can be updated, instructions in [anonyymioodi README](anonyymioodi/README.md)
[Rapodiff](./services/backend/src/rapodiff/) can be used to check differences between Rapo and Oodikone data.
## ❓FAQ
### Modules are missing after updating package.json
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# This script will first make db dumps of the test environment and upload the dumps to staging
. ../scripts/utils.sh
set -euo pipefail
main() {
declare -r db_names="sis-db kone-db user-db"
declare -A db_url_names
db_url_names["sis-db"]="SIS_DB_URL"
db_url_names["kone-db"]="DB_URL_KONE"
db_url_names["user-db"]="DB_URL_USER"
declare -r dump_directory="dumps"
# Takes the first db-tools pod
check_oc_logged_staging
db_tools_name=$(oc get pods -l app=db-tools --output jsonpath='{.items[0].metadata.name}')
readonly db_tools_name
create_dumps
upload_and_migrate_dumps
}
function create_dumps() {
echo "Starting test/anon containers..."
(npm run docker:down && npm run both) || die "Failed to start containers"
echo
echo "Waiting for containers to start..."
sleep 10 # wait for containers to start
# Create dumps for all dbs
echo
echo "Creating dumps for $db_names"
echo
# Create dir if not exists
if [ ! -d $dump_directory ]; then mkdir -p $dump_directory; fi
for db in $db_names
do
create_dump_from_db "$db"
if [ ! -f "$dump_directory/$db.sqz" ]; then die "Db dump not created"; fi
done
echo "Dumps can be found in $PWD/\*.sqz"
}
# $1 db-name
function create_dump_from_db() {
echo "Dumping db $1..."
docker exec "$1" pg_dump -Fc -U postgres "$1" > "$dump_directory/$1.sqz" || die "Failed to dump db $1"
}
function check_oc_logged_staging() {
echo -n "Checking if oc is logged into toska staging..."
[[ $(oc config current-context) == "toska/api-ocp-test-0-k8s-it-helsinki-fi"* ]] || die "\nNot logged in to toska staging environment: $(oc config current-context)"
echo " OK"
}
function upload_and_migrate_dumps() {
local target_dump_directory="$db_tools_name:/tmp/oodikone-staging-dumps"
echo -n "Dumping $dump_directory/ to $target_dump_directory..."
oc rsync "$dump_directory/" "$target_dump_directory" 1> /dev/null # show errors
echo " OK"
echo
for db in $db_names
do
migrate_dumps "$db"
done
}
function migrate_dumps() {
local db_url_b64
local db_url_raw
local db_url
db_url_b64=$(oc get secrets -l app.kubernetes.io/instance=oodikone --output jsonpath="{.items[0].data.${db_url_names[$1]}}")
db_url_raw=$(echo "$db_url_b64" | base64 --decode)
db_url="${db_url_raw%\?*}"
[[ $db_url != *"oodikone_*_test"* ]] || die "Trying to use non-test database: $db_url"
echo "Migrating $1 to url: $db_url"
oc exec "$db_tools_name" -- pg_restore --clean --no-owner -d "$db_url" "/tmp/oodikone-staging-dumps/$1.sqz"
}
# Run script
main
+292 -273
View File
@@ -92,17 +92,17 @@ const clickAway = () => {
const getCourseSearchHooksForSingleYear = code => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': code },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
}
@@ -185,13 +185,16 @@ describe('Basic user', () => {
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
cy.contains('50131 • Pro gradu -tutkielma tietojenkäsittelytieteessä')
cy.contains('50131') // Pro gradu -tutkielma tietojenkäsittelytieteessä
cy.contains('Show population').should('be.enabled')
cy.contains('Show population').click()
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('Population of course Pro gradu -tutkielma tietojenkäsittelytieteessä 2007-2020 (open and normal)')
cy.contains('Pro gradu -tutkielma tietojenkäsittelytieteessä')
cy.contains('Class of 2007-2020, 10 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Showing 10 out of 10 students')
const gradesTableContents = [
@@ -214,17 +217,15 @@ describe('Basic user', () => {
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
cy.contains(
'ON-310 • Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
)
cy.contains('200012 • Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
cy.contains('ON-310') // Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot
cy.contains('200012') // Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot
cy.contains('Show population').should('be.enabled')
cy.contains('Show population').click()
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains(
'Population of course Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
)
cy.contains('Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
cy.contains('Class of 2011-2018, 4 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Showing 4 out of 4 students')
const gradesTableContents = [
@@ -263,17 +264,17 @@ describe('Basic user', () => {
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code: 'TKT' },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
@@ -309,17 +310,17 @@ describe('Basic user', () => {
it('On searches with multiple courses, has correct links on the Course tab', () => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
pathname: '/api/coursesmulti',
query: { code: 'BSCS' },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
pathname: '/api/courseyearlystats',
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/v3/populationstatisticsbycourse',
pathname: '/api/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
@@ -338,7 +339,9 @@ describe('Basic user', () => {
cy.url().should('contain', 'courseCodes=%5B%22BSCS1001%22%2C%22BSCS1003%22%5D')
cy.cs('course-population-for-2023-2024').click()
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
cy.contains('Introduction to Programming')
cy.contains('Class of 2023-2024')
cy.contains('Include substitutions, Open and normal')
// NOTE: These should also have `cy.intercept`s.
// The calls should be either cached or cheap so this is only
@@ -349,7 +352,9 @@ describe('Basic user', () => {
// Check the link has updated correctly
cy.cs('course-population-for-2022-2023').invoke('attr', 'href').should('include', 'BSCS1003')
cy.cs('course-population-for-2022-2023').click()
cy.contains('Population of course Data Structures and Algorithms 2022-2023 (open and normal)')
cy.contains('Data Structures and Algorithms')
cy.contains('Class of 2022-2023')
cy.contains('Include substitutions, Open and normal')
})
it('Searching course by name displays right courses', () => {
@@ -361,11 +366,11 @@ describe('Basic user', () => {
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004 • Tietokantojen perusteet')
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
cy.contains('BSCS2001 • Introduction to Databases')
cy.contains('581328 • Tietokantojen perusteet')
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
cy.contains('TKT10004') // Tietokantojen perusteet
cy.contains('AYTKT10004') // Avoin yo: Tietokantojen perusteet
cy.contains('BSCS2001') // Introduction to Databases
cy.contains('581328') // Tietokantojen perusteet
cy.contains('A581328') // Avoin yo: Tietokantojen perusteet
clickNewQueryButton()
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
})
@@ -377,20 +382,20 @@ describe('Basic user', () => {
cy.cs(`course-TKT20001`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
cy.contains('BSCS1003') // Data Structures and Algorithms
cy.contains('58131') // Tietorakenteet
clickNewQueryButton()
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
searchByCourseName('tietorakenteet ja algoritmit')
cy.cs(`course-TKT20001`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
cy.contains('BSCS1003') // Data Structures and Algorithms
cy.contains('58131') // Tietorakenteet
})
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
@@ -410,11 +415,11 @@ describe('Basic user', () => {
cy.cs(`course-TKT10004`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004 • Tietokantojen perusteet')
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
cy.contains('BSCS2001 • Introduction to Databases')
cy.contains('581328 • Tietokantojen perusteet')
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
cy.contains('TKT10004') // Tietokantojen perusteet
cy.contains('AYTKT10004') // Avoin yo: Tietokantojen perusteet
cy.contains('BSCS2001') // Introduction to Databases
cy.contains('581328') // Tietokantojen perusteet
cy.contains('A581328') // Avoin yo: Tietokantojen perusteet
cy.cs('ProviderOrganizationSelect').click()
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
@@ -438,10 +443,14 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT20003')
cy.contains('tr', 'TKT20003').click()
cy.contains('TKT20003 • Käyttöjärjestelmät')
cy.contains('582219 • Käyttöjärjestelmät')
cy.contains('TKT20003') // Käyttöjärjestelmät
cy.contains('582219') // Käyttöjärjestelmät
cy.cs('course-population-for-2020-2021').click()
cy.contains('Population of course Käyttöjärjestelmät 2020-2021 (open and normal')
cy.contains('Käyttöjärjestelmät')
cy.contains('Class of 2020-2021, 19 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('TKT20003')
cy.contains('Students (19)').click()
@@ -452,12 +461,16 @@ describe('Basic user', () => {
it('Population of course shows grades for each student', () => {
searchByCourseCode('TKT20001')
cy.cs(`course-TKT20001`).click()
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
cy.contains('BSCS1003') // Data Structures and Algorithms
cy.contains('58131') // Tietorakenteet
cy.cs('course-population-for-2019-2020').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 33 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (33)').click()
cy.contains('td', '394776').siblings().eq(2).contains('3')
cy.contains('td', '497388').siblings().eq(2).contains('2')
@@ -466,12 +479,16 @@ describe('Basic user', () => {
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
searchByCourseCode('TKT20001')
cy.cs(`course-TKT20001`).click()
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
cy.contains('BSCS1003 • Data Structures and Algorithms')
cy.contains('58131 • Tietorakenteet')
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
cy.contains('BSCS1003') // Data Structures and Algorithms
cy.contains('58131') // Tietorakenteet
cy.cs('course-population-for-2019-2020').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 33 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (33)').click()
cy.get('table tbody td').filter(':contains("Hidden")').should('have.length', 9)
})
@@ -480,10 +497,14 @@ describe('Basic user', () => {
it('Language distribution is correct', () => {
searchByCourseCode('TKT20003')
cy.cs(`course-TKT20003`).click()
cy.contains('TKT20003 • Käyttöjärjestelmät')
cy.contains('582219 • Käyttöjärjestelmät')
cy.contains('TKT20003') // Käyttöjärjestelmät
cy.contains('582219') // Käyttöjärjestelmät
cy.cs('course-population-for-2021-2022').click()
cy.contains('Population of course Käyttöjärjestelmät 2021-2022 (open and normal)')
cy.contains('Käyttöjärjestelmät')
cy.contains('Class of 2021-2022')
cy.contains('Include substitutions, Open and normal')
cy.contains('Language distribution').click()
cy.contains('td', 'finnish').siblings().eq(0).contains('5')
cy.contains('td', 'english').siblings().eq(0).contains('2')
@@ -499,7 +520,7 @@ describe('Basic user', () => {
searchByCourseCode('TKT10002')
cy.cs(`course-TKT10002`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
cy.contains('TKT10002') // Ohjelmoinnin perusteet
})
it('Time range', () => {
@@ -538,14 +559,14 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
['Total', 187, 140, 11, 36, '74.87 %', '25.13 %'],
['Total', 183, 140, 6, 37, '76.50 %', '23.50 %'],
['2023-2024', 7, 1, 0, 6, '14.29 %', '85.71 %'],
['2022-2023', 33, 27, 0, 6, '81.82 %', '18.18 %'],
['2021-2022', 39, 15, 0, 24, '38.46 %', '61.54 %'],
['2020-2021', 23, 23, 0, null, '100.00 %', '0.00 %'],
['2022-2023', 32, 26, 0, 6, '81.25 %', '18.75 %'],
['2021-2022', 39, 14, 0, 25, '35.90 %', '64.10 %'],
['2020-2021', 21, 21, 0, null, '100.00 %', '0.00 %'],
['2019-2020', 28, 28, 0, null, '100.00 %', '0.00 %'],
['2018-2019', 30, 26, 4, null, '86.67 %', '13.33 %'],
['2017-2018', 26, 19, 7, null, '73.08 %', '26.92 %'],
['2018-2019', 29, 27, 2, null, '93.10 %', '6.90 %'],
['2017-2018', 26, 22, 4, null, '84.62 %', '15.38 %'],
['2016-2017', 1, 1, 0, null, '100.00 %', '0.00 %'],
]
checkTableContents(tableContents)
@@ -554,21 +575,21 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
['Total', 191, 140, 13, 38, '73.30 %', '26.70 %'],
['Total', 185, 140, 6, 39, '75.68 %', '24.32 %'],
['Syksy 2023', 7, 1, 0, 6, '14.29 %', '85.71 %'],
['Kevät 2023', 10, 9, 0, 1, '90.00 %', '10.00 %'],
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
['Kevät 2022', 26, 9, 0, 17, '34.62 %', '65.38 %'],
['Syksy 2021', 15, 6, 0, 9, '40.00 %', '60.00 %'],
['Kevät 2021', 4, 4, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 19, 19, 0, null, '100.00 %', '0.00 %'],
['Kevät 2020', 4, 4, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 25, 24, 1, null, '96.00 %', '4.00 %'],
['Kevät 2019', 8, 8, 0, null, '100.00 %', '0.00 %'],
['Syksy 2018', 22, 18, 4, null, '81.82 %', '18.18 %'],
['Kevät 2018', 19, 12, 7, null, '63.16 %', '36.84 %'],
['Syksy 2017', 8, 7, 1, null, '87.50 %', '12.50 %'],
['Kevät 2017', 0, 0, 0, null, '–', '–'],
['Syksy 2021', 15, 5, 0, 10, '33.33 %', '66.67 %'],
['Kevät 2021', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 18, 18, 0, null, '100.00 %', '0.00 %'],
['Kevät 2020', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 25, 25, 0, null, '100.00 %', '0.00 %'],
['Kevät 2019', 7, 7, 0, null, '100.00 %', '0.00 %'],
['Syksy 2018', 22, 20, 2, null, '90.91 %', '9.09 %'],
['Kevät 2018', 18, 14, 4, null, '77.78 %', '22.22 %'],
['Syksy 2017', 8, 8, 0, null, '100.00 %', '0.00 %'],
['Kevät 2017', 0, 0, 0, 0, '–', '–'],
['Syksy 2016', 1, 1, 0, null, '100.00 %', '0.00 %'],
]
toggleSeparateBySemesters()
@@ -578,14 +599,14 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters off', () => {
const tableContents = [
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
['Total', 187, 11, 3, 8, 5, 24, 99, 1, 36, '74.87 %', '25.13 %'],
['Total', 183, 6, 3, 8, 5, 24, 99, 1, 37, '76.50 %', '23.50 %'],
['2023-2024', 7, 0, 0, 0, 0, 0, 1, 0, 6, '14.29 %', '85.71 %'],
['2022-2023', 33, 0, 0, 0, 0, 6, 21, 0, 6, '81.82 %', '18.18 %'],
['2021-2022', 39, 0, 0, 0, 0, 2, 13, 0, 24, '38.46 %', '61.54 %'],
['2020-2021', 23, 0, 0, 2, 0, 2, 18, 1, null, '100.00 %', '0.00 %'],
['2022-2023', 32, 0, 0, 0, 0, 6, 20, 0, 6, '81.25 %', '18.75 %'],
['2021-2022', 39, 0, 0, 0, 0, 2, 12, 0, 25, '35.90 %', '64.10 %'],
['2020-2021', 21, 0, 0, 1, 0, 2, 17, 1, null, '100.00 %', '0.00 %'],
['2019-2020', 28, 0, 1, 4, 1, 5, 17, 0, null, '100.00 %', '0.00 %'],
['2018-2019', 30, 4, 2, 1, 2, 3, 18, 0, null, '86.67 %', '13.33 %'],
['2017-2018', 26, 7, 0, 1, 2, 5, 11, 0, null, '73.08 %', '26.92 %'],
['2018-2019', 29, 2, 2, 1, 2, 3, 19, 0, null, '93.10 %', '6.90 %'],
['2017-2018', 26, 4, 0, 2, 2, 5, 13, 0, null, '84.62 %', '15.38 %'],
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -595,21 +616,21 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters on', () => {
const tableContents = [
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
['Total', 191, 13, 3, 8, 5, 24, 99, 1, 38, '73.30 %', '26.70 %'],
['Total', 185, 6, 3, 8, 5, 24, 99, 1, 39, '75.68 %', '24.32 %'],
['Syksy 2023', 7, 0, 0, 0, 0, 0, 1, 0, 6, '14.29 %', '85.71 %'],
['Kevät 2023', 10, 0, 0, 0, 0, 3, 6, 0, 1, '90.00 %', '10.00 %'],
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
['Syksy 2022', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
['Kevät 2022', 26, 0, 0, 0, 0, 2, 7, 0, 17, '34.62 %', '65.38 %'],
['Syksy 2021', 15, 0, 0, 0, 0, 0, 6, 0, 9, '40.00 %', '60.00 %'],
['Kevät 2021', 4, 0, 0, 0, 0, 0, 4, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 19, 0, 0, 2, 0, 2, 14, 1, null, '100.00 %', '0.00 %'],
['Kevät 2020', 4, 0, 1, 0, 1, 0, 2, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 25, 1, 0, 4, 0, 5, 15, 0, null, '96.00 %', '4.00 %'],
['Kevät 2019', 8, 0, 0, 0, 1, 1, 6, 0, null, '100.00 %', '0.00 %'],
['Syksy 2018', 22, 4, 2, 1, 1, 2, 12, 0, null, '81.82 %', '18.18 %'],
['Kevät 2018', 19, 7, 0, 1, 0, 3, 8, 0, null, '63.16 %', '36.84 %'],
['Syksy 2017', 8, 1, 0, 0, 2, 2, 3, 0, null, '87.50 %', '12.50 %'],
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2021', 15, 0, 0, 0, 0, 0, 5, 0, 10, '33.33 %', '66.67 %'],
['Kevät 2021', 3, 0, 0, 0, 0, 0, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 18, 0, 0, 1, 0, 2, 14, 1, null, '100.00 %', '0.00 %'],
['Kevät 2020', 3, 0, 0, 0, 1, 0, 2, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 25, 0, 1, 4, 0, 5, 15, 0, null, '100.00 %', '0.00 %'],
['Kevät 2019', 7, 0, 0, 0, 1, 1, 5, 0, null, '100.00 %', '0.00 %'],
['Syksy 2018', 22, 2, 2, 1, 1, 2, 14, 0, null, '90.91 %', '9.09 %'],
['Kevät 2018', 18, 4, 0, 2, 0, 3, 9, 0, null, '77.78 %', '22.22 %'],
['Syksy 2017', 8, 0, 0, 0, 2, 2, 4, 0, null, '100.00 %', '0.00 %'],
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0, 0, '–', '–'],
['Syksy 2016', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -644,15 +665,15 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 186, 140, 16, '89.74 %', 73],
['2023-2024', 6, 1, 0, '16.67 %', 6],
['2022-2023', 26, 27, 0, '100.00 %', 26],
['2021-2022', 41, 15, 0, '36.59 %', 41],
['2020-2021', 23, 23, 0, '100.00 %', null],
['2019-2020', 29, 28, 1, '96.55 %', null],
['2018-2019', 32, 26, 6, '81.25 %', null],
['2017-2018', 28, 19, 9, '67.86 %', null],
['2016-2017', 1, 1, 0, '100.00 %', null],
['Total', 178, 140, 6, '95.89 %', 73],
['2023-2024', 6, 1, null, '16.67 %', 6],
['2022-2023', 26, 26, null, '100.00 %', 26],
['2021-2022', 41, 14, null, '34.15 %', 41],
['2020-2021', 21, 21, null, '100.00 %', null],
['2019-2020', 28, 28, null, '100.00 %', null],
['2018-2019', 29, 27, 2, '93.10 %', null],
['2017-2018', 26, 22, 4, '84.62 %', null],
['2016-2017', 1, 1, null, '100.00 %', null],
]
checkTableContents(tableContents)
})
@@ -660,20 +681,20 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 186, 140, 16, '89.74 %', 73],
['Total', 178, 140, 6, '95.89 %', 73],
['Syksy 2023', 6, 1, 0, '16.67 %', 6],
['Kevät 2023', 11, 9, 0, '81.82 %', 11],
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
['Kevät 2022', 29, 9, 0, '31.03 %', 29],
['Syksy 2021', 12, 6, 0, '50.00 %', 12],
['Kevät 2021', 4, 4, 0, '100.00 %', null],
['Syksy 2020', 19, 19, 0, '100.00 %', null],
['Kevät 2020', 4, 4, 0, '100.00 %', null],
['Syksy 2019', 25, 24, 1, '96.00 %', null],
['Kevät 2019', 8, 8, 0, '100.00 %', null],
['Syksy 2018', 24, 18, 6, '75.00 %', null],
['Kevät 2018', 20, 12, 8, '60.00 %', null],
['Syksy 2017', 8, 7, 1, '87.50 %', null],
['Syksy 2021', 12, 5, 0, '41.67 %', 12],
['Kevät 2021', 3, 3, 0, '100.00 %', null],
['Syksy 2020', 18, 18, 0, '100.00 %', null],
['Kevät 2020', 3, 3, 0, '100.00 %', null],
['Syksy 2019', 25, 25, 0, '100.00 %', null],
['Kevät 2019', 7, 7, 0, '100.00 %', null],
['Syksy 2018', 22, 20, 2, '90.91 %', null],
['Kevät 2018', 18, 14, 4, '77.78 %', null],
['Syksy 2017', 8, 8, 0, '100.00 %', null],
['Kevät 2017', 0, 0, 0, '–', 0],
['Syksy 2016', 1, 1, 0, '100.00 %', null],
]
@@ -684,14 +705,14 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
['Total', 186, 16, 3, 8, 5, 24, 99, 1],
['Total', 178, 6, 3, 8, 5, 24, 99, 1],
['2023-2024', 6, 0, 0, 0, 0, 0, 1, 0],
['2022-2023', 26, 0, 0, 0, 0, 6, 21, 0],
['2021-2022', 41, 0, 0, 0, 0, 2, 13, 0],
['2020-2021', 23, 0, 0, 2, 0, 2, 18, 1],
['2019-2020', 29, 1, 1, 4, 1, 5, 17, 0],
['2018-2019', 32, 6, 2, 1, 2, 3, 18, 0],
['2017-2018', 28, 9, 0, 1, 2, 5, 11, 0],
['2022-2023', 26, 0, 0, 0, 0, 6, 20, 0],
['2021-2022', 41, 0, 0, 0, 0, 2, 12, 0],
['2020-2021', 21, 0, 0, 1, 0, 2, 17, 1],
['2019-2020', 28, 0, 1, 4, 1, 5, 17, 0],
['2018-2019', 29, 2, 2, 1, 2, 3, 19, 0],
['2017-2018', 26, 4, 0, 2, 2, 5, 13, 0],
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0],
]
toggleShowGrades()
@@ -701,20 +722,20 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
['Total', 186, 16, 3, 8, 5, 24, 99, 1],
['Total', 178, 6, 3, 8, 5, 24, 99, 1],
['Syksy 2023', 6, 0, 0, 0, 0, 0, 1, 0],
['Kevät 2023', 11, 0, 0, 0, 0, 3, 6, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 14, 0],
['Kevät 2022', 29, 0, 0, 0, 0, 2, 7, 0],
['Syksy 2021', 12, 0, 0, 0, 0, 0, 6, 0],
['Kevät 2021', 4, 0, 0, 0, 0, 0, 4, 0],
['Syksy 2020', 19, 0, 0, 2, 0, 2, 14, 1],
['Kevät 2020', 4, 0, 1, 0, 1, 0, 2, 0],
['Syksy 2019', 25, 1, 0, 4, 0, 5, 15, 0],
['Kevät 2019', 8, 0, 0, 0, 1, 1, 6, 0],
['Syksy 2018', 24, 6, 2, 1, 1, 2, 12, 0],
['Kevät 2018', 20, 8, 0, 1, 0, 3, 8, 0],
['Syksy 2017', 8, 1, 0, 0, 2, 2, 3, 0],
['Syksy 2021', 12, 0, 0, 0, 0, 0, 5, 0],
['Kevät 2021', 3, 0, 0, 0, 0, 0, 3, 0],
['Syksy 2020', 18, 0, 0, 1, 0, 2, 14, 1],
['Kevät 2020', 3, 0, 0, 0, 1, 0, 2, 0],
['Syksy 2019', 25, 0, 1, 4, 0, 5, 15, 0],
['Kevät 2019', 7, 0, 0, 0, 1, 1, 5, 0],
['Syksy 2018', 22, 2, 2, 1, 1, 2, 14, 0],
['Kevät 2018', 18, 4, 0, 2, 0, 3, 9, 0],
['Syksy 2017', 8, 0, 0, 0, 2, 2, 4, 0],
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2016', 1, 0, 0, 0, 0, 1, 0, 0],
]
@@ -732,11 +753,11 @@ describe('Basic user', () => {
searchByCourseCode('TKT10002')
cy.cs(`course-TKT10002`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
cy.contains('AYTKT10002 • Avoin yo: Ohjelmoinnin perusteet')
cy.contains('BSCS1001 • Introduction to Programming')
cy.contains('581325 • Ohjelmoinnin perusteet')
cy.contains('A581325 • Avoin yo: Ohjelmoinnin perusteet')
cy.contains('TKT10002') // Ohjelmoinnin perusteet
cy.contains('AYTKT10002') // Avoin yo: Ohjelmoinnin perusteet
cy.contains('BSCS1001') // Introduction to Programming
cy.contains('581325') // Ohjelmoinnin perusteet
cy.contains('A581325') // Avoin yo: Ohjelmoinnin perusteet
})
it('Time range', () => {
@@ -757,18 +778,18 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
['Total', 284, 237, 16, 31, '83.45 %', '16.55 %'],
['Total', 277, 237, 8, 32, '85.56 %', '14.44 %'],
['2023-2024', 8, 2, 0, 6, '25.00 %', '75.00 %'],
['2022-2023', 35, 28, 0, 7, '80.00 %', '20.00 %'],
['2021-2022', 45, 27, 0, 18, '60.00 %', '40.00 %'],
['2020-2021', 33, 33, 0, null, '100.00 %', '0.00 %'],
['2019-2020', 57, 56, 1, null, '98.25 %', '1.75 %'],
['2018-2019', 42, 38, 4, null, '90.48 %', '9.52 %'],
['2017-2018', 32, 25, 7, null, '78.13 %', '21.88 %'],
['2016-2017', 7, 6, 1, null, '85.71 %', '14.29 %'],
['2015-2016', 4, 3, 1, null, '75.00 %', '25.00 %'],
['2014-2015', 6, 6, 0, null, '100.00 %', '0.00 %'],
['2013-2014', 2, 1, 1, null, '50.00 %', '50.00 %'],
['2022-2023', 34, 27, 0, 7, '79.41 %', '20.59 %'],
['2021-2022', 45, 26, 0, 19, '57.78 %', '42.22 %'],
['2020-2021', 31, 31, 0, null, '100.00 %', '0.00 %'],
['2019-2020', 56, 56, 0, null, '100.00 %', '0.00 %'],
['2018-2019', 41, 39, 2, null, '95.12 %', '4.88 %'],
['2017-2018', 32, 28, 4, null, '87.50 %', '12.50 %'],
['2016-2017', 6, 5, 1, null, '83.33 %', '16.67 %'],
['2015-2016', 4, 4, 0, null, '100.00 %', '0.00 %'],
['2014-2015', 5, 5, 0, null, '100.00 %', '0.00 %'],
['2013-2014', 2, 2, 0, null, '100.00 %', '0.00 %'],
['2012-2013', 4, 4, 0, null, '100.00 %', '0.00 %'],
['2011-2012', 1, 1, 0, null, '100.00 %', '0.00 %'],
['2010-2011', 1, 1, 0, null, '100.00 %', '0.00 %'],
@@ -790,28 +811,28 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
['Total', 293, 239, 19, 35, '81.57 %', '18.43 %'],
['Total', 282, 237, 9, 36, '84.04 %', '15.96 %'],
['Syksy 2023', 8, 2, 0, 6, '25.00 %', '75.00 %'],
['Kevät 2023', 12, 10, 0, 2, '83.33 %', '16.67 %'],
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
['Kevät 2022', 28, 11, 0, 17, '39.29 %', '60.71 %'],
['Syksy 2021', 21, 16, 0, 5, '76.19 %', '23.81 %'],
['Kevät 2021', 8, 8, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 25, 25, 0, null, '100.00 %', '0.00 %'],
['Kevät 2020', 26, 26, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 33, 31, 2, null, '93.94 %', '6.06 %'],
['Kevät 2019', 17, 16, 1, null, '94.12 %', '5.88 %'],
['Syksy 2018', 27, 23, 4, null, '85.19 %', '14.81 %'],
['Kevät 2018', 24, 17, 7, null, '70.83 %', '29.17 %'],
['Syksy 2017', 9, 8, 1, null, '88.89 %', '11.11 %'],
['Syksy 2021', 21, 15, 0, 6, '71.43 %', '28.57 %'],
['Kevät 2021', 7, 7, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 24, 24, 0, null, '100.00 %', '0.00 %'],
['Kevät 2020', 24, 24, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 32, 32, 0, null, '100.00 %', '0.00 %'],
['Kevät 2019', 15, 14, 1, null, '93.33 %', '6.67 %'],
['Syksy 2018', 27, 25, 2, null, '92.59 %', '7.41 %'],
['Kevät 2018', 23, 19, 4, null, '82.61 %', '17.39 %'],
['Syksy 2017', 9, 9, 0, null, '100.00 %', '0.00 %'],
['Kevät 2017', 4, 3, 1, null, '75.00 %', '25.00 %'],
['Syksy 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 2, 1, null, '66.67 %', '33.33 %'],
['Syksy 2016', 2, 2, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2015', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2015', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 2, 2, 0, null, '100.00 %', '0.00 %'],
['Kevät 2014', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', 1, 0, 1, null, '0.00 %', '100.00 %'],
['Syksy 2013', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2013', 0, 0, 0, 0, '–', '–'],
['Syksy 2012', 4, 4, 0, null, '100.00 %', '0.00 %'],
['Kevät 2012', 1, 1, 0, null, '100.00 %', '0.00 %'],
@@ -848,18 +869,18 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters off', () => {
const tableContents = [
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
['Total', 284, 16, 4, 14, 9, 37, 168, 5, 31, '83.45 %', '16.55 %'],
['Total', 277, 8, 4, 14, 9, 37, 168, 5, 32, '85.56 %', '14.44 %'],
['2023-2024', 8, 0, 0, 0, 0, 0, 2, 0, 6, '25.00 %', '75.00 %'],
['2022-2023', 35, 0, 0, 0, 0, 6, 22, 0, 7, '80.00 %', '20.00 %'],
['2021-2022', 45, 0, 0, 1, 0, 2, 24, 0, 18, '60.00 %', '40.00 %'],
['2020-2021', 33, 0, 0, 2, 1, 2, 27, 1, null, '100.00 %', '0.00 %'],
['2019-2020', 57, 1, 1, 5, 3, 9, 38, 0, null, '98.25 %', '1.75 %'],
['2018-2019', 42, 4, 3, 2, 2, 6, 25, 0, null, '90.48 %', '9.52 %'],
['2017-2018', 32, 7, 0, 2, 2, 6, 15, 0, null, '78.13 %', '21.88 %'],
['2016-2017', 7, 1, 0, 0, 1, 1, 4, 0, null, '85.71 %', '14.29 %'],
['2015-2016', 4, 1, 0, 1, 0, 0, 1, 1, null, '75.00 %', '25.00 %'],
['2014-2015', 6, 0, 0, 1, 0, 0, 2, 3, null, '100.00 %', '0.00 %'],
['2013-2014', 2, 1, 0, 0, 0, 0, 1, 0, null, '50.00 %', '50.00 %'],
['2022-2023', 34, 0, 0, 0, 0, 6, 21, 0, 7, '79.41 %', '20.59 %'],
['2021-2022', 45, 0, 0, 1, 0, 2, 23, 0, 19, '57.78 %', '42.22 %'],
['2020-2021', 31, 0, 0, 1, 1, 2, 26, 1, null, '100.00 %', '0.00 %'],
['2019-2020', 56, 0, 1, 5, 3, 9, 38, 0, null, '100.00 %', '0.00 %'],
['2018-2019', 41, 2, 3, 2, 2, 6, 26, 0, null, '95.12 %', '4.88 %'],
['2017-2018', 32, 4, 0, 3, 2, 6, 17, 0, null, '87.50 %', '12.50 %'],
['2016-2017', 6, 1, 0, 0, 1, 1, 3, 0, null, '83.33 %', '16.67 %'],
['2015-2016', 4, 0, 0, 1, 0, 0, 2, 1, null, '100.00 %', '0.00 %'],
['2014-2015', 5, 0, 0, 1, 0, 0, 1, 3, null, '100.00 %', '0.00 %'],
['2013-2014', 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
['2012-2013', 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
['2011-2012', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['2010-2011', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
@@ -882,28 +903,28 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters on', () => {
const tableContents = [
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
['Total', 293, 19, 5, 15, 9, 37, 168, 5, 35, '81.57 %', '18.43 %'],
['Total', 282, 9, 4, 14, 9, 37, 168, 5, 36, '84.04 %', '15.96 %'],
['Syksy 2023', 8, 0, 0, 0, 0, 0, 2, 0, 6, '25.00 %', '75.00 %'],
['Kevät 2023', 12, 0, 0, 0, 0, 3, 7, 0, 2, '83.33 %', '16.67 %'],
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
['Syksy 2022', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
['Kevät 2022', 28, 0, 0, 0, 0, 2, 9, 0, 17, '39.29 %', '60.71 %'],
['Syksy 2021', 21, 0, 0, 1, 0, 0, 15, 0, 5, '76.19 %', '23.81 %'],
['Kevät 2021', 8, 0, 0, 0, 0, 0, 8, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 25, 0, 0, 2, 1, 2, 19, 1, null, '100.00 %', '0.00 %'],
['Kevät 2020', 26, 0, 1, 1, 2, 4, 18, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 33, 2, 1, 4, 1, 5, 20, 0, null, '93.94 %', '6.06 %'],
['Kevät 2019', 17, 1, 1, 1, 1, 2, 11, 0, null, '94.12 %', '5.88 %'],
['Syksy 2018', 27, 4, 2, 2, 1, 4, 14, 0, null, '85.19 %', '14.81 %'],
['Kevät 2018', 24, 7, 0, 2, 0, 4, 11, 0, null, '70.83 %', '29.17 %'],
['Syksy 2017', 9, 1, 0, 0, 2, 2, 4, 0, null, '88.89 %', '11.11 %'],
['Syksy 2021', 21, 0, 0, 1, 0, 0, 14, 0, 6, '71.43 %', '28.57 %'],
['Kevät 2021', 7, 0, 0, 0, 0, 0, 7, 0, null, '100.00 %', '0.00 %'],
['Syksy 2020', 24, 0, 0, 1, 1, 2, 19, 1, null, '100.00 %', '0.00 %'],
['Kevät 2020', 24, 0, 0, 1, 2, 4, 17, 0, null, '100.00 %', '0.00 %'],
['Syksy 2019', 32, 0, 1, 4, 1, 5, 21, 0, null, '100.00 %', '0.00 %'],
['Kevät 2019', 15, 1, 1, 1, 1, 2, 9, 0, null, '93.33 %', '6.67 %'],
['Syksy 2018', 27, 2, 2, 1, 1, 4, 17, 0, null, '92.59 %', '7.41 %'],
['Kevät 2018', 23, 4, 0, 3, 0, 4, 12, 0, null, '82.61 %', '17.39 %'],
['Syksy 2017', 9, 0, 0, 0, 2, 2, 5, 0, null, '100.00 %', '0.00 %'],
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0, null, '75.00 %', '25.00 %'],
['Syksy 2016', 3, 0, 0, 0, 0, 1, 2, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 1, 0, 1, 0, 0, 1, 0, null, '66.67 %', '33.33 %'],
['Syksy 2016', 2, 0, 0, 0, 0, 1, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
['Syksy 2015', 1, 0, 0, 0, 0, 0, 0, 1, null, '100.00 %', '0.00 %'],
['Kevät 2015', 3, 0, 0, 0, 0, 0, 0, 3, null, '100.00 %', '0.00 %'],
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 2, 0, 0, 1, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2014', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
['Syksy 2013', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2013', 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
['Syksy 2012', 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2012', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
@@ -947,18 +968,18 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 288, 241, 25, '90.60 %', 79],
['Total', 270, 237, 9, '96.34 %', 79],
['2023-2024', 6, 2, 0, '33.33 %', 6],
['2022-2023', 28, 28, 0, '100.00 %', 28],
['2021-2022', 45, 27, 0, '60.00 %', 45],
['2020-2021', 34, 34, 0, '100.00 %', null],
['2019-2020', 60, 57, 3, '95.00 %', null],
['2018-2019', 46, 39, 7, '84.78 %', null],
['2017-2018', 35, 26, 9, '74.29 %', null],
['2016-2017', 7, 6, 1, '85.71 %', null],
['2015-2016', 5, 3, 2, '60.00 %', null],
['2014-2015', 7, 6, 1, '85.71 %', null],
['2013-2014', 2, 1, 1, '50.00 %', null],
['2022-2023', 28, 27, 0, '96.43 %', 28],
['2021-2022', 45, 26, 0, '57.78 %', 45],
['2020-2021', 31, 31, 0, '100.00 %', null],
['2019-2020', 56, 56, 0, '100.00 %', null],
['2018-2019', 42, 39, 3, '92.86 %', null],
['2017-2018', 32, 28, 4, '87.50 %', null],
['2016-2017', 6, 5, 1, '83.33 %', null],
['2015-2016', 4, 4, 0, '100.00 %', null],
['2014-2015', 5, 5, 0, '100.00 %', null],
['2013-2014', 2, 2, 0, '100.00 %', null],
['2012-2013', 4, 4, 0, '100.00 %', null],
['2011-2012', 1, 1, 0, '100.00 %', null],
['2010-2011', 1, 1, 0, '100.00 %', null],
@@ -980,55 +1001,55 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 288, 241, 25, '90.60 %', 79],
['Total', 270, 237, 9, '96.34 %', 79],
['Syksy 2023', 6, 2, 0, '33.33 %', 6],
['Kevät 2023', 13, 10, 0, '76.92 %', 13],
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
['Kevät 2022', 30, 11, 0, '36.67 %', 30],
['Syksy 2021', 15, 16, 0, '100.00 %', 15],
['Kevät 2021', 8, 8, 0, '100.00 %', null],
['Syksy 2020', 26, 26, 0, '100.00 %', null],
['Kevät 2020', 26, 26, 0, '100.00 %', null],
['Syksy 2019', 34, 31, 3, '91.18 %', null],
['Kevät 2019', 17, 16, 1, '94.12 %', null],
['Syksy 2018', 29, 23, 6, '79.31 %', null],
['Kevät 2018', 26, 18, 8, '69.23 %', null],
['Syksy 2017', 9, 8, 1, '88.89 %', null],
['Syksy 2021', 15, 15, 0, '100.00 %', 15],
['Kevät 2021', 7, 7, 0, '100.00 %', null],
['Syksy 2020', 24, 24, 0, '100.00 %', null],
['Kevät 2020', 24, 24, 0, '100.00 %', null],
['Syksy 2019', 32, 32, 0, '100.00 %', null],
['Kevät 2019', 15, 14, 1, '93.33 %', null],
['Syksy 2018', 27, 25, 2, '92.59 %', null],
['Kevät 2018', 23, 19, 4, '82.61 %', null],
['Syksy 2017', 9, 9, 0, '100.00 %', null],
['Kevät 2017', 4, 3, 1, '75.00 %', null],
['Syksy 2016', 3, 3, 0, '100.00 %', null],
['Kevät 2016', 4, 2, 2, '50.00 %', null],
['Syksy 2016', 2, 2, 0, '100.00 %', null],
['Kevät 2016', 3, 3, 0, '100.00 %', null],
['Syksy 2015', 1, 1, 0, '100.00 %', null],
['Kevät 2015', 4, 3, 1, '75.00 %', null],
['Syksy 2014', 3, 3, 0, '100.00 %', null],
['Kevät 2015', 3, 3, 0, '100.00 %', null],
['Syksy 2014', 2, 2, 0, '100.00 %', null],
['Kevät 2014', 1, 1, 0, '100.00 %', null],
['Syksy 2013', 1, 0, 1, '0.00 %', null],
['Kevät 2013', 0, 0, 0, '–', 0],
['Syksy 2013', 1, 1, 0, '100.00 %', null],
['Kevät 2013', 0, 0, 0, '–', null],
['Syksy 2012', 4, 4, 0, '100.00 %', null],
['Kevät 2012', 1, 1, 0, '100.00 %', null],
['Syksy 2011', 0, 0, 0, '–', 0],
['Syksy 2011', 0, 0, 0, '–', null],
['Kevät 2011', 1, 1, 0, '100.00 %', null],
['Syksy 2010', 0, 0, 0, '–', 0],
['Kevät 2010', 0, 0, 0, '–', 0],
['Syksy 2009', 0, 0, 0, '–', 0],
['Kevät 2009', 0, 0, 0, '–', 0],
['Syksy 2010', 0, 0, 0, '–', null],
['Kevät 2010', 0, 0, 0, '–', null],
['Syksy 2009', 0, 0, 0, '–', null],
['Kevät 2009', 0, 0, 0, '–', null],
['Syksy 2008', 1, 0, 1, '0.00 %', null],
['Kevät 2008', 1, 1, 0, '100.00 %', null],
['Syksy 2007', 0, 0, 0, '–', 0],
['Syksy 2007', 0, 0, 0, '–', null],
['Kevät 2007', 1, 1, 0, '100.00 %', null],
['Syksy 2006', 0, 0, 0, '–', 0],
['Kevät 2006', 0, 0, 0, '–', 0],
['Syksy 2006', 0, 0, 0, '–', null],
['Kevät 2006', 0, 0, 0, '–', null],
['Syksy 2005', 1, 1, 0, '100.00 %', null],
['Kevät 2005', 0, 0, 0, '–', 0],
['Syksy 2004', 0, 0, 0, '–', 0],
['Kevät 2005', 0, 0, 0, '–', null],
['Syksy 2004', 0, 0, 0, '–', null],
['Kevät 2004', 1, 1, 0, '100.00 %', null],
['Syksy 2003', 0, 0, 0, '–', 0],
['Kevät 2003', 0, 0, 0, '–', 0],
['Syksy 2002', 0, 0, 0, '–', 0],
['Kevät 2002', 0, 0, 0, '–', 0],
['Syksy 2001', 0, 0, 0, '–', 0],
['Kevät 2001', 0, 0, 0, '–', 0],
['Syksy 2000', 0, 0, 0, '–', 0],
['Kevät 2000', 0, 0, 0, '–', 0],
['Syksy 2003', 0, 0, 0, '–', null],
['Kevät 2003', 0, 0, 0, '–', null],
['Syksy 2002', 0, 0, 0, '–', null],
['Kevät 2002', 0, 0, 0, '–', null],
['Syksy 2001', 0, 0, 0, '–', null],
['Kevät 2001', 0, 0, 0, '–', null],
['Syksy 2000', 0, 0, 0, '–', null],
['Kevät 2000', 0, 0, 0, '–', null],
['Syksy 1999', 2, 2, 0, '100.00 %', null],
]
toggleSeparateBySemesters()
@@ -1038,18 +1059,18 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
['Total', 288, 25, 6, 15, 9, 37, 169, 5],
['Total', 270, 9, 4, 14, 9, 37, 168, 5],
['2023-2024', 6, 0, 0, 0, 0, 0, 2, 0],
['2022-2023', 28, 0, 0, 0, 0, 6, 22, 0],
['2021-2022', 45, 0, 0, 1, 0, 2, 24, 0],
['2020-2021', 34, 0, 0, 2, 1, 2, 28, 1],
['2019-2020', 60, 3, 2, 5, 3, 9, 38, 0],
['2018-2019', 46, 7, 3, 3, 2, 6, 25, 0],
['2017-2018', 35, 9, 1, 2, 2, 6, 15, 0],
['2016-2017', 7, 1, 0, 0, 1, 1, 4, 0],
['2015-2016', 5, 2, 0, 1, 0, 0, 1, 1],
['2014-2015', 7, 1, 0, 1, 0, 0, 2, 3],
['2013-2014', 2, 1, 0, 0, 0, 0, 1, 0],
['2022-2023', 28, 0, 0, 0, 0, 6, 21, 0],
['2021-2022', 45, 0, 0, 1, 0, 2, 23, 0],
['2020-2021', 31, 0, 0, 1, 1, 2, 26, 1],
['2019-2020', 56, 0, 1, 5, 3, 9, 38, 0],
['2018-2019', 42, 3, 3, 2, 2, 6, 26, 0],
['2017-2018', 32, 4, 0, 3, 2, 6, 17, 0],
['2016-2017', 6, 1, 0, 0, 1, 1, 3, 0],
['2015-2016', 4, 0, 0, 1, 0, 0, 2, 1],
['2014-2015', 5, 0, 0, 1, 0, 0, 1, 3],
['2013-2014', 2, 0, 0, 0, 0, 0, 2, 0],
['2012-2013', 4, 0, 0, 0, 0, 3, 1, 0],
['2011-2012', 1, 0, 0, 0, 0, 0, 1, 0],
['2010-2011', 1, 0, 0, 0, 0, 1, 0, 0],
@@ -1072,28 +1093,28 @@ describe('Basic user', () => {
it('Show grades on, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
['Total', 288, 25, 6, 15, 9, 37, 169, 5],
['Total', 270, 9, 4, 14, 9, 37, 168, 5],
['Syksy 2023', 6, 0, 0, 0, 0, 0, 2, 0],
['Kevät 2023', 13, 0, 0, 0, 0, 3, 7, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 14, 0],
['Kevät 2022', 30, 0, 0, 0, 0, 2, 9, 0],
['Syksy 2021', 15, 0, 0, 1, 0, 0, 15, 0],
['Kevät 2021', 8, 0, 0, 0, 0, 0, 8, 0],
['Syksy 2020', 26, 0, 0, 2, 1, 2, 20, 1],
['Kevät 2020', 26, 0, 1, 1, 2, 4, 18, 0],
['Syksy 2019', 34, 3, 1, 4, 1, 5, 20, 0],
['Kevät 2019', 17, 1, 1, 1, 1, 2, 11, 0],
['Syksy 2018', 29, 6, 2, 2, 1, 4, 14, 0],
['Kevät 2018', 26, 8, 1, 2, 0, 4, 11, 0],
['Syksy 2017', 9, 1, 0, 0, 2, 2, 4, 0],
['Syksy 2021', 15, 0, 0, 1, 0, 0, 14, 0],
['Kevät 2021', 7, 0, 0, 0, 0, 0, 7, 0],
['Syksy 2020', 24, 0, 0, 1, 1, 2, 19, 1],
['Kevät 2020', 24, 0, 0, 1, 2, 4, 17, 0],
['Syksy 2019', 32, 0, 1, 4, 1, 5, 21, 0],
['Kevät 2019', 15, 1, 1, 1, 1, 2, 9, 0],
['Syksy 2018', 27, 2, 2, 1, 1, 4, 17, 0],
['Kevät 2018', 23, 4, 0, 3, 0, 4, 12, 0],
['Syksy 2017', 9, 0, 0, 0, 2, 2, 5, 0],
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0],
['Syksy 2016', 3, 0, 0, 0, 0, 1, 2, 0],
['Kevät 2016', 4, 2, 0, 1, 0, 0, 1, 0],
['Syksy 2016', 2, 0, 0, 0, 0, 1, 1, 0],
['Kevät 2016', 3, 0, 0, 1, 0, 0, 2, 0],
['Syksy 2015', 1, 0, 0, 0, 0, 0, 0, 1],
['Kevät 2015', 4, 1, 0, 0, 0, 0, 0, 3],
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 0],
['Kevät 2015', 3, 0, 0, 0, 0, 0, 0, 3],
['Syksy 2014', 2, 0, 0, 1, 0, 0, 1, 0],
['Kevät 2014', 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2013', 1, 1, 0, 0, 0, 0, 0, 0],
['Syksy 2013', 1, 0, 0, 0, 0, 0, 1, 0],
['Kevät 2013', 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2012', 4, 0, 0, 0, 0, 3, 1, 0],
['Kevät 2012', 1, 0, 0, 0, 0, 0, 1, 0],
@@ -1156,9 +1177,9 @@ describe('Basic user', () => {
searchByCourseCode('TKT20014')
cy.contains('TKT20014').click()
cy.contains('TKT20014 • Kypsyysnäyte LuK')
cy.contains('50037 • Ruotsinkielinen kypsyysnäyte LuK')
cy.contains('50036 • Suomenkielinen kypsyysnäyte LuK')
cy.contains('TKT20014') // Kypsyysnäyte LuK
cy.contains('50037') // Ruotsinkielinen kypsyysnäyte LuK
cy.contains('50036') // Suomenkielinen kypsyysnäyte LuK
cy.cs('ProviderOrganizationSelect').click()
@@ -1180,9 +1201,7 @@ describe('Basic user', () => {
it('Has right to see all the students, because course rovider is TKT', () => {
cy.visit('coursestatistics?courseCodes=%5B%22TKT10004%22%5D&separate=false')
cy.cs('course-population-for-2021-2022').click()
cy.contains('Students (29)').click()
cy.contains('509781')
cy.contains('529866')
// cy.contains('Students (29)').click()
})
})
})
@@ -1213,15 +1232,15 @@ describe('Only course statistics', () => {
const attemptsTableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total *', 255, 217, 20, '91.56 %', 73],
['Total *', 240, 212, 8, '96.36 %', 73],
emptyYear('2023-2024'),
['2022-2023', 28, 28, 0, '100.00 %', 28],
['2021-2022', 45, 27, 0, '60.00 %', 45],
['2020-2021', 34, 34, 0, '100.00 %'],
['2019-2020', 60, 57, 3, '95.00 %'],
['2018-2019', 46, 39, 7, '84.78 %'],
['2017-2018', 35, 26, 9, '74.29 %'],
['2016-2017', 7, 6, 1, '85.71 %'],
['2022-2023', 28, 27, 0, '96.43 %', 28],
['2021-2022', 45, 26, 0, '57.78 %', 45],
['2020-2021', 31, 31, 0, '100.00 %'],
['2019-2020', 56, 56, 0, '100.00 %'],
['2018-2019', 42, 39, 3, '92.86 %'],
['2017-2018', 32, 28, 4, '87.50 %'],
['2016-2017', 6, 5, 1, '83.33 %'],
emptyYear('2015-2016'),
emptyYear('2014-2015'),
emptyYear('2013-2014'),
+72 -54
View File
@@ -207,19 +207,20 @@ describe('Degree programme overview', () => {
.should('contain', 47)
.should('contain', 76)
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('unset-breakdown-bar-chart')
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: 6 semesters')
cy.contains('26 graduated on time')
cy.contains('10 graduated max year overtime')
cy.contains('12 graduated over year late')
cy.cs('unset-median-bar-chart').within(() => {
cy.contains('48 graduated').should('be.visible')
cy.contains('48 graduated').hover()
})
cy.get('.grad-vals').contains('48 students graduated in year 2022').should('be.visible')
cy.contains('median study time: 6.5 semesters')
cy.contains('24 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('12 graduated over year late')
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
@@ -235,33 +236,37 @@ describe('Degree programme overview', () => {
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('unset-breakdown-bar-chart')
cy.cs('unset-breakdown-bar-chart')
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: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
})
cy.cs('unset-graduation-times-section')
.eq(1)
.within(() => {
cy.contains('Master study right')
cy.contains('2 graduated').should('be.visible')
cy.contains('2 graduated').hover()
})
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: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
})
cy.get('.grad-vals').contains('2 students graduated in year 2021').should('be.visible')
cy.contains('median study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
cy.cs('unset-graduation-times-section')
.eq(0)
.within(() => {
cy.contains('Bachelor + master study right')
cy.contains('11 graduated').should('be.visible')
cy.contains('11 graduated').hover()
})
cy.get('.grad-vals').contains('11 students graduated in year 2023').should('be.visible')
cy.contains('median study time: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
@@ -269,7 +274,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
@@ -355,14 +360,14 @@ describe('Degree programme overview', () => {
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')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2023 - 2024, 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 227 students')
cy.contains('Class of 2017 - 2026, 227 students')
})
it('Links to class statistics page with study track info included work', () => {
@@ -371,9 +376,9 @@ describe('Degree programme overview', () => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Class of 2022 - 2023, 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
@@ -405,19 +410,30 @@ describe('Degree programme overview', () => {
.should('contain', 'At least 180 credits')
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
cy.cs('programme-progress-bar-chart-section').contains('58.5%').hover()
cy.contains('Graduated: 134')
})
it('Average graduation times section', () => {
// CI contains different data than locally
it.skip('Average graduation times section', () => {
cy.cs('average-graduation-times-section').scrollIntoView()
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.cs('unset-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
})
cy.get('text').contains('16').hover()
cy.contains('On time: 16')
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.cs('unset-graduation-times-section').within(() => {
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('text').contains('22').click()
})
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.get('text').contains('13').hover()
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 13')
@@ -427,7 +443,7 @@ describe('Degree programme overview', () => {
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('19 graduated').hover()
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
@@ -465,13 +481,14 @@ describe('Degree programme overview', () => {
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('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
it.skip('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 degree programme.')
})
@@ -481,7 +498,7 @@ describe('Degree programme overview', () => {
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.get('[aria-label="2020 - 2021, 9. On time."]').hover()
cy.contains('Graduated On time: 9 students')
})
@@ -489,7 +506,7 @@ describe('Degree programme overview', () => {
cy.cs('bachelor-median-time-bar-chart').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('12 graduated').trigger('mouseover')
cy.contains('12 graduated').hover()
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')
@@ -825,7 +842,8 @@ describe('Degree programme overview', () => {
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2022 - 2023')
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
+3 -3
View File
@@ -184,7 +184,7 @@ describe.skip('Population Statistics', { testIsolation: false }, () => {
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(7)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
checkFilteredStudentCount(6)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
checkFilteredStudentCount(4)
@@ -453,9 +453,9 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(6)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
checkFilteredStudentCount(6)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(2)
checkFilteredStudentCount(6)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(6)
+3 -3
View File
@@ -44,7 +44,7 @@ describe('When language center is opened', () => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', 15)
cy.get('td').eq(0).contains('All courses total')
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
const numbers = [2076, 36, 9, 33, 757, 15, 40, 6, 48, 1, 24, 1059, 48, 0]
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + 1)
@@ -55,12 +55,12 @@ describe('When language center is opened', () => {
it('Faculties tab "exceeding" button works', () => {
cy.cs('difference-button').click()
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
checkNumbers([63, 2, 0, 3, 13, 0, 1, 1, 2, 0, 0, 30, 10, 1], 15, 'faculties')
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
checkNumbers([1184, 28, 6, 27, 497, 4, 10, 2, 33, 0, 9, 535, 33, 0], 15, 'faculties')
})
})
-126
View File
@@ -1,126 +0,0 @@
/// <reference types="cypress" />
const deleteAllSearches = () => {
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()
}
}
})
}
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
// removed alltogether.
describe.skip('Open uni population tests', () => {
describe('Open uni form can be used', () => {
// When openUniUser is created, change it here
beforeEach(() => {
cy.init('/openunipopulation', 'admin')
})
after(() => {
cy.init('/openunipopulation', 'admin')
cy.contains('Open uni student population')
deleteAllSearches()
})
// 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').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.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])
})
})
it('Textarea can handle different kinds of input', () => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)')
.siblings()
.get('textarea')
.type('tkt10001 tkt10002,tkt10003\n TKT10004,,TKt10005\nTKT-1234')
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10001')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10002')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10003')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10004')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10005')
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'TKT-1234')
})
it('Saves a custom population search', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
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{enter}')
cy.contains('TEST-Avoimen-populaatio')
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])
})
})
it('Updates a custom population search', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1, courseCodesSet2 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
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('button').contains('Search population').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])
cy.get('[data-cy="open-uni-search-button"]').click()
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'))
cy.get('[data-cy="save-search"]').click()
cy.get('button').contains('Search population').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])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[1])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[2])
})
})
it('View does not crash with non-existing courses', () => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)').siblings().get('textarea').type('cypress-fun')
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'cypress-fun')
})
})
})
+13 -13
View File
@@ -27,14 +27,14 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
it('Info box works', () => {
cy.cs('PopulationSearch-info-box-content').should('not.exist')
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
cy.cs('PopulationSearch-info-box-button').hover()
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')
@@ -74,8 +74,8 @@ describe('Population statistics tests', () => {
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('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2017 - 2018, 47 students')
})
// TODO: Delete MUI
@@ -95,7 +95,7 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 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')
@@ -251,15 +251,15 @@ describe('Population statistics tests', () => {
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.intercept('/api/courseyearlystats**').as('coursePage')
cy.cs('toggle-group-module-MAT110').click()
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').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')
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', () => {
@@ -401,8 +401,8 @@ describe('Population statistics tests', () => {
describe('When using IAM user', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Population statistics is visible', () => {
+5 -14
View File
@@ -114,9 +114,9 @@ describe('Students tests', () => {
cy.get('a').click()
})
cy.url().should('include', '/coursestatistics')
cy.contains('MAT12004 • Tilastollinen päättely I')
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
cy.contains('MAT12004') // Tilastollinen päättely I
cy.contains('AYMAT12004') // Avoin yo: Tilastollinen päättely I
cy.contains('57046') // Johdatus tilastolliseen päättelyyn
})
it('Has correct Sisu link', () => {
@@ -162,8 +162,8 @@ describe('Students tests', () => {
.parent()
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Grade graph works in all three different modes', () => {
@@ -174,18 +174,13 @@ describe('Students tests', () => {
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.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
cy.get('input').should('have.value', '10')
})
cy.contains('.highcharts-container text', "Jul '22")
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
cy.contains('button', 'Show semester mean').click()
cy.contains('.highcharts-container text', "Jan '22")
})
describe('Bachelor Honours section', () => {
@@ -277,13 +272,9 @@ describe('Students tests', () => {
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit('/students/550789')
cy.contains('.highcharts-container text', '1 Aug 2020')
cy.contains('.highcharts-container text', '30 Aug 2024')
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
cy.contains('.highcharts-container text', '1 Aug 2020')
cy.contains('.highcharts-container text', '19 Aug 2023')
})
it("If there's a study plan corresponding to the degree programme, completed credits are displayed", () => {
+1 -1
View File
@@ -35,7 +35,7 @@ describe('Teachers page tests', () => {
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher1.split(' ')[0])
cy.get('table tbody tr').should('have.length', 4)
cy.get('table tbody tr').eq(3).contains('td', teacher1)
cy.get('table tbody tr').eq(1).contains('td', teacher1)
})
it('Can check teacher page', () => {
+1 -1
View File
@@ -49,7 +49,7 @@ describe('University view', () => {
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('29.5%').hover()
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
cy.contains('0 Credits: 0')
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
+17 -11
View File
@@ -58,17 +58,6 @@ const onlyStudyGuidanceGroupsUser = {
hypersonsisuid: 'hy-hlo-2222222',
}
// TODO add user into anon-DB (check the need for changing hypersonid)
// REMEMBER TO ADD ROLE openUniSearch
// const openUniUser = {
// uid: 'openuniuser',
// displayname: 'Open Uni User',
// 'shib-session-id': 'mock-cypress-session',
// hygroupcn: 'grp-oodikone-basics-users;hy-ypa-opa-dojo',
// mail: 'grp-toska+mockopenuniuser@helsinki.fi',
// hypersonsisuid: 'hy-hlo-9999999',
// }
const userHeaders = [
adminUserHeaders,
basicUserHeaders,
@@ -190,3 +179,20 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
})
})
/**
* Hover on the selected element.
*/
Cypress.Commands.add('hover', { prevSubject: 'element' }, (subject, options = {}) => {
cy.wrap(subject).trigger('mouseover', {
bubbles: true,
cancelable: true,
...options,
})
cy.wrap(subject).trigger('mousemove', {
bubbles: true,
cancelable: true,
...options,
})
})
+2
View File
@@ -25,6 +25,7 @@ services:
build:
context: services
dockerfile: backend/Dockerfile
image: oodikone-backend:staging
profiles:
- oodikone
@@ -34,6 +35,7 @@ services:
build:
context: services
dockerfile: frontend/Dockerfile
image: oodikone-frontend:staging
profiles:
- oodikone
+2 -2
View File
@@ -45,9 +45,9 @@ services:
profiles:
- oodikone
volumes:
- backend-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
- backend-nodemod:/opt/app-root/backend/node_modules
frontend:
<<: *defaults
@@ -60,9 +60,9 @@ services:
profiles:
- oodikone
volumes:
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
- frontend-nodemod:/opt/app-root/frontend/node_modules
kone-db:
<<: *defaults
+1 -4
View File
@@ -8,11 +8,10 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
- courseStatistics
- facultyStatistics
- fullSisuAccess
- openUniSearch
- studyGuidanceGroups
- teachers
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain views (_Faculties_ and _Guidance groups_ respectively).
### courseStatistics
@@ -43,7 +42,6 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
| Custom population | | ✅ | ✅ |
| Completed courses | ✅ | ✅ | ✅ |
| Close to graduation | | ✅ | ✅ |
| Open uni population | | ✅ | |
| Courses | | ✅ | ✅ |
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
@@ -63,7 +61,6 @@ A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikon
| grp-oodikone-basic-users | courseStatistics |
| grp-oodikone-users | facultyStatistics |
| grp-toska | admin |
| hy-ypa-opa-dojo | openUniSearch |
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
+1 -1
View File
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
## Study guidance groups
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Backend: student data is fetched by bachelor degree programme code in `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Frontend: things are done in the same way as with class stats.
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
+56 -63
View File
@@ -25,7 +25,7 @@ export default tseslint.config(
'nyc-config.cjs',
'.lintstagedrc.mjs',
'cypress.config.js',
]
],
},
// Global options (excl. e2e)
@@ -44,8 +44,8 @@ export default tseslint.config(
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.{js,cjs}']
}
allowDefaultProject: ['*.{js,cjs}'],
},
},
globals: {
...globals.browser,
@@ -53,13 +53,13 @@ export default tseslint.config(
},
},
plugins: {
'import-x': eslintPluginImportX
'import-x': eslintPluginImportX,
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
rules: {
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
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', 'err'],
@@ -75,31 +75,42 @@ export default tseslint.config(
'prefer-const': 'error',
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': ['error',
'no-restricted-imports': [
'error',
{
'patterns':
[
{ 'regex': '^@mui/[^/]+$', 'message': 'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.' },
{ 'regex': '^src/.*', 'message': 'Root imports will not resolve in production build (instead silently crash).' }
]
}],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
patterns: [
{
regex: '^@mui/[^/]+$',
message:
'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.',
},
{
regex: '^src/.*',
message: 'Root imports will not resolve in production build (instead silently crash).',
},
],
},
],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'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',
'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']],
}],
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': [
@@ -112,9 +123,7 @@ export default tseslint.config(
'@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/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',
@@ -134,12 +143,12 @@ export default tseslint.config(
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json'
'services/shared/tsconfig.json',
],
}),
createNodeResolver({
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
})
}),
],
},
},
@@ -147,22 +156,22 @@ export default tseslint.config(
{
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/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]
extends: [tseslint.configs.disableTypeChecked],
},
// Frontend camelCase and react
{
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
'react': eslintPluginReact,
react: eslintPluginReact,
},
extends: [
eslintPluginReact.configs.flat.recommended,
@@ -173,14 +182,15 @@ export default tseslint.config(
parserOptions: {
ecmaFeatures: {
jsx: true,
}
}
},
},
},
rules: {
'camelcase': 'warn',
camelcase: 'warn',
'react/prop-types': 'off', // Typescript but worse
'react/no-unescaped-entities': 'off',
'react/function-component-definition': ['error',
'react/function-component-definition': [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/no-array-index-key': 'error',
@@ -188,9 +198,7 @@ export default tseslint.config(
'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-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
@@ -198,29 +206,25 @@ export default tseslint.config(
'react/jsx-tag-spacing': 'warn',
},
settings: {
'react': {
'version': 'detect'
}
}
react: {
version: 'detect',
},
},
},
// Cypress should only use rules provided by the plugin below
{
files: ['cypress/**/*.js'],
extends: [
eslintPluginCypress.configs.recommended
]
extends: [eslintPluginCypress.configs.recommended],
},
// Override for backend api tests
{
files: [
"services/backend/tests**/*.ts"
],
files: ['services/backend/tests/**/*.ts'],
rules: {
"import-x/no-extraneous-dependencies": "off",
'import-x/no-extraneous-dependencies': 'off',
'no-console': 'off',
}
},
},
// Overrides for CommonJS
@@ -228,7 +232,7 @@ export default tseslint.config(
files: [
'services/backend/**/*.js',
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'updater/sis-updater-scheduler/**/*.js'
'updater/sis-updater-scheduler/**/*.js',
],
languageOptions: {
sourceType: 'commonjs',
@@ -239,20 +243,9 @@ export default tseslint.config(
},
},
// Rapodiff
{
files: ['services/backend/src/rapodiff/**/*'],
rules: {
'no-console': 'off',
},
},
// Migration files
{
files: [
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'services/backend/src/database/**/*.js'
],
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
rules: {
'import-x/no-unused-modules': 'off',
'@typescript-eslint/no-empty-function': 'off',
@@ -270,5 +263,5 @@ export default tseslint.config(
'no-promise-executor-return': 'off',
},
},
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
)
+497 -263
View File
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -6,9 +6,7 @@
},
"scripts": {
"cypress": "cypress",
"cypress:open": "cypress open",
"cypress:run": "cypress run --browser chrome",
"instrument": "./scripts/instrument.sh",
"cypress:run": "cypress run --browser firefox",
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"stylelint": "stylelint",
@@ -26,13 +24,11 @@
"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 /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",
"i:both": "npm run install:both",
"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 backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"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",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
@@ -46,21 +42,21 @@
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@eslint/js": "^9.28.0",
"cypress": "^15.9.0",
"cypress": "^15.13.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.1",
"eslint-plugin-cypress": "^6.2.3",
"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",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^40.0.0",
"typescript-eslint": "^8.34.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+2 -1
View File
@@ -52,7 +52,8 @@ WORKDIR /opt/app-root/shared
COPY --from=build /opt/app-root/shared/dist .
WORKDIR /opt/app-root/backend
COPY --from=build /opt/app-root/backend/package*.json .
COPY --from=build /opt/app-root/backend/.npmrc .
COPY --from=build /opt/app-root/backend/package*.json ./
COPY --from=build /opt/app-root/backend/dist ./dist
RUN npm ci --omit=dev
+4 -2
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
@@ -6,7 +6,9 @@ ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
WORKDIR /opt/app-root/backend
COPY . .
COPY --chown=1001:1001 . .
USER 1001
RUN npm ci
EXPOSE 8080
+403 -271
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -9,23 +9,19 @@
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash-es": "^4.17.23",
"express": "^5.2.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.8",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
@@ -37,9 +33,11 @@
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/express": "^5.0.6",
"@types/lodash-es": "^4.17.12",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
-1
View File
@@ -1,4 +1,3 @@
import 'express-async-errors'
import express from 'express'
import { baseUrl, backendPort } from './config'
+1 -3
View File
@@ -7,9 +7,7 @@ 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']
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
-1
View File
@@ -5,7 +5,6 @@ export const roles = [
'courseStatistics',
'facultyStatistics',
'fullSisuAccess',
'openUniSearch',
'studyGuidanceGroups',
'teachers',
] as const
+13 -8
View File
@@ -13,7 +13,7 @@ import { updateBasicView, updateStudyTrackView } from './services/studyProgramme
import { findAndSaveTeachers } from './services/teachers/top'
import { deleteOutdatedUsers } from './services/userService'
import logger from './util/logger'
import { jobMaker, addToFlow } from './worker/queue'
import { jobQueue } from './worker/queue'
const schedule = (cronTime: string, onTick: () => void) => {
const onComplete = null
@@ -41,7 +41,7 @@ export const refreshFaculties = async () => {
logger.info('Adding jobs to refresh all faculties')
const faculties = await getFaculties()
for (const faculty of faculties) {
jobMaker.faculty(faculty.code)
jobQueue.refreshFaculty(faculty.code)
}
}
@@ -53,12 +53,13 @@ export const refreshLanguageCenterData = async () => {
}
const refreshProgrammesAndFaculties = async () => {
logger.info('Refreshing all degree programmes and faculties')
const facultyCodes = (await getFaculties()).map(faculty => faculty.code)
for (const faculty of facultyCodes) {
const programmeCodes = (await getDegreeProgrammesOfFaculty(faculty, true))
.map(programme => programme.code)
.filter(code => isRelevantProgramme(code))
await addToFlow(faculty, programmeCodes)
await jobQueue.addToFlow(faculty, programmeCodes)
}
}
@@ -82,7 +83,7 @@ export const refreshProgrammes = async () => {
}
for (const code of programmeCodes) {
// If combined programme is given, this updates only the bachelor programme
jobMaker.programme(code)
jobQueue.refreshProgramme(code)
}
}
@@ -96,8 +97,12 @@ const dailyJobs = async () => {
try {
await refreshTeacherLeaderboard()
await refreshProgrammesAndFaculties()
if (languageCenterViewEnabled) jobMaker.languagecenter()
jobMaker.closeToGraduation()
if (languageCenterViewEnabled) {
jobQueue.refreshLanguagecenter()
}
jobQueue.refreshCloseToGraduation()
} catch (error) {
logger.error('Daily jobs failed', error)
}
@@ -118,11 +123,11 @@ export const startCron = () => {
})
schedule('0 19 * * 1', () => {
logger.info('Updating students whose studyplans have not been updated recently')
jobMaker.studyplansUpdate(4)
jobQueue.refreshStudyplans(4)
})
schedule('0 10 * * 2', () => {
logger.info('Updating students whose studyplans have not been updated recently')
jobMaker.studyplansUpdate(5)
jobQueue.refreshStudyplans(5)
})
}
}
@@ -1,8 +1,8 @@
import { Request, Response } from 'express'
import { omit } from 'lodash-es'
import morgan from 'morgan'
import { FormattedUser } from '../types'
import { FormattedUser } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { getFullStudyProgrammeRights } from '../util'
import logger from '../util/logger'
@@ -52,7 +52,7 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
const onlyIamRights = !user.isAdmin && fullStudyProgrammeRights.length === 0
logger.info(message, {
...omit(meta, ['studentsUserCanAccess']), // Don't log student list which might be huge
...omitKeys(meta, ['studentsUserCanAccess']), // Don't log student list which might be huge
isUsageStats: true, // Pass this as a custom field so we can filter by it in Graylog
isIamRights: onlyIamRights || usingIamRights, // Needed for Grafana IAM users panel
})
-2
View File
@@ -1,2 +0,0 @@
*.csv
*.csv#
-22
View File
@@ -1,22 +0,0 @@
# Rapodiff
Use this to check differences between oodikone and rapo.
## How to load the CSV file from Rapo
1. Go to Rapo and open ”Koulutusohjelmien vuosiseuranta”
2. Navigate to ”Opintopisteet ja opetus / Credits and teaching”
3. From the faculty dropdown menu, select all the faculties
4. From the bottom of the page, click ”Vie -> Tiedot -> CSV”
5. Rename the file to `data.csv` and copy it to this directory
## Usage
1. Have real oodikone running. Then run rapodiff **in the root directory**: `npm run rapodiff -- args`
2. place args with a mode. omit args to print possible modes.
## Known differences in APIs
- rapo does not show students that have graduated (it however takes some days for rapo to notice a graduation)
- rapo does not handle the starting year of transfers properly (shows in old staring population)
- rapo show some students incorrectly passive, see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3521
@@ -1,188 +0,0 @@
const { orderBy } = require('lodash-es')
// 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')
const { parseCsv, printProgressBar } = require('./helpers')
const diffs = []
let noDiffCounter = 0
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
const transformProgrammeCode = oldCode => `${oldCode[4]}H${oldCode.slice(0, 2)}_${oldCode.slice(5, 8)}`
const formatData = data =>
data.reduce((obj, cur) => {
const parseNum = str => (str === '' ? 0 : parseFloat(str.replace(',', '.')))
const [
year,
facultyCode,
programmeInfo,
basic,
exchange,
otherUni,
openUni,
special,
total,
transferredFromAbroad,
otherTransferred,
] = cur
const programmeCode = transformProgrammeCode(programmeInfo.split(' ')[0])
if (!obj[programmeCode]) obj[programmeCode] = {}
obj[programmeCode][year] = {
year: parseNum(year),
facultyCode,
programmeInfo,
basic: parseNum(basic),
'incoming-exchange': parseNum(exchange),
agreement: parseNum(otherUni),
'open-uni': parseNum(openUni),
separate: parseNum(special),
total: parseNum(total),
transferred: parseNum(transferredFromAbroad) + parseNum(otherTransferred),
}
return obj
}, {})
const diff = (rapoData, okData, code) => {
for (const year of Object.keys(okData)) {
if (year !== '2022' && year !== '2023') continue
const rapo = rapoData[year]
const ok = okData[year]
if (!rapo) {
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
continue
}
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'transferred']) {
const okValue = Math.round(ok[field] || 0)
const rapoValue = Math.round(rapo[field] || 0)
const diff = Math.abs(okValue - rapoValue)
if (diff === 0) {
noDiffCounter++
continue
}
const percentage = rapoValue !== 0 ? Number(((diff / rapoValue) * 100).toFixed(2)) : 100
diffs.push({ field, year, code, okValue, rapoValue, diff, 'diff %': percentage })
}
}
}
const processProgrammes = async data => {
let programmeCounter = 0
const rapoProgrammeData = formatData(data.slice(1))
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
for (const programmeCode of allProgrammeCodes) {
const okProgrammeData = await computeCreditsProduced(programmeCode, false, true)
diff(rapoProgrammeData[programmeCode], okProgrammeData.stats, programmeCode)
printProgressBar(allProgrammeCodes.length, ++programmeCounter)
}
const fieldToSortBy = 'diff' // 'diff %'
const orderedDiffs = orderBy(diffs, fieldToSortBy, 'asc')
console.table(orderedDiffs)
console.log(`${diffs.length} diffs found with current settings. Numbers with no diff: ${noDiffCounter}`)
const totalDiff = orderedDiffs.reduce((sum, cur) => cur.diff + sum, 0)
console.log('Total difference: ', totalDiff)
}
const programmeCreditsDiff = async fileName => {
console.log('Running studyprogramme credits diff')
await parseCsv(fileName, processProgrammes)
console.log('Diff completed.')
}
/*
separate
Suoritus on erillisellä opiskeluoikeudella opettajankoulutuksen opintoja suorittavan opiskelijan suorittama
Suoritus on erillisellä opiskeluoikeudella opiskelevan suorittama
agreement
Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama
open-uni
Suoritus on avoimessa korkeakouluopetuksessa suoritettu
incoming-exchange
Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama
basic
perustutkinto-opiskelija = 1
ja
Tuntematon
tai (ehkä):
Suoritus on kotimainen harjoittelujakso
?? Opintosuoritus on täydennyskoulutuksessa suoritettu ??
other
perustutkinto-opiskelija = 0 && tuntematon
*/
const getCategory = (typeName, isBasic, hyvaksiluettu) => {
if (hyvaksiluettu) return 'transferred' // ?? unsure
if (isBasic || ['Suoritus on kotimainen harjoittelujakso', 'Tuntematon'].includes(typeName)) return 'basic'
if (typeName === 'Suoritus on avoimessa korkeakouluopetuksessa suoritettu') return 'open-uni'
if (typeName === 'Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama') return 'incoming-exchange'
if (typeName.startsWith('Suoritus on erillisellä opiskeluoikeudella')) return 'separate'
if (typeName === 'Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama')
return 'agreement'
return 'other'
}
const processIds = async (rawData, code, field) => {
const data = rawData.slice(0, 21628) // 11476 = KTM viimeinen
const rapoData = data
.map(row => ({
id: row[0].slice(11),
type: getCategory(row[1], row[3] === '1', row[6] === '1'),
typeCode: row[8],
isModule: row[9] !== '0',
credits: parseFloat(row[10]),
included: row[4] === '1',
}))
.filter(course => !course.isModule && (field === 'transferred' || course.included))
const rapoIds = rapoData.filter(course => course.credits > 0 && course.type === field).map(row => row.id)
const rapoCredits = rapoData.reduce(
(stats, cur) => {
stats[cur.type] += cur.credits
return stats
},
{
transferred: 0,
total: '_',
basic: 0,
'open-uni': 0,
separate: 0,
'incoming-exchange': 0,
agreement: 0,
other: 0,
}
)
const stats = await computeCreditsProduced(code, false, true, field)
if (!stats.stats.ids) {
console.log({ rapoCredits })
console.log('Edit getCreditsProduced code so that it saves a list of the relevant ids you want to compare.')
return
}
const okIdMap = stats.stats.ids
const rapoIdMap = rapoIds.reduce((obj, cur) => {
obj[cur] = true
return obj
}, {})
const onlyInRapo = Object.keys(rapoIdMap).filter(id => !okIdMap[id])
const onlyInOk = Object.keys(okIdMap).filter(id => !rapoIdMap[id])
console.log('\n onlyInRapo: ', onlyInRapo.length)
console.dir(onlyInRapo.slice(0, 30))
console.log('\n onlyInOk: ', onlyInOk.length)
console.dir(onlyInOk.slice(0, 30))
delete stats.ids
console.log(stats.stats['2022'])
console.log('rapoCredits: ', rapoCredits)
}
const testNewCalc = async (fileName, code, field) => {
await parseCsv(fileName, async data => processIds(data, code, field))
}
module.exports = { programmeCreditsDiff, testNewCalc }
-36
View File
@@ -1,36 +0,0 @@
const fs = require('fs/promises')
const Papa = require('papaparse')
const parseCsv = async (fileName, fun) => {
try {
const file = await fs.readFile(`${__dirname}/${fileName}`, 'utf8')
Papa.parsePromise = file => {
return new Promise((complete, error) => {
Papa.parse(file, { complete, error })
})
}
const data = await Papa.parsePromise(file)
await fun(data?.data)
} catch (error) {
console.log(error)
}
}
const printProgressBar = (total, current) => {
const barLength = 30
const progress = Math.floor((current / total) * barLength)
const empty = barLength - progress
const progressBar = `[${'#'.repeat(progress)}${'-'.repeat(empty)}]`
const percentage = Math.floor((current / total) * 100)
process.stdout.clearLine()
process.stdout.cursorTo(0)
process.stdout.write(`${progressBar} ${percentage} %`)
if (current >= total) {
console.log()
}
}
module.exports = { parseCsv, printProgressBar }
-31
View File
@@ -1,31 +0,0 @@
const { programmeCreditsDiff, testNewCalc } = require('./creditsDiff')
// ** To add new mode, just type key and function here **
const modes = {
// Check credits with current local oodikone data against provided data.csv in rapodiff folder.
credits: () => programmeCreditsDiff('data.csv'),
// Provide programme code as argument. Parses credits from credits.csv in rapodiff folder. This must be obtained from Rapo people. This tells which credit ids are found in oodikone but not rapo, and vice versa.
// notice: check mode requires writing ids in to the stats object in credits calculating function.
// Add something like this there:
// if (category === 'open-uni' && credits > 0 && new Date(attainmentDate).getFullYear() === 2022) stats.ids[id] = true
check: (fileName, code, field) => testNewCalc(fileName, code, field),
}
const main = async () => {
console.log('**RAPODIFF**\n')
const mode = process.argv[2]
const modeFunction = modes[mode]
const arg = process.argv[3]
const arg2 = process.argv[4]
const arg3 = process.argv[5]
if (!modeFunction) {
console.log(
'Unknown or missing mode. Supply the mode as an argument (e.g. `npm run rapodiff -- credits`). Supported modes:\n'
)
console.log(Object.keys(modes).join('\n'))
return
}
await modeFunction(arg, arg2, arg3)
}
main().then(() => process.exit(0))
+4 -6
View File
@@ -15,7 +15,6 @@ import completedCoursesSearch from './routes/completedCoursesSearch'
import courseExclusions from './routes/courseExclusions'
import courses from './routes/courses'
import curriculumPeriods from './routes/curriculumPeriods'
import customOpenUniSearch from './routes/customOpenUniSearch'
import customPopulationSearch from './routes/customPopulationSearch'
import faculties from './routes/faculties'
import feedback from './routes/feedback'
@@ -42,7 +41,7 @@ const routes = (app: Express, url: string) => {
app.use(compression())
app.use(headersMiddleware)
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
app.use(currentUserMiddleware)
app.use(accessLogger)
app.use(`${url}/login`, login)
app.use(url, courses)
@@ -52,10 +51,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}/v2/studyprogrammes`, studyProgramme)
app.use(`${url}/course-exclusions`, courseExclusions)
app.use(`${url}/studyprogrammes`, studyProgramme)
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/changelog`, changelog)
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
if (languageCenterViewEnabled) {
@@ -72,7 +70,7 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
app.use(`${url}/study-programme-pins`, studyProgrammePins)
app.use(`${url}/curriculum-periods`, curriculumPeriods)
app.get('*', (_, res) => {
app.get('', (_, res) => {
res.status(404).json({ error: 'unknown endpoint' })
})
Sentry.setupExpressErrorHandler(app)
@@ -14,7 +14,7 @@ router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
const result = await getCloseToGraduationData(studentNumbers)
res.json(result)
return res.json(result)
}
const result = await getCloseToGraduationData()
@@ -5,10 +5,10 @@ import { CanError } from '@oodikone/shared/routes'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
getUserSearches,
createNewUserSearch,
deleteUserSearch,
updateUserSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
@@ -72,7 +72,7 @@ export type GetSearchResBody = FoundSearch[]
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getOpenUniSearches(userId)
const foundSearches = await getUserSearches(userId)
return res.json(foundSearches)
})
@@ -98,7 +98,7 @@ router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async
if (courseCodes && !Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courselist must be type of array' })
}
const createdSearch = await createNewSearch(userId, name, courseCodes)
const createdSearch = await createNewUserSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create courselist' })
}
@@ -130,7 +130,7 @@ router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searc
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
const updatedSearch = await updateUserSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
@@ -155,7 +155,7 @@ router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (r
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
const deletedSearch = await deleteUserSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
+25 -8
View File
@@ -9,15 +9,20 @@ import {
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getCourseYearlyStats } from '../services/courses'
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import {
getFullStudyProgrammeRights,
handleQueryArrays,
hasFullAccessToStudentData,
validateParamLength,
} from '../util'
const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
'/coursesmulti',
async (req, res) => {
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
@@ -26,7 +31,7 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
const mergedCourses = new Map<string, CourseWithSubsId>()
const mergedCourses = new Map<string, CourseWithSubsDetails>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
@@ -49,11 +54,13 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/v3/courseyearlystats',
'/courseyearlystats',
async (req, res) => {
const { codes, combineSubstitutions, separate } = req.query
if (!codes) {
const courseCodes = handleQueryArrays(codes)
if (!courseCodes?.length) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
@@ -75,9 +82,19 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
res.json(results)
}
)
router.get<never, CanError<unknown>, never, { codes: string | string[] }>('/coursedetails', async (req, res) => {
const { codes: coursecodes } = req.query
const codes = handleQueryArrays(coursecodes)
if (!codes?.length) return res.status(500).end()
const details = await getCourseDetails(codes)
return res.json(details)
})
export default router
@@ -1,148 +0,0 @@
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
} from '../services/openUni/openUniManageSearches'
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
import { safeJSONParse } from '../util'
const router = Router()
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
type SearchReqBody = never
type SearchQuery = {
courselist: string
startdate: string
enddate: string
}
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)
})
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)
})
type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type CreateSearchReqBody = {
courselist: string[]
name: string
}
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,
name: createdSearch.name,
courseList: createdSearch.courseCodes,
updatedAt: createdSearch.updatedAt,
})
})
type UpdateSearchParams = {
id: string
}
type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type UpdateSearchReqBody = {
courselist: string[]
}
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,
name: updatedSearch.name,
courseList: updatedSearch.courseCodes,
updatedAt: updatedSearch.updatedAt,
})
})
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
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)
})
export default router
+1 -1
View File
@@ -1,8 +1,8 @@
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { FormattedUser } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types/user'
import { ApplicationError } from '../util/customErrors'
const router = Router()
+23 -18
View File
@@ -18,7 +18,11 @@ import {
import { Unification } from '@oodikone/shared/types'
import { keyBy, mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
import {
getCourseProvidersForCourses,
maxYearsToCreatePopulationFrom,
searchAndCombineSubstitutionGroupsToCodes,
} from '../services/courses'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
import { getStudentTagMap } from '../services/populations/getStudentData'
@@ -26,7 +30,7 @@ import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
import { findByCourseAndSemesters } from '../services/students'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
const router = Router()
@@ -98,15 +102,11 @@ const obfuscateStuff = ({
}
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
'/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
const { programme, combinedProgramme } = req.query
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
return Array.isArray(requestField) ? requestField : [requestField]
}
const years = handleQueryArrays(req.query.years)
const semesters = handleQueryArrays(req.query.semesters)
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
@@ -172,14 +172,15 @@ router.get<
CanError<PopulationstatisticsbycourseResBody>,
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/v3/populationstatisticsbycourse', async (req, res) => {
>('/populationstatisticsbycourse', async (req, res) => {
const {
id: userId,
roles: userRoles,
programmeRights: userProgrammeRights,
studentsUserCanAccess: allStudentsUserCanAccess,
} = req.user
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses, ...restParams } = req.query
const includeSubstitutions = restParams.includeSubstitutions === 'true'
if (!coursecodeJSON || !from || !to) {
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
@@ -187,16 +188,17 @@ router.get<
const isSeparate = separate === 'true'
const coursecodes = JSON.parse(coursecodeJSON)
const codes = includeSubstitutions ? await searchAndCombineSubstitutionGroupsToCodes(coursecodes) : coursecodes
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(codes, Unification.REGULAR)
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
}
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const studentNumbers = await findByCourseAndSemesters(codes, Number(from), Number(to), isSeparate, unifyCourses)
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
@@ -217,18 +219,21 @@ router.get<
studentsUserCanAccess,
})
return res.json(processed)
return res.json({ ...processed, mainCourseCodes: coursecodes, allCourseCodes: codes })
})
// Used in custom population and single study guidance groups
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
'/v3/populationstatisticsbystudentnumbers',
'/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const students = handleQueryArrays(studentNumbers)
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
? students
: intersection(students, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
@@ -242,14 +247,14 @@ router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/v3/populationstatistics/studyprogrammes',
'/populationstatistics/studyprogrammes',
async (req, res) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
@@ -275,7 +280,7 @@ router.get<
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
never,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
>('/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)
@@ -1,16 +1,21 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CurriculumOption } from '@oodikone/shared/types'
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
const router = Router()
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
type CurriculumOptionParams = { code: string }
router.get<CurriculumOptionParams, CurriculumOption[] | null>('/curriculum-options/:code', async (req, res) => {
const { code } = req.params
const result = await getCurriculumOptions(code)
res.json(result)
})
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
type CurriculumPeriodParams = { code: string; periodIds: string }
router.get<CurriculumPeriodParams>('/curriculum/:code/:periodIds', async (req, res) => {
const { code, periodIds } = req.params
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
+63 -66
View File
@@ -1,4 +1,4 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
@@ -23,15 +23,15 @@ import { logInfoForGrafana } from '../util/logInfoForGrafana'
const router = Router()
interface GetCreditStatsRequest extends Request {
query: {
codes: string
specialGroups: SpecialGroups
yearType: YearType
}
type StudyProgrammeParams = { id: string }
type UpdateStudyProgrammeQuery = { combined_programme: string }
type CreditStatsQuery = {
codes: string
specialGroups: SpecialGroups
yearType: YearType
}
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats', async (req, res) => {
const { codes, specialGroups, yearType } = req.query
const stats: CreditStatsPayload = {}
for (const code of JSON.parse(codes)) {
@@ -40,15 +40,13 @@ router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) =>
return res.json(stats)
})
interface GetStatsRequest extends Request {
query: {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
}
type StatsRequestQuery = {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
}
router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basicstats', async (req, res) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -75,7 +73,7 @@ router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
return res.json(updatedStats)
})
router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/graduationstats', async (req, res) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -102,14 +100,12 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
return res.json(updatedStats)
})
interface GetCourseStatsRequest extends Request {
query: {
combinedProgramme: string
yearType: YearType
}
type CourseStatsQuery = {
combinedProgramme: string
yearType: YearType
}
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
const code = req.params.id
const { combinedProgramme, yearType } = req.query
const date = new Date()
@@ -123,15 +119,13 @@ router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response)
}
})
interface GetStudyTrackStatsRequest extends Request {
query: {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
}
type StudyTrackStatsQuery = {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
}
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/studytrackstats', async (req, res) => {
const code = req.params.id
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -153,13 +147,16 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
},
studyRightsOfProgramme,
})
if (updated) {
await setStudyTrackStats(updated, specialGroups)
return res.json(updated)
}
return res.json(updated)
return res.status(500).end()
})
router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response) => {
router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, res) => {
const code = req.params.id
try {
const data = await getStudyProgrammeStatsForColorizedCoursesTable(code)
@@ -169,7 +166,7 @@ router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response)
}
})
router.get('/:id/studytracks', async (req: Request, res: Response) => {
router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
const code = req.params.id
if (!code) {
return res.status(422).end()
@@ -182,42 +179,42 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
}
})
interface GetUpdateViewRequest extends Request {
query: {
combined_programme: string
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
'/:id/update_basicview',
async (req, res) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateBasicView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
}
}
)
router.get('/:id/update_basicview', async (req: GetUpdateViewRequest, res: Response) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
'/:id/update_studytrackview',
async (req, res) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateStudyTrackView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
}
try {
const result = await updateBasicView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
})
router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res: Response) => {
const code = req.params.id
const combinedProgramme = req.query?.combined_programme
if (!code) {
return res.status(400).json({ error: 'Missing code' })
}
try {
const result = await updateStudyTrackView(code, combinedProgramme)
return res.json(result)
} catch (error) {
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
})
)
export default router
+4 -3
View File
@@ -5,7 +5,7 @@ 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 { getFullStudyProgrammeRights, validateParamLength } from '../util'
import { getFullStudyProgrammeRights, handleQueryArrays, validateParamLength } from '../util'
const router = Router()
@@ -91,11 +91,12 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
const { roles, programmeRights, iamGroups } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const { providers, semesterStart, semesterEnd } = req.query
if (!providers || !semesterStart) {
const { providers: providerStack, semesterStart, semesterEnd } = req.query
if (!providerStack || !semesterStart) {
return res.status(422).send('Missing required query parameters')
}
const providers = handleQueryArrays(providerStack)
const providerRights = mapToProviders(fullStudyProgrammeRights)
if (
!(
+20 -14
View File
@@ -4,6 +4,7 @@ import { languageCenterViewEnabled } from '../config'
import { refreshFaculties, refreshProgrammes } from '../events'
import {
abortUpdate,
nukeRedis,
updateSISCoursesByCourseCode,
updateSISMetadata,
updateSISProgrammes,
@@ -12,28 +13,28 @@ import {
updateSISStudentsByStudentNumber,
} from '../services/sisUpdaterService'
import logger from '../util/logger'
import { getJobs, jobMaker, removeWaitingJobs } from '../worker/queue'
import { jobQueue } from '../worker/queue'
const router = Router()
const refreshFacultiesByList = (facultyCodes: string[]) => {
facultyCodes.forEach(code => jobMaker.faculty(code))
facultyCodes.forEach(code => jobQueue.refreshFaculty(code))
return 'Added jobs for refreshing faculties'
}
const refreshProgrammesByList = (programmeCodes: string[]) => {
programmeCodes.forEach(code => jobMaker.programme(code))
programmeCodes.forEach(code => jobQueue.refreshProgramme(code))
return 'Added jobs for refreshing programme'
}
router.get('/update/v2/meta', async (_req: Request, res: Response) => {
router.get('/update/meta', async (_req: Request, res: Response) => {
const response = await updateSISMetadata()
if (response) {
res.status(200).json('Update SIS meta scheduled')
}
})
router.get('/update/v2/students', async (_req: Request, res: Response) => {
router.get('/update/students', async (_req: Request, res: Response) => {
const response = await updateSISStudents()
if (response) {
res.status(200).json('Update SIS students scheduled')
@@ -47,7 +48,7 @@ interface PostCustomListRequest extends Request {
}
}
router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
router.post('/update/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
const { type } = req.params
const list = req.body
const typeToJob = {
@@ -65,7 +66,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
}
})
router.get('/update/v2/programmes', async (_req: Request, res: Response) => {
router.get('/update/programmes', async (_req: Request, res: Response) => {
const response = await updateSISProgrammes()
if (response) {
res.status(200).json('Update SIS programmes scheduled')
@@ -80,11 +81,17 @@ router.get('/refresh_redis_cache', async (req: Request, res: Response) => {
}
})
router.get('/nuke_redis', async (req: Request, res: Response) => {
logger.info(`${req.user.username} requested complete wipe of redis`)
const response = await nukeRedis()
if (response) res.status(200).json('Complete flush of redis scheduled')
})
router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
logger.info(
`${req.user.username} requested refresh of teacher leaderboard for the current and previous academic year`
)
jobMaker.teacherLeaderboard()
jobQueue.refreshTeacherLeaderboard()
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
})
@@ -104,13 +111,13 @@ router.post('/refresh_language_center_data', (req: Request, res: Response) => {
logger.info(`${req.user.username} requested refresh of language center data`)
if (!languageCenterViewEnabled)
res.status(418).json({ error: 'The language center functionality is not activated in your environment.' })
jobMaker.languagecenter()
jobQueue.refreshLanguagecenter()
res.status(200).json('Added job for refreshing language center data')
})
router.post('/refresh-close-to-graduation', (req: Request, res: Response) => {
logger.info(`${req.user.username} requested refresh of close to graduation data`)
jobMaker.closeToGraduation()
jobQueue.refreshCloseToGraduation()
res.status(200).json('Added job for refreshing close to graduation data')
})
@@ -120,13 +127,12 @@ router.get('/abort', async (_req: Request, res: Response) => {
})
router.get('/jobs', async (_req: Request, res: Response) => {
const waiting = await getJobs('waiting')
const active = await getJobs('active')
res.status(200).json({ waiting, active })
const jobs = await jobQueue.getJobs()
res.status(200).json(jobs)
})
router.delete('/jobs', async (_req: Request, res: Response) => {
await removeWaitingJobs()
await jobQueue.clearWaitingJobs()
res.status(200).end()
})
+27 -57
View File
@@ -1,33 +1,34 @@
import { Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { LANGUAGE_CODES } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import {
ChangeLanguageReqBody,
GetUserByIdParams,
GetUserByIdResBody,
GetUserRolesResBody,
GetUsersResBody,
ModifyUserRolesReqBody,
PostUserEmailReqBody,
UserEmailPreviewResBody,
UserIDElementsParams,
UserIDElementsReqBody,
} from '@oodikone/shared/routes/users'
import { tryCatch } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const router = Router()
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
const results = await userService.findAll()
res.json(results)
})
type GetUserRolesResBody = readonly string[]
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
res.json(roles)
})
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
@@ -41,61 +42,35 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
async (req, res) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
if (error) return res.status(400).json(error)
return res.status(204).end()
}
)
type UserEmailPreviewResBody = {
subject: string
html: string
}
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
res.json({ subject: accessMessageSubject, html: accessMessageText })
})
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['admin']), async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
'/email',
auth.roles(['admin']),
async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
}
)
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
type PostUserUIDElementsResBody = void
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -106,9 +81,7 @@ router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, Elemen
}
)
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -119,10 +92,7 @@ router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, El
}
)
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
@@ -4,19 +4,19 @@ import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitution_groups'>>
interface StudentCredit {
date: Date
courseCode: string
creditType: CreditTypeCode
substitution: string | null
substitution: string[] | null
}
interface StudentEnrollment {
date: Date
courseCode: string
substitution: string | null
substitution: string[] | null
}
interface StudentInfo {
@@ -58,7 +58,7 @@ interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplan
const getCourses = async (courseCodes: string[]) => {
const courses: Courses = (
await CourseModel.findAll({
attributes: ['code', 'name', 'substitutions'],
attributes: ['code', 'name', 'substitution_groups'],
where: where(fn('LOWER', col('code')), {
[Op.in]: courseCodes.map(code => code.toLowerCase()),
}),
@@ -67,16 +67,13 @@ const getCourses = async (courseCodes: string[]) => {
return courses
}
const getCredits = async (
courses: Courses,
fullCourseCodes: string[],
courseCodes: string[],
studentNumbers: string[]
) => {
const getPassedCredits = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
const credits: Array<
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
> = await CreditModel.findAll({
raw: true,
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
order: [['attainment_date', 'DESC']],
where: {
course_code: {
[Op.in]: fullCourseCodes,
@@ -84,20 +81,47 @@ const getCredits = async (
student_studentnumber: {
[Op.in]: studentNumbers,
},
credittypecode: {
[Op.not]: CreditTypeCode.FAILED,
},
},
})
const formattedCredits = credits.map(credit => {
const originalCode = courseCodes.includes(credit.course_code)
? null
: courses.find(course => course.substitutions.includes(credit.course_code))?.code
return {
courseCode: originalCode ?? credit.course_code,
substitution: originalCode ? credit.course_code : null,
studentNumber: credit.student_studentnumber,
creditType: credit.credittypecode,
date: credit.attainment_date,
const creditsByStudentNumber = Object.groupBy(credits, ({ student_studentnumber }) => student_studentnumber)
const formattedCredits: {
courseCode: string
substitution: string[] | null
studentNumber: string
creditType: CreditTypeCode
date: Date
}[] = []
Object.entries(creditsByStudentNumber).map(([studentNumber, credits]) => {
// We know that credits will exist, because of the way we created the previous object
const studentCreditCourseCodes = credits!.map(({ course_code }) => course_code)
for (const course of courses) {
// Also handle the main course code by adding it as a group
for (const group of [[course.code]].concat(course.substitution_groups)) {
if (group.every(code => studentCreditCourseCodes.includes(code))) {
// We just checked that (group) course code exists in credits so .find(...)! is ok
const groupCredits = group.map(code => credits!.find(credit => credit.course_code === code)!)
if (groupCredits.length) {
const groupCreditCodes = groupCredits.map(({ course_code }) => course_code)
formattedCredits.push({
courseCode: course.code,
substitution:
groupCreditCodes.includes(course.code) && groupCreditCodes.length === 1 ? null : groupCreditCodes,
studentNumber,
creditType: groupCredits.length > 1 ? CreditTypeCode.PASSED : groupCredits.at(0)!.credittypecode,
date: groupCredits?.at(0)?.attainment_date ?? new Date(), // Credits are sorted by date in desc. order
})
}
}
}
}
})
return formattedCredits
}
@@ -105,6 +129,7 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
await EnrollmentModel.findAll({
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
order: [['enrollment_date_time', 'DESC']],
where: {
course_code: {
[Op.in]: fullCourseCodes,
@@ -117,15 +142,42 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
},
},
})
const formattedEnrollments = enrollments.map(enrollment => {
const originalCode = courses.find(course => course.substitutions.includes(enrollment.course_code))?.code
return {
courseCode: originalCode ?? enrollment.course_code,
substitution: originalCode ? enrollment.course_code : null,
studentNumber: enrollment.studentnumber,
date: enrollment.enrollment_date_time,
const enrollmentsByStudents = Object.groupBy(enrollments, ({ studentnumber }) => studentnumber)
const formattedEnrollments: {
courseCode: string
substitution: string[] | null
studentNumber: string
date: Date
}[] = []
Object.entries(enrollmentsByStudents).map(([studentNumber, enrollments]) => {
// We know that enrollments will exist, because of the way we created the previous object
const studentEnrollmentCourseCodes = enrollments!.map(({ course_code }) => course_code)
for (const course of courses) {
// Also handle the main course code by adding it as a group
for (const group of [[course.code]].concat(course.substitution_groups)) {
if (group.every(code => studentEnrollmentCourseCodes.includes(code))) {
// We just checked that (group) course code exists in enrollments so .find(...)! is ok
const groupEnrollments = group.map(code => enrollments!.find(enrollment => enrollment.course_code === code)!)
if (groupEnrollments.length) {
const groupEnrollmentCodes = groupEnrollments.map(({ course_code }) => course_code)
formattedEnrollments.push({
courseCode: course.code,
substitution:
groupEnrollmentCodes.includes(course.code) && groupEnrollmentCodes.length === 1
? null
: groupEnrollmentCodes,
studentNumber,
date: groupEnrollments?.at(0)?.enrollment_date_time ?? new Date(), // Enrollments are sorted by date in desc. order
})
}
}
}
}
})
return formattedEnrollments
}
@@ -159,15 +211,20 @@ export const getCompletedCourses = async (
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
const courses = await getCourses(courseCodes)
const courseCodesSet = new Set(courseCodes)
courses.forEach(course => {
// Get *ALL* courses including any courses in any substitution groups
for (const course of courses) {
courseCodesSet.add(course.code)
course.substitutions.forEach(substitution => {
courseCodesSet.add(substitution)
})
})
for (const group of course.substitution_groups) {
for (const code of group) {
courseCodesSet.add(code)
}
}
}
const fullCourseCodes = Array.from(courseCodesSet)
const credits = await getCredits(courses, fullCourseCodes, courseCodes, studentNumbers)
const credits = await getPassedCredits(courses, fullCourseCodes, studentNumbers)
const enrollments = await getEnrollments(courses, fullCourseCodes, studentNumbers)
const studentInfo = await getStudents(studentNumbers)
@@ -243,7 +300,7 @@ export const getCompletedCourses = async (
students.forEach(student => {
courseCodes.forEach(courseCode => {
const [latestEnrollment] = student.allEnrollments
.filter(enrollment => enrollment.courseCode === courseCode || enrollment.substitution === courseCode)
.filter(enrollment => enrollment.courseCode === courseCode)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
student.enrollments[courseCode] = latestEnrollment
})
@@ -1,8 +1,6 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getSortRank } from '../../../src/util/sortRank'
import { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { CourseModel } from '../../models'
const nameLikeTerm = (name: string) => {
@@ -40,8 +38,8 @@ const codeLikeTerm = (code: string) => {
}
}
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) =>
await CourseModel.findAll({
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
const courses = await CourseModel.findAll({
where: {
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
...nameLikeTerm(name),
@@ -50,42 +48,23 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
raw: true,
})
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
const rawCourses = await getRawCourses(name, code, includeSpecial)
return rawCourses
.map(course => ({
...course,
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
}))
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code)) as CourseWithSubsId[]
}
const coursesWithSubstitutionGroupDetails = await Promise.all(
courses.map<Promise<CourseWithSubsDetails>>(async course => {
const groupDetails = await Promise.all(
course?.substitution_groups?.map(async group => {
return await CourseModel.findAll({
attributes: ['name', 'code'],
where: { code: { [Op.in]: group } },
raw: true,
})
})
)
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
const courses = await getCourses(name, code, includeSpecial)
const visitedCourses = new Set<string>()
const assignSubstitutionGroup = (course: CourseWithSubsId, index: number) => {
const relatedCourses = courses.filter(currentCourse => course.substitutions.includes(currentCourse.code))
relatedCourses.unshift(course)
relatedCourses.forEach(relatedCourse => {
visitedCourses.add(relatedCourse.id)
relatedCourse.subsId = index
return { ...course, substitution_groups: groupDetails }
})
}
)
courses
.filter(course => !visitedCourses.has(course.id) && !visitedCourses.has(course.code))
.forEach((course, index) => assignSubstitutionGroup(course, index))
return courses
return coursesWithSubstitutionGroupDetails
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -1,7 +1,9 @@
import { Op } from 'sequelize'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import {
StudentModel,
CreditModel,
SemesterModel,
OrganizationModel,
@@ -11,37 +13,84 @@ import {
} from '../../models'
import { getIsOpen } from './helpers'
export const getCreditsForCourses = async (codes: string[], unification: Unification) =>
CreditModel.findAll({
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
export const getCreditsForCourses = async (codeGroups: string[][], unification: Unification) => {
const allCourseCodes = codeGroups.flatMap(group => group)
// We need the credits grouped by student numbers so that we can check if a student has
// completed a substitution group
const students = await StudentModel.findAll({
raw: true,
nest: true,
attributes: ['studentnumber'],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
model: CreditModel,
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
where: {
startdate: {
[Op.lte]: new Date(),
course_code: {
[Op.in]: allCourseCodes,
},
is_open: getIsOpen(unification),
},
order: [['attainment_date', 'ASC']],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
[Op.lte]: new Date(),
},
},
},
],
},
],
where: {
course_code: {
[Op.in]: codes,
},
is_open: getIsOpen(unification),
},
order: [['attainment_date', 'ASC']],
})
const studentNumberToCredits = students.reduce<Record<string, Credit[]>>((acc, student) => {
acc[student.studentnumber] ??= []
acc[student.studentnumber] = acc[student.studentnumber].concat(student.credits)
return acc
}, {})
const completedGroups: Credit[][] = []
for (const studentCredits of Object.values(studentNumberToCredits)) {
const codesOfPassedCredits = studentCredits
.filter(credit => CreditModel.passed(credit))
.map(credit => credit.course_code)
for (const group of codeGroups) {
if (group.length === 1) {
// Failed courses are only calculated for the original course and 1-to-1 substitutions
const credits = studentCredits.filter(credit => credit.course_code === group.at(0))
if (credits.length) {
completedGroups.push(credits)
}
}
// For substitution groups we must only get passed groups
else if (group.every(code => codesOfPassedCredits.includes(code))) {
// The credit in question should always exist because we just checked that it does
completedGroups.push(
studentCredits.filter(credit => group.includes(credit.course_code) && CreditModel.passed(credit))
)
} else {
// "Partially completed substitution, do not count towards anything"
}
}
}
return completedGroups.filter(group => group.length)
}
export const getStudentNumberToSrElementsMap = async (studentNumbers: string[]) => {
const studyRights = await SISStudyRightModel.findAll({
attributes: ['facultyCode', 'id', 'studentNumber'],
@@ -83,26 +132,64 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
}, {})
}
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) =>
EnrollmentModel.findAll({
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
export const getEnrollmentsForCourses = async (codeGroups: string[][], unification: Unification) => {
const allCourseCodes = codeGroups.flatMap(group => group)
const students = await StudentModel.findAll({
raw: true,
nest: true,
attributes: ['studentnumber'],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
[Op.lte]: new Date(),
model: EnrollmentModel,
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
[Op.lte]: new Date(),
},
},
},
],
where: {
course_code: {
[Op.in]: allCourseCodes,
},
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
state: EnrollmentState.ENROLLED,
is_open: getIsOpen(unification),
},
},
],
where: {
course_code: {
[Op.in]: codes,
},
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
state: EnrollmentState.ENROLLED,
is_open: getIsOpen(unification),
},
})
const studentNumberToEnrollments = students.reduce<Record<string, Enrollment[]>>((acc, student) => {
acc[student.studentnumber] ??= []
acc[student.studentnumber] = acc[student.studentnumber].concat(student.enrollments)
return acc
}, {})
// Calculate all code groups that have enrollments for all of the courses in the group
const completedEnrollments: Enrollment[][] = []
for (const studentEnrollments of Object.values(studentNumberToEnrollments)) {
const codesOfEnrollments = studentEnrollments.map(enrollment => enrollment.course_code)
for (const group of codeGroups) {
if (group.every(code => codesOfEnrollments.includes(code))) {
completedEnrollments.push(
studentEnrollments
.filter(enrollment => group.includes(enrollment.course_code))
.sort((a, b) => b.enrollment_date_time.getTime() - a.enrollment_date_time.getTime())
)
} else {
// Partially completed enrollments for a group, skipping...
}
}
}
return completedEnrollments
}
+144 -51
View File
@@ -1,11 +1,12 @@
import crypto from 'crypto'
import { Op, fn as dbFn, col as dbCol } from 'sequelize'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
import { dateIsBetween } from '@oodikone/shared/util/datetime'
import logger from '../../../src/util/logger'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
import { getOpenUniCourseCode } from '../../util'
import { getSortRank } from '../../util/sortRank'
import { isOpenUniCourseCode } from '../../util'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
getCreditsForCourses,
@@ -14,13 +15,6 @@ import {
} from './creditsAndEnrollmentsOfCourse'
import { FormattedProgramme, getIsOpen } from './helpers'
const sortMainCode = (codes: string[]) => {
if (!codes) {
return []
}
return codes.sort((a, b) => getSortRank(b) - getSortRank(a))
}
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
code: studyRightElement.code,
name: studyRightElement.name,
@@ -47,20 +41,38 @@ type FormattedCredit = {
credits: number
}
// Is group in question for a single course (original or 1-to-1 substitution) or a substitution group with multiple courses
const isSingleCourse = (group: Credit[] | Enrollment[]): boolean =>
group?.length === 1 || [...new Set(group?.map((course: Credit | Enrollment) => course.course_code))].length === 1
const parseCredit = (
credit: CreditModel,
creditGroup: Credit[],
anonymizationSalt: string | null,
mainCourseCode: string,
studyRightElements: Array<SISStudyRightElementModel>
): FormattedCredit => {
const singleCourse = isSingleCourse(creditGroup)
// Latest attainment for a course
const credit =
creditGroup
.sort((a, b) => b.attainment_date.getTime() - a.attainment_date.getTime())
.find(credit => !CreditModel.failed(credit)) ?? creditGroup.at(0)!
const courseCode = singleCourse ? credit.course_code : mainCourseCode
const grade = singleCourse ? credit.grade : 'substituted'
const credits = singleCourse ? credit.credits : creditGroup.reduce((acc, credit) => acc + credit.credits, 0)
// Take the first attainment for the course (see Student statistics info-box)
// TODO: Substitution groups should be marked for the semester when
// the group was first finished eg. when was the first time the last missing
// course of the group was passed
const {
semester,
grade,
course_code: courseCode,
credits,
attainment_date: attainmentDate,
student_studentnumber: studentNumber,
studyright_id: studyRightId,
} = credit
} = creditGroup.at(-1)!
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const programmeOfCredit: SISStudyRightElementModel | undefined =
@@ -87,7 +99,7 @@ const parseCredit = (
attainmentDate,
courseCode,
grade,
passed: !CreditModel.failed(credit),
passed: singleCourse ? !CreditModel.failed(credit) : true,
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
programme,
credits,
@@ -104,7 +116,7 @@ type FormattedEnrollment = {
studentNumber: string
}
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
const parseEnrollment = (enrollment: Enrollment, anonymizationSalt: string | null): FormattedEnrollment => {
const {
studentnumber: studentNumber,
semester,
@@ -124,7 +136,22 @@ const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string
}
}
const getSubstitutionGroupDetails = async (codeGroups: string[][]) => {
const substitutionGroupDetails = await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
code: { [Op.in]: codeGroups.flatMap(group => group) },
},
raw: true,
})
return codeGroups.map(group =>
group.map(code => substitutionGroupDetails.find(subCourse => subCourse.code === code)!)
)
}
const getYearlyStatsOfNew = async (
course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null,
courseCode: string,
separate: boolean,
unification: Unification,
@@ -132,27 +159,36 @@ const getYearlyStatsOfNew = async (
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
const course: Pick<CourseModel, 'code' | 'name' | 'substitutions'> | null = await CourseModel.findOne({
attributes: ['code', 'name', 'substitutions'],
where: { code: courseCode },
})
// Includes main course code and substitutions (if enabled)
const creditGroupCodes =
combineSubstitutions && course?.substitution_groups
? [[courseCode]].concat(course.substitution_groups)
: [[courseCode]]
const substitutedCodes =
combineSubstitutions && course?.substitutions ? sortMainCode([...course.substitutions, courseCode]) : [courseCode]
let filteredCreditGroupCodes: string[][]
switch (unification) {
// Include only course codes / group of course codes WITHOUT ANY AY prefix
case Unification.REGULAR:
filteredCreditGroupCodes = creditGroupCodes.filter(group => group.every(course => !isOpenUniCourseCode(course)))
break
// Include all courses / group of courses
case Unification.OPEN:
case Unification.UNIFY:
default:
filteredCreditGroupCodes = creditGroupCodes
break
}
const codes =
unification === Unification.REGULAR
? substitutedCodes.filter(course => !getOpenUniCourseCode(course))
: substitutedCodes
const [credits, enrollments] = await Promise.all([
getCreditsForCourses(codes, unification),
getEnrollmentsForCourses(codes, unification),
const [creditGroups, enrollmentGroups] = await Promise.all([
getCreditsForCourses(filteredCreditGroupCodes, unification),
getEnrollmentsForCourses(filteredCreditGroupCodes, unification),
])
const counter = new CourseYearlyStatsCounter()
for (const credit of credits) {
for (const creditGroup of creditGroups) {
if (!creditGroup?.length) continue
const {
studentNumber,
grade,
@@ -163,9 +199,14 @@ const getYearlyStatsOfNew = async (
yearName,
attainmentDate,
programme,
courseCode,
courseCode: creditCourseCode,
credits,
} = parseCredit(credit, anonymizationSalt, studentNumberToSrElementsMap[credit.student_studentnumber] ?? [])
} = parseCredit(
creditGroup,
anonymizationSalt,
courseCode,
studentNumberToSrElementsMap[creditGroup.at(0)?.student_studentnumber ?? 0] ?? []
)
counter.markStudyProgramme(
studentNumber,
@@ -180,36 +221,49 @@ const getYearlyStatsOfNew = async (
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, courseCode, yearCode)
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, creditCourseCode, yearCode)
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
}
for (const enrollment of enrollments) {
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, enrollmentDateTime } =
parseEnrollment(enrollment, anonymizationSalt)
for (const enrollments of enrollmentGroups) {
if (!enrollments?.length) continue
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
for (const enrollment of enrollments) {
const {
studentNumber,
semesterCode,
semesterName,
yearCode,
yearName,
courseCode: enrollmentCourseCode,
enrollmentDateTime,
} = parseEnrollment(enrollment, anonymizationSalt)
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
counter.markEnrollmentToGroup(
studentNumber,
enrollmentDateTime,
groupCode,
groupName,
enrollmentCourseCode,
yearCode
)
}
}
const statistics = await counter.getFinalStatistics(anonymizationSalt)
const substitutionCourses: Pick<CourseModel, 'code' | 'name'>[] =
combineSubstitutions && course?.substitutions?.length
? await CourseModel.findAll({
where: {
code: { [Op.in]: codes },
},
attributes: ['code', 'name'],
})
: [{ code: course!.code, name: course!.name }]
const substitutionGroups: Pick<CourseModel, 'code' | 'name'>[][] =
combineSubstitutions && course?.substitution_groups?.length
? await getSubstitutionGroupDetails(course.substitution_groups)
: [[{ code: course!.code, name: course!.name }]]
return {
...statistics,
coursecode: courseCode,
alternatives: substitutionCourses,
alternatives: substitutionGroups,
name: course?.name,
}
}
@@ -223,7 +277,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
raw: true,
})) as { date: Date } | null
if (lastAttainmentDate === null) return 0
if (lastAttainmentDate?.date == null) return 0
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
@@ -277,8 +331,19 @@ export const getCourseYearlyStats = async (
const statsRegular = await Promise.all(
courseCodes.map(async courseCode => {
const course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null = await CourseModel.findOne({
attributes: ['code', 'name', 'substitution_groups'],
where: { code: courseCode },
})
if (!course) {
logger.error('Course for course stats not found with code' + courseCode)
return []
}
const [openStats, regularStats, unifyStats] = await Promise.all([
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.OPEN,
@@ -287,6 +352,7 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.REGULAR,
@@ -295,6 +361,7 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.UNIFY,
@@ -327,3 +394,29 @@ export const getCourseProvidersForCourses = async (codes: string[]) => {
})
).map(({ code }) => code)
}
export const getCourseDetails = async (codes: string[]) =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitution_groups'],
where: { code: { [Op.in]: codes } },
raw: true,
})
// Add all substitution_groups and coursecodes together
export const searchAndCombineSubstitutionGroupsToCodes = async (coursecodes: string[]) => {
const substitutionGroups = await CourseModel.findAll({
raw: true,
attributes: ['substitution_groups'],
where: {
code: { [Op.in]: coursecodes },
},
})
return [
...new Set(
coursecodes.concat(
substitutionGroups.flatMap(({ substitution_groups }) => substitution_groups.flatMap(code => code))
)
),
]
}
@@ -1,7 +1,8 @@
import { cloneDeep, omit } from 'lodash-es'
import { cloneDeep } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { omitKeys } from '@oodikone/shared/util'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
@@ -54,12 +55,12 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
if (!medians[level]) {
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omit(year, ['y']), median: year.y }))
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omitKeys(year, ['y']), median: year.y }))
} else {
for (const statsForYear of graduationTimes.medians) {
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
medians[level].push({ ...omitKeys(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
@@ -73,12 +74,12 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
if (level === 'master' && comboTimes.medians.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omit(year, ['y']), median: year.y }))
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omitKeys(year, ['y']), median: year.y }))
} else {
for (const statsForYear of comboTimes.medians) {
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
medians.bcMsCombo.push({ ...omitKeys(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
@@ -101,7 +102,7 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
programmes.medians[level][year.name] = { data: [], programmes: [] }
}
programmes.medians[level][year.name].data.push(
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
)
programmes.medians[level][year.name].programmes.push(code)
}
@@ -116,7 +117,7 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
programmes.medians.bcMsCombo[year.name] = { data: [], programmes: [] }
}
programmes.medians.bcMsCombo[year.name].data.push(
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
)
programmes.medians.bcMsCombo[year.name].programmes.push(code)
}
@@ -168,7 +169,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
classSizes.programmes[code] = classSizesByYear
if (!medians[level]) {
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
medians[level] = cloneDeep(basicStats).map(year => ({ ...omitKeys(year, ['y', 'classSize']), median: year.y }))
}
classSizes[level] ??= {}
for (const statsForYear of basicStats) {
@@ -177,7 +178,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
medians[level].push({ ...omitKeys(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
@@ -190,7 +191,10 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
if (level === 'master' && comboStats.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({
...omitKeys(year, ['y', 'classSize']),
median: year.y,
}))
}
classSizes.bcMsCombo ??= {}
for (const statsForYear of comboStats) {
@@ -198,7 +202,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize ?? 0
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
medians.bcMsCombo.push({ ...omitKeys(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
@@ -220,7 +224,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
programmes.medians[level][year.name] = { data: [], programmes: [] }
}
programmes.medians[level][year.name].data.push(
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
)
programmes.medians[level][year.name].programmes.push(code)
}
@@ -235,7 +239,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
programmes.medians.bcMsCombo[year.name] = { data: [], programmes: [] }
}
programmes.medians.bcMsCombo[year.name].data.push(
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
)
programmes.medians.bcMsCombo[year.name].programmes.push(code)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { FormattedUser } from '@oodikone/shared/types'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const pateClient = axios.create({
@@ -14,7 +14,7 @@ export type FoundSearch = {
updatedAt: Date
}
export const getOpenUniSearches = async (userId: string) => {
export const getUserSearches = async (userId: string) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches === undefined) {
return []
@@ -34,7 +34,7 @@ export const getOpenUniSearches = async (userId: string) => {
return foundSearches
}
export const createNewSearch = async (userId: string, name: string, courseCodes: string[]) => {
export const createNewUserSearch = async (userId: string, name: string, courseCodes: string[]) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches?.some(search => search.name === name)) {
return null
@@ -51,7 +51,7 @@ export const createNewSearch = async (userId: string, name: string, courseCodes:
}
}
export const deleteSearch = async (userId: string, id: string) => {
export const deleteUserSearch = async (userId: string, id: string) => {
try {
const deleted = await deleteOpenUniSearch(userId, id)
if (!deleted) {
@@ -64,7 +64,7 @@ export const deleteSearch = async (userId: string, id: string) => {
}
}
export const updateSearch = async (userId: string, id: string, courseCodes: string[]) => {
export const updateUserSearch = async (userId: string, id: string, courseCodes: string[]) => {
try {
const updated = await updateOpenUniPopulationSearch(userId, id, courseCodes)
if (!updated) {
@@ -1,149 +0,0 @@
import dayjs from 'dayjs'
import { SISStudyRightModel } from '../../models'
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
type CourseInfo = {
status: {
passed: Date | null
failed: Date | null
unfinished: Date | null
}
}
type StudentStats = Record<
string,
{
courseInfo: Record<string, CourseInfo>
email: string
secondaryEmail: string
totals: {
passed: number
failed: number
unfinished: number
}
}
>
const uniq = objects => [...new Set(objects)]
const calculateTotalsForStudent = (studentStats: StudentStats, studentNumber: string) => {
Object.keys(studentStats[studentNumber].courseInfo).forEach(course => {
const { status } = studentStats[studentNumber].courseInfo[course]
if (status.passed) {
studentStats[studentNumber].totals.passed += 1
} else if (status.failed) {
studentStats[studentNumber].totals.failed += 1
} else if (status.unfinished) {
studentStats[studentNumber].totals.unfinished += 1
}
})
}
const getAllCourseCodes = (courseCodes: string[]) => {
const ayCourseCodes = courseCodes.map(courseCode => `AY${courseCode}`)
return courseCodes.concat(ayCourseCodes)
}
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
return startDate <= studyRight.startDate && studyRight.startDate <= new Date()
}
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
return studyRight.startDate <= startDate && new Date() <= studyRight.endDate
}
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => studyRight.endDate <= new Date()
const getEmptyCourseInfo = (): CourseInfo => ({
status: {
passed: null,
failed: null,
unfinished: null,
},
})
const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (!courseInfo.status.passed || dayjs(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
courseInfo.status.passed = attainmentDate
}
}
const updateFailedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (
!courseInfo.status.passed &&
(!courseInfo.status.failed || dayjs(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
) {
courseInfo.status.failed = attainmentDate
}
}
const updateUnfinishedStatus = (courseInfo: CourseInfo, enrollmentDateTime: Date) => {
if (
!courseInfo.status.passed &&
!courseInfo.status.failed &&
(!courseInfo.status.unfinished || dayjs(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
) {
courseInfo.status.unfinished = enrollmentDateTime
}
}
export const getCustomOpenUniCourses = async (courseCodes: string[], startDate: Date, endDate: Date) => {
const allCourseCodes = getAllCourseCodes(courseCodes)
const allCredits = await getCredits(allCourseCodes, startDate)
const allEnrollments = await getEnrollments(allCourseCodes, startDate, endDate)
const courses = await getCourseNames(courseCodes)
const studentNumbers = uniq(allEnrollments.map(enrollment => enrollment.enrollmentStudentNumber)) as string[]
const allStudyRights = await getStudyRights(studentNumbers)
const studentInfo = await getStudentInfo(studentNumbers)
const passedGrades = ['1', '2', '3', '4', '5', 'Hyv.', 'hyv.', 'HT', 'TT']
const failedGrades = ['Hyl.', 'HYL', '0']
const studentsWithCurrentStudyRight = allStudyRights
.filter(
studyRight =>
isStartDateOutsideInterval(studyRight, startDate) ||
isStartDateInsideAndEndDateOutside(studyRight, startDate) ||
isEndDateBeforeNow(studyRight)
)
.map(studyRight => studyRight.studentNumber)
const uniqueStudentsWithCurrentStudyRight = uniq(studentsWithCurrentStudyRight)
const studentStats: StudentStats = {}
for (const { studentNumber, email, secondaryEmail } of studentInfo) {
if (uniqueStudentsWithCurrentStudyRight.includes(studentNumber)) {
continue
}
if (!(studentNumber in studentStats)) {
studentStats[studentNumber] = {
courseInfo: courseCodes.reduce((acc, code) => ({ ...acc, [code.replace('AY', '')]: getEmptyCourseInfo() }), {}),
email,
secondaryEmail,
totals: { passed: 0, failed: 0, unfinished: 0 },
}
}
const studentCourses = studentStats[studentNumber].courseInfo
for (const { attainmentCourseCode, attainmentDate, attainmentStudentNumber, attainmentGrade } of allCredits) {
if (attainmentStudentNumber === studentNumber) {
const courseCode = attainmentCourseCode.replace('AY', '')
if (passedGrades.includes(attainmentGrade)) {
updatePassedStatus(studentCourses[courseCode], attainmentDate)
} else if (failedGrades.includes(attainmentGrade)) {
updateFailedStatus(studentCourses[courseCode], attainmentDate)
}
}
}
for (const { enrollmentStudentNumber, enrollmentCourseCode, enrollmentDateTime } of allEnrollments) {
if (enrollmentStudentNumber === studentNumber) {
const courseCode = enrollmentCourseCode.replace('AY', '')
updateUnfinishedStatus(studentCourses[courseCode], enrollmentDateTime)
}
}
calculateTotalsForStudent(studentStats, studentNumber)
}
const openUniStats = { students: studentStats, courses }
return openUniStats
}
@@ -1,83 +0,0 @@
const axios = require('axios')
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
const { ApplicationError } = require('../util/customErrors')
const getSisuAccessToken = async eppn => {
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
let sisuResponse
try {
sisuResponse = await axios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
} catch (error) {
throw new ApplicationError(
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
404
)
}
return sisuResponse.data.authToken
}
const decodeJwtTokenPayloadToObject = token => {
const payload = token.split('.')[1]
const paddedPayload = `${payload}==`
const bufferObj = Buffer.from(paddedPayload, 'base64')
return JSON.parse(bufferObj.toString('utf-8'))
}
const getSisuAuthData = async eppn => {
const accessToken = await getSisuAccessToken(eppn)
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
return { accessToken, tokenData }
}
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
private_person(id: $subjectUserId) {
id
first_name:firstNames
last_name:lastName
call_name:callName
eppn:eduPersonPrincipalName
email_address:primaryEmail
student_number:studentNumber
phone_number:phoneNumber
document_state:documentState
}
}`
const getGraphqlData = async (accessToken, queryObject) => {
const graphQlUrl = `${sisUrl}/api`
const authHeader = `Application ${accessToken}`
const config = {
url: graphQlUrl,
method: 'post',
headers: {
Authorization: authHeader,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
data: queryObject,
}
let graphQlResponse
try {
graphQlResponse = await axios(config)
} catch (error) {
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
}
return graphQlResponse.data.data.private_person
}
module.exports = {
getSisuAccessToken,
decodeJwtTokenPayloadToObject,
getSisuAuthData,
personSearchQuery,
getGraphqlData,
}
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
attributes: ['code', 'name', 'substitution_groups', 'is_study_module'],
where: {
code: { [Op.in]: courses },
},
+9 -5
View File
@@ -1,7 +1,9 @@
import { getOpenUniCourseCode } from '../../src/util'
import { CourseModel, OrganizationModel } from '../models'
export const getAllProviders = async () => {
const providers = OrganizationModel.findAll({
raw: true,
attributes: ['code', 'name'],
})
return providers
@@ -9,7 +11,8 @@ export const getAllProviders = async () => {
export const getCourseCodesOfProvider = async (provider: string) => {
const coursesByProvider = await CourseModel.findAll({
attributes: ['id', 'code', 'substitutions'],
raw: true,
attributes: ['id', 'code', 'substitution_groups'],
include: {
model: OrganizationModel,
required: true,
@@ -22,12 +25,13 @@ export const getCourseCodesOfProvider = async (provider: string) => {
},
})
const coursesWithOpenUniSubstitutions = coursesByProvider.map(({ code, substitutions }) => {
if (!substitutions?.length) {
const coursesWithOpenUniSubstitutions = coursesByProvider.map(({ code, substitution_groups }) => {
if (!substitution_groups?.length) {
return [code]
}
const alternatives = [`AY-${code}`, `AY${code}`, `A-${code}`]
return [code].concat(substitutions.filter(sub => alternatives.includes(sub)))
// This is ok to flatten, because we only want all credits completed by students, not what courses are
// completed and how => we don't care about substitutions
return [code].concat(substitution_groups.filter(group => group.some(code => getOpenUniCourseCode(code))).flat())
})
return coursesWithOpenUniSubstitutions.flat()
+2 -2
View File
@@ -55,7 +55,7 @@ export const getCurrentSemester = async () => {
export const getSemestersAndYears = async () => {
const semesters = await SemesterModel.findAll()
return semesters.reduce(
return semesters.reduce<SemestersAndYears>(
(acc, semester) => {
const { semestercode, name, yearcode, yearname, startdate, enddate } = semester
acc.semesters[semestercode] = { semestercode, name, yearcode, startdate, enddate }
@@ -68,6 +68,6 @@ export const getSemestersAndYears = async () => {
return acc
},
{ years: {}, semesters: {} } as SemestersAndYears
{ years: {}, semesters: {} }
)
}
@@ -8,39 +8,44 @@ const client = axios.create({ baseURL: SIS_UPDATER_URL })
const params = { params: { token: SECRET_TOKEN } }
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
const response = await client.get('/abort', params)
return response.data?.message
}
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
const data = { coursecodes }
const response = await client.post('/v1/courses', data, params)
const response = await client.post('/courses', data, params)
return response.data
}
export const updateSISMetadata = async () => {
const response = await client.get('/v1/meta', params)
const response = await client.get('/meta', params)
return response.data
}
export const updateSISProgrammes = async () => {
const response = await client.get('/v1/programmes', params)
const response = await client.get('/programmes', params)
return response.data
}
export const updateSISRedisCache = async () => {
const response = await client.get('/v1/rediscache', params)
const response = await client.get('/rediscache', params)
return response.data
}
export const updateSISStudents = async () => {
const response = await client.get('/v1/students', params)
const response = await client.get('/students', params)
return response.data
}
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
const data = { studentnumbers }
const response = await client.post('/v1/students', data, params)
const response = await client.post('/students', data, params)
return response.data
}
export const nukeRedis = async () => {
const response = await client.get('/nuke_redis', params)
return response.data
}
@@ -54,6 +59,6 @@ export const updateSISStudyPlans = async (days: number) => {
limit: 1000,
})
const studentnumbers = result.map(r => r.studentnumber)
const response = await client.post('v1/studyplans', { studentnumbers }, params)
const response = await client.post('studyplans', { studentnumbers }, params)
return response.data
}
+4 -4
View File
@@ -118,7 +118,7 @@ const getUnifyStatus = (unifyCourses: UnifyStatus): [boolean] | [true, false] =>
/* from & to are semestercodes if separate = false, or yearcodes in case separate is true. */
export const findByCourseAndSemesters = async (
coursecodes: string[],
codes: string[],
from: number,
to: number,
separate: boolean,
@@ -169,7 +169,7 @@ export const findByCourseAndSemesters = async (
credit c
WHERE
c.student_studentnumber = s.studentnumber
AND c.course_code IN (:coursecodes)
AND c.course_code IN (:codes)
AND c.is_open IN (:isOpen)
AND c.attainment_date BETWEEN '${startdate.toISOString()}' AND '${enddate.toISOString()}'
)
@@ -180,7 +180,7 @@ export const findByCourseAndSemesters = async (
enrollment e
WHERE
e.studentnumber = s.studentnumber
AND e.course_code IN (:coursecodes)
AND e.course_code IN (:codes)
AND e.semestercode BETWEEN ${fromSemester} AND ${toSemester}
AND e.enrollment_date_time >= '2021-05-31'
AND e.state = :enrollmentState
@@ -188,7 +188,7 @@ export const findByCourseAndSemesters = async (
`,
{
replacements: {
coursecodes,
codes,
minYearCode: from,
maxYearCode: to,
isOpen: unifyStatus,
@@ -1,6 +1,5 @@
import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash-es'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
import { StudyGuidanceGroupTagModel } from '../models/kone'
@@ -50,7 +49,7 @@ export const getAllStudentsUserHasInGroups = async (sisPersonId: string) => {
.map(group => group.members)
.flat()
.map(member => member.personStudentNumber)
return uniq(students)
return Array.from(new Set(students))
}
const getTagToUpdate = (studyProgramme?: string | null, year?: string | null) => {
@@ -7,8 +7,8 @@ import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, Programme
import logger from '../../util/logger'
export const getAllProgrammeCourses = async (providerCode: string) => {
const courses = await CourseModel.findAll({
attributes: ['id', 'code', 'name', 'substitutions'],
const courses: Pick<CourseModel, 'id' | 'code' | 'name' | 'substitution_groups'>[] = await CourseModel.findAll({
attributes: ['id', 'code', 'name', 'substitution_groups'],
include: [
{
model: OrganizationModel,
@@ -1,7 +1,5 @@
import { range } from 'lodash-es'
import { Name, StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { mapToProviders, range } from '@oodikone/shared/util'
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
@@ -28,8 +26,9 @@ const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
for (const course of normalCourses) {
courseCodes.add(course.code)
if (course.substitutions?.includes(`AY${course.code}`)) {
courseCodes.add(`AY${course.code}`)
// We want all AY course codes variants, even from partially completed susbtitution groups
for (const code of course?.substitution_groups?.flat() ?? []) {
if (code === 'AY' + course.code) courseCodes.add(code)
}
}
@@ -1,7 +1,7 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel } from '../../models'
@@ -13,34 +13,33 @@ export function getYearsArray(since: number, isAcademicYear: false, yearsCombine
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined?: false): number[]
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean): Array<string | number>
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean) {
const years: Array<string | number> = []
const allYears = 'Total'
if (yearsCombined) {
years.push(allYears)
}
const today = new Date()
const until = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
for (let i = since; i <= until; i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
const years: Array<string | number> = []
if (yearsCombined) {
years.push('Total')
}
for (let i = since; i <= until; i++) {
years.push(isAcademicYear ? `${i} - ${i + 1}` : i)
}
return years
}
export function getYearsObject(params: { years: Array<string | number>; emptyArrays: true }): Record<string, []>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, 0>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, number>
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: boolean }) {
const { years, emptyArrays = false } = params
return years.reduce<Record<string, 0 | []>>((acc, year) => {
acc[year] = emptyArrays ? [] : 0
return acc
}, {})
const { years, emptyArrays } = params
return Object.fromEntries(years.map(year => [year, emptyArrays ? [] : 0]))
}
export const getStatsBasis = (years: Array<string | number>) => {
return {
graphStats: new Array<number>(years.length).fill(0),
tableStats: getYearsObject({ years }) as Record<string, number>,
tableStats: getYearsObject({ years }),
}
}
@@ -57,6 +56,7 @@ export const getMedian = (values: number[]) => {
return (sorted[half - 1] + sorted[half]) / 2.0
}
/** @returns academic starting year e.g. "2019-2020" */
export function defineYear(date: Date, isAcademicYear: true): string
export function defineYear(date: Date, isAcademicYear: false): number
export function defineYear(date: Date, isAcademicYear: boolean): string | number
@@ -75,8 +75,109 @@ export function defineYear(date: Date, isAcademicYear: boolean) {
return `${year} - ${year + 1}`
}
export const getStartDate = (isAcademicYear: boolean) => {
return isAcademicYear ? new Date('2017-08-01') : new Date('2017-01-01')
const filterCreditsByDate = (credits: Credit[], threshold: Date) =>
credits.filter(credit => threshold <= credit.attainment_date)
export const getCreditCount = (credits: Credit[], startDate: Date) =>
filterCreditsByDate(credits, startDate).reduce((prev, curr) => prev + curr.credits, 0)
/**
* @param startDate exact date when started in programme to cut credit attainment date
* @param startYear academic start year
*/
export const getMonthlyCredits = (
credits: Credit[],
startDate: Date,
startYear: number,
monthlyCredits: Record<string, number[]>
) => {
const filteredCredits = filterCreditsByDate(credits, startDate)
const studentMonthlyCredits = getMonthlyCreditsObj(startYear)
/* Add all credits to a per-month credit map */
for (const credit of filteredCredits) {
const creditKey = `${credit.attainment_date.getFullYear()}-${credit.attainment_date.getMonth() + 1}`
studentMonthlyCredits[creditKey].push(credit.credits)
}
/* Add monthly cumulative credits of individual student to the main obj,
must iterate over months in order */
let accumulator = 0
const sortedKeys = Object.keys(monthlyCredits).sort((a, b) => +new Date(a) - +new Date(b))
for (const creditKey of sortedKeys) {
for (const credit of studentMonthlyCredits[creditKey]) {
accumulator += credit
}
monthlyCredits[creditKey].push(accumulator)
}
}
export const getStartDate = (isAcademicYear: boolean, year = 2017) => {
return isAcademicYear ? new Date(`${year}-08-01`) : new Date(`${year}-01-01`)
}
export const getYearlyMonthlyCreditsObj = () => {
const yearlyMonthlyCredits: Record<string, ReturnType<typeof getMonthlyCreditsObj>> = {}
const today = new Date()
for (let year = getStartDate(true).getFullYear(); year <= today.getFullYear(); year++) {
yearlyMonthlyCredits[year] = getMonthlyCreditsObj(year)
}
return yearlyMonthlyCredits
}
const getMonthlyCreditsObj = (academicStartYear?: number) => {
const today = new Date()
const time = getStartDate(true, academicStartYear)
const monthlyCredits: Record<string, number[]> = {}
today.setDate(1)
/* NB: JS months start at 0. We need them to start at 1. */
while (time < today) {
monthlyCredits[`${time.getFullYear()}-${time.getMonth() + 1}`] = []
time.setMonth(time.getMonth() + 1)
}
return monthlyCredits
}
export const computePercentiles = (
yearlyMonthlyCredits: Record<string, Record<string, number[]>>,
percentiles = [10, 25, 50, 75, 90]
) => {
const values: Record<string, Record<string, [string, number][]>> = {}
const interpolate = (percentile: number, credits: number[]) => {
const n = credits.length
if (n === 0) return NaN
const p = percentile / 100
const index = (n - 1) * p
const lower = Math.floor(index)
const upper = Math.ceil(index)
const weight = index - lower
if (upper >= n) return credits[lower]
if (lower === upper) return credits[lower]
return credits[lower] * (1 - weight) + credits[upper] * weight
}
for (const yearKey of Object.keys(yearlyMonthlyCredits)) {
for (const monthlyKey of Object.keys(yearlyMonthlyCredits[yearKey])) {
const sortedCredits = yearlyMonthlyCredits[yearKey][monthlyKey].sort((a, b) => a - b)
for (const percentile of percentiles) {
values[yearKey] ??= {}
values[yearKey][percentile] ??= []
values[yearKey][percentile].push([monthlyKey, Number(interpolate(percentile, sortedCredits).toFixed(1))])
}
}
}
return values
}
// In the object programmes should be {bachelorCode: masterCode}
@@ -1,7 +1,7 @@
import { col, fn, Includeable, Op } from 'sequelize'
import { SISStudyRight } from '@oodikone/shared/models'
import { Name, CreditTypeCode } from '@oodikone/shared/types'
import { CreditTypeCode } from '@oodikone/shared/types'
import { StudentModel, SISStudyRightModel, SISStudyRightElementModel, CreditModel } from '../../models'
export const getStudyRightsInProgramme = async (
@@ -73,20 +73,15 @@ export const getStudyTracksForProgramme = async (studyProgramme: string) => {
attributes: [[fn('DISTINCT', col('study_track')), 'studyTrack']],
where: {
code: studyProgramme,
study_track: { [Op.ne]: null },
},
raw: true,
})
return result
.map(studyTrack => studyTrack.studyTrack)
.filter(studyTrack => studyTrack != null)
.reduce<Record<string, Name | 'All students of the programme'>>(
(acc, track) => {
acc[track.code] = track.name
return acc
},
{ [studyProgramme]: 'All students of the programme' }
)
return Object.assign(
{ [studyProgramme]: 'All students of the programme' },
Object.fromEntries(result.map(({ studyTrack }) => [studyTrack!.code, studyTrack!.name]))
)
}
export const getSISStudyRightsOfStudents = async (
@@ -96,11 +91,11 @@ export const getSISStudyRightsOfStudents = async (
> =>
(
await SISStudyRightModel.findAll({
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
where: {
studentNumber: {
[Op.in]: studentNumbers,
},
},
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
})
).map(studyRight => studyRight.toJSON())
@@ -1,4 +1,4 @@
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import {
GraduationTimes,
MedianEntry,
@@ -15,12 +15,16 @@ import { getSemestersAndYears } from '../semesters'
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
import {
computePercentiles,
defineYear,
getCreditCount,
getGoal,
getMedian,
getMonthlyCredits,
getPercentage,
getStartDate,
getStudyRightElementsWithPhase,
getYearlyMonthlyCreditsObj,
getYearsArray,
getYearsObject,
hasTransferredFromOrToProgramme,
@@ -28,9 +32,6 @@ import {
} from './studyProgrammeHelpers'
import { getStudyTracksForProgramme } from './studyRightFinders'
const getCreditCount = (credits: Credit[], startDate: Date) =>
credits.filter(credit => startDate <= credit.attainment_date).reduce((prev, curr) => prev + curr.credits, 0)
const getGraduationTimeStats = async (
studyProgramme: string,
graduationTimes: Record<string, Record<string, number[]>>,
@@ -206,8 +207,7 @@ const getMainStatsByTrackAndYear = async (
startedInProgramme
)
if (firstPresentAt) {
const firstPresentYear = defineYear(firstPresentAt, true)
if (year === firstPresentYear) {
if (year === defineYear(firstPresentAt, true)) {
yearlyStats[year][programmeOrStudyTrack].started += 1
}
}
@@ -332,33 +332,41 @@ const getMainStatsByTrackAndYear = async (
emptyArrays: true,
})
const creditCountsByTrack: Record<string, Record<string, number[]>> = {}
const creditCountsCombo: Record<string, number[]> = doCombo
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
: {}
const creditCountsComboByTrack: Record<string, Record<string, number[]>> = {}
const monthlyCreditsByStartingYear = getYearlyMonthlyCreditsObj()
const monthlyCreditsByStartingYearByTrack: Record<string, ReturnType<typeof getYearlyMonthlyCreditsObj>> = {}
const monthlyCreditsByStartingYearCombo = getYearlyMonthlyCreditsObj()
const monthlyCreditsByStartingYearComboByTrack: Record<string, ReturnType<typeof getYearlyMonthlyCreditsObj>> = {}
for (const studyRight of studyRightsOfProgramme) {
if (!studyRight.semesterEnrollments || !studyRight.student) {
continue
}
if (!studyRight.semesterEnrollments || !studyRight.student) continue
const studyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
if (!studyRightElement) {
continue
}
if (!studyRightElement) continue
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
studyRight,
studyRightElement
)
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) {
continue
}
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) continue
const startedInProgramme = new Date(studyRightElement.startDate)
const startYear = defineYear(startedInProgramme, true)
if (!years.includes(startYear)) {
continue
}
if (!years.includes(startYear)) continue
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
const bachelorsStartYear = defineYear(startedInBachelor, true)
/* Stats for "Students of the degree programme by starting year" */
updateCounts(
startYear,
studyProgramme,
@@ -380,18 +388,78 @@ const getMainStatsByTrackAndYear = async (
)
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
// TODO: FIXME: This could be written better
/* Stats for "Monthly credit accumulation of the students by starting year" */
/* DILEMMA: if viewing a master's programme, students with bs+ms study right, but bs start date before 2017-2018
do not belong in any category. Other option would be to show bs+ms based on their start date in master's studies,
but then we would have (worst case) 50-60 years of bachelors studies in prior to show for some old study rights.
*/
if (doCombo && !years.includes(bachelorsStartYear)) continue
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER) {
const bscStartYear = Number(bachelorsStartYear.slice(0, 4))
getMonthlyCredits(
studyRight.student.credits,
startedInBachelor,
bscStartYear,
monthlyCreditsByStartingYearCombo[bscStartYear]
)
if (studyTrack) {
monthlyCreditsByStartingYearComboByTrack[studyTrack] ??= getYearlyMonthlyCreditsObj()
getMonthlyCredits(
studyRight.student.credits,
startedInBachelor,
bscStartYear,
monthlyCreditsByStartingYearComboByTrack[studyTrack][bscStartYear]
)
}
} else {
const academicStartYear = Number(startYear.slice(0, 4))
getMonthlyCredits(
studyRight.student.credits,
startedInProgramme,
academicStartYear,
monthlyCreditsByStartingYear[academicStartYear]
)
if (studyTrack) {
monthlyCreditsByStartingYearByTrack[studyTrack] ??= getYearlyMonthlyCreditsObj()
getMonthlyCredits(
studyRight.student.credits,
startedInProgramme,
academicStartYear,
monthlyCreditsByStartingYearByTrack[studyTrack][academicStartYear]
)
}
}
/* Stats for Progress of students of the degree programme by starting year" */
/* the DILEMMA from above also applies */
if (!studyRightElement.graduated) {
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER) {
creditCountsCombo[bachelorsStartYear].push(getCreditCount(studyRight.student.credits, startedInBachelor))
if (studyTrack) {
creditCountsComboByTrack[studyTrack] ??= doCombo
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
: {}
creditCountsComboByTrack[studyTrack][startYear].push(
getCreditCount(studyRight.student.credits, startedInProgramme)
)
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
if (studyTrack) {
creditCountsByTrack[studyTrack] ??= getYearsObject({
years: years.filter(year => year !== 'Total'),
emptyArrays: true,
})
creditCountsByTrack[studyTrack][startYear].push(
getCreditCount(studyRight.student.credits, startedInProgramme)
)
}
}
}
}
@@ -416,10 +484,9 @@ const getMainStatsByTrackAndYear = async (
}
}
const graduatedCount: Record<string, number> = {}
years.forEach(year => {
graduatedCount[year] = yearlyStats[year] ? yearlyStats[year][studyProgramme].graduated : 0
})
const graduatedCount: Record<string, number> = Object.fromEntries(
years.map(year => [year, yearlyStats[year]?.[studyProgramme].graduated ?? 0])
)
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
years,
@@ -428,6 +495,13 @@ const getMainStatsByTrackAndYear = async (
combinedProgramme
)
const graduatedCountByTrack: Record<string, Record<string, number>> = Object.fromEntries(
Object.keys(mainStatsByTrack).map(track => [
track,
Object.fromEntries(years.map(year => [year, yearlyStats[year]?.[track]?.graduated ?? 0])),
])
)
const finalGraduationTimes = await getGraduationTimeStats(
studyProgramme,
graduationTimes,
@@ -444,12 +518,19 @@ const getMainStatsByTrackAndYear = async (
)
return {
monthlyCreditsByStartingYear,
monthlyCreditsByStartingYearByTrack,
monthlyCreditsByStartingYearCombo,
monthlyCreditsByStartingYearComboByTrack,
mainStatsByYear,
mainStatsByTrack,
otherCountriesCount,
creditCounts,
creditCountsByTrack,
creditCountsCombo,
creditCountsComboByTrack,
graduatedCount,
graduatedCountByTrack,
graduationTimes: finalGraduationTimes,
graduationTimesSecondProg: finalCombinedGraduationTimes,
}
@@ -468,12 +549,10 @@ export const getStudyTrackStatsForStudyProgramme = async ({
settings: { specialGroups: boolean }
studyRightsOfProgramme: SISStudyRight[]
}) => {
const isAcademicYear = true
const includeYearsCombined = true
const since = getStartDate(isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear, includeYearsCombined)
const years = getYearsArray(getStartDate(true).getFullYear(), true, true)
const studyTracks = await getStudyTracksForProgramme(studyProgramme)
const hasStudyTracks = Object.keys(studyTracks).length > 1
const doCombo = await shouldIncludeComboStats(studyProgramme)
const stats = await getMainStatsByTrackAndYear(
@@ -485,17 +564,72 @@ export const getStudyTrackStatsForStudyProgramme = async ({
combinedProgramme
)
const getCorrectCombinedTitles = () => {
if (combinedProgramme === 'MH90_001') return tableTitles.studytracksCombined.licentiate
return tableTitles.studytracksCombined.master
}
const graduatedTitles = combinedProgramme ? getCorrectCombinedTitles() : tableTitles.studytracksBasic
const getClassSize = (accumulatedCredits: typeof stats.monthlyCreditsByStartingYear) =>
Object.fromEntries(
Object.entries(accumulatedCredits).map(([year, values]) => [year, Object.values(values).at(0)?.length ?? 0])
)
const classSizes: StudyTrackStats['classSizes'] = {
main: getClassSize(stats.monthlyCreditsByStartingYear),
combo: doCombo ? getClassSize(stats.monthlyCreditsByStartingYearCombo) : undefined,
byTrack: hasStudyTracks
? Object.fromEntries(
Object.keys(stats.monthlyCreditsByStartingYearByTrack).map(track => [
track,
getClassSize(stats.monthlyCreditsByStartingYearByTrack[track]),
])
)
: undefined,
comboByTrack:
doCombo && hasStudyTracks
? Object.fromEntries(
Object.keys(stats.monthlyCreditsByStartingYearComboByTrack).map(track => [
track,
getClassSize(stats.monthlyCreditsByStartingYearComboByTrack[track]),
])
)
: undefined,
}
const percentiles: StudyTrackStats['percentiles'] = {
main: computePercentiles(stats.monthlyCreditsByStartingYear),
byTrack: hasStudyTracks
? Object.fromEntries(
Object.keys(stats.monthlyCreditsByStartingYearByTrack).map(track => [
track,
computePercentiles(stats.monthlyCreditsByStartingYearByTrack[track]),
])
)
: undefined,
combo: doCombo ? computePercentiles(stats.monthlyCreditsByStartingYearCombo) : undefined,
comboByTrack:
doCombo && hasStudyTracks
? Object.fromEntries(
Object.keys(stats.monthlyCreditsByStartingYearComboByTrack).map(track => [
track,
computePercentiles(stats.monthlyCreditsByStartingYearComboByTrack[track]),
])
)
: undefined,
}
const graduatedTitles = combinedProgramme
? combinedProgramme === 'MH90_001'
? tableTitles.studytracksCombined.licentiate
: tableTitles.studytracksCombined.master
: tableTitles.studytracksBasic
// FIXME: Clean this object. Thanks.
const studyTrackStats: StudyTrackStats = {
creditCounts: stats.creditCounts,
creditCountsByTrack: stats.creditCountsByTrack,
creditCountsCombo: stats.creditCountsCombo,
creditCountsComboByTrack: stats.creditCountsComboByTrack,
percentiles,
classSizes,
doCombo,
graduatedCount: stats.graduatedCount,
graduatedCountByTrack: stats.graduatedCountByTrack,
graduationTimes: stats.graduationTimes,
graduationTimesSecondProg: stats.graduationTimesSecondProg,
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
+17 -49
View File
@@ -1,17 +1,15 @@
import { isEqual, keyBy, omit, uniq } from 'lodash-es'
import { LRUCache } from 'lru-cache'
import { User } from '@oodikone/shared/models/user'
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
import { DetailedProgrammeRights, Role, ExpandedUser, FormattedUser, IamAccess } from '@oodikone/shared/types'
import { keyBy, omitKeys } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { UserModel } from '../models/user'
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
import { sendNotificationAboutNewUser } from './mailService'
import { getSisuAuthData, personSearchQuery, getGraphqlData } from './oriProvider'
import { getStudentnumbersByElementdetails } from './students'
import { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } from './studyGuidanceGroups'
@@ -49,7 +47,7 @@ export const modifyElementDetails = async (id: string, codes: string[], enable:
throw new Error(`User with id ${id} not found`)
}
if (enable === true) {
user.programmeRights = uniq([...user.programmeRights, ...codes])
user.programmeRights = Array.from(new Set([...user.programmeRights, ...codes]))
} else {
user.programmeRights = user.programmeRights.filter(code => !codes.includes(code))
}
@@ -135,7 +133,16 @@ const formatUser = async (user: ExpandedUser, getStudentAccess = true) => {
const formatUserForFrontend = async (user: ExpandedUser) => {
const formattedUser = await formatUser(user, false)
return omit(formattedUser, ['studentsUserCanAccess', 'isAdmin', 'mockedBy', 'userId'])
return omitKeys(formattedUser, ['studentsUserCanAccess', 'isAdmin', 'mockedBy', 'userId'])
}
const arraysAreEqual = <T>(a: T[], b: T[]): boolean => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
const updateAccessGroups = async (
@@ -145,7 +152,7 @@ const updateAccessGroups = async (
userFromDb: UserModel
) => {
const { jory, superAdmin, openUni, fullSisuAccess } = specialGroup
const { jory, superAdmin, fullSisuAccess } = specialGroup
const currentAccessGroups = userFromDb?.roles ?? []
const newAccessGroups = [
@@ -156,11 +163,10 @@ const updateAccessGroups = async (
...(jory || iamGroups.includes(facultyStatisticsGroup) ? ['facultyStatistics'] : []),
...(currentAccessGroups.includes('teachers') ? ['teachers'] : []),
...(superAdmin || currentAccessGroups.includes('admin') ? ['admin'] : []),
...(openUni ? ['openUniSearch'] : []),
...(fullSisuAccess ? ['fullSisuAccess'] : []),
] as Role[]
if (!isEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
if (!arraysAreEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
userFromDb.roles = newAccessGroups
await userFromDb.save()
}
@@ -173,7 +179,7 @@ export const findAll = async () => {
const formattedUsers = await Promise.all(
users.map(async user => {
const { iamGroups, specialGroup, access } = userAccessMap[user.sisuPersonId] || {}
const { iamGroups, specialGroup, access } = userAccessMap[user.sisuPersonId] ?? {}
const detailedProgrammeRights = getStudyProgrammeRights(access, specialGroup, user.programmeRights)
const formattedUser = await formatUserForFrontend({ ...user, detailedProgrammeRights, iamGroups })
return formattedUser
@@ -194,10 +200,6 @@ export const findOne = async (id: string) => {
return formattedUser
}
export const findByUsername = async (username: string) => {
return (await UserModel.findOne({ where: { username } }))?.toJSON()
}
export const getOrganizationAccess = async (sisPersonId: string, iamGroups: string[]) => {
if (!iamGroups.length) {
return {}
@@ -262,6 +264,7 @@ export const getUser = async ({
sisuPersonId: sisId,
lastLogin: new Date(),
})
await updateAccessGroups(iamGroups, specialGroup, sisId, upsertResult)
const userJSON = upsertResult.toJSON()
@@ -272,38 +275,3 @@ export const getUser = async ({
return user
}
export const addNewUser = async user => {
const name = user.first_name.concat(' ', user.last_name)
try {
await UserModel.upsert({
fullName: name,
username: user.eppn,
email: user.email_address,
sisuPersonId: user.id,
roles: [],
lastLogin: new Date(),
})
return true
} catch (error) {
throw new Error('Could not add or update user.')
}
}
export const getUserFromSisuByEppn = async (requesterEppn: string, newUserEppn: string) => {
const { accessToken: requesterAccessToken } = await getSisuAuthData(requesterEppn)
const { tokenData: newUserTokenData } = await getSisuAuthData(newUserEppn)
const personData = await getGraphqlData(requesterAccessToken, {
query: personSearchQuery,
variables: { subjectUserId: newUserTokenData.personid },
})
return personData
}
export const deleteUserById = async userId => {
await UserModel.destroy({
where: {
id: userId,
},
})
}
-1
View File
@@ -1 +0,0 @@
export type IamAccess = Record<string, Record<'read' | 'write' | 'admin', boolean>>
-2
View File
@@ -1,3 +1 @@
export { ParsedCourse } from './course'
export { IamAccess } from './iamAccess'
export { ExpandedUser, FormattedUser } from './user'
+1 -1
View File
@@ -1,4 +1,4 @@
import { FormattedUser } from './user'
import { FormattedUser } from '@oodikone/shared/types'
declare global {
namespace Express {
+8
View File
@@ -49,6 +49,9 @@ export const hasFullAccessToStudentData = (roles?: Role[]): boolean => {
return !!roles?.some(role => rolesWithFullAccess.includes(role))
}
// Only open uni courses
export const isOpenUniCourseCode = (code: string): boolean => /^AY.+?(?:en|fi|sv)?$/.test(code)
// Open uni AND normal courses
export const getOpenUniCourseCode = (code: string): string[] | null => /^AY?(.+?)(?:en|fi|sv)?$/.exec(code)
/**
@@ -100,3 +103,8 @@ export const safeJSONParse = async <T>(json: string): Promise<T | null> => {
return data
}
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
export const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
return Array.isArray(requestField) ? requestField : [requestField]
}
+1 -1
View File
@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/node'
import axios from 'axios'
import { IamAccess } from '@oodikone/shared/types'
import { importerToken, isStaging, jamiUrl } from '../config'
import { IamAccess } from '../types'
import logger from './logger'
const jamiClient = axios.create({
+1 -4
View File
@@ -1,9 +1,6 @@
import { isSpring } from '@oodikone/shared/util'
import { dateDaysFromNow } from '@oodikone/shared/util/datetime'
const isSpring = (date: Date) => {
return 0 <= date.getMonth() && date.getMonth() <= 6
}
const getSemester = (date: Date) => (isSpring(date) ? 'SPRING' : 'FALL')
export enum SemesterStart {
-18
View File
@@ -1,18 +0,0 @@
// sort substitutions so that main code is first
const newLetterBasedCode = /^[A-Za-z]/ // new letter based codes come first
const oldNumericCode = /^\d/ // old numeric codes come second
const openUniCode = /^AY?(.+?)(?:en|fi|sv)?$/ // open university codes come last
const openUniCodeA = /A\d/ // open university with just A come last
const digi = /DIGI-A?(.+?)(?:en|fi|sv)?$/ // digi-a goes on top courses goes third
const bscsCode = /BSCS??/
const codeRegexes = [openUniCodeA, openUniCode, bscsCode, oldNumericCode, newLetterBasedCode, digi]
export const getSortRank = (code: string): number => {
for (let i = 0; i < codeRegexes.length; i++) {
if (codeRegexes[i].test(code)) {
return i
}
}
return 3 // if no hit, put before open uni courses
}
-92
View File
@@ -1,92 +0,0 @@
const { Queue, FlowProducer } = require('bullmq')
const { redis } = require('../config')
const connection = {
host: redis,
port: 6379,
}
const defaultJobOptions = {
attempts: 3,
removeOnComplete: true,
removeOnFail: true,
backoff: {
type: 'exponential',
delay: 60 * 1000,
},
}
const queueName = 'refresh-redis-data'
const queue = new Queue(queueName, {
connection,
defaultJobOptions,
})
const flowProducer = new FlowProducer({
connection,
})
const addJob = (type, data, keep) => {
// job name (first arg) makes the job unique, and we want unique based on faculty/programme code
const name = data?.code ? `${type}-${data.code}` : type
queue.add(name, { ...data }, { jobId: name, keepJobs: keep ?? 0 })
}
const getJobs = async type => {
let jobs = []
if (type === 'waiting') {
jobs = await queue.getWaiting()
} else if (type === 'active') {
jobs = await queue.getActive()
}
return jobs
}
const removeWaitingJobs = async () => {
await queue.drain()
}
const jobMaker = {
faculty: code => addJob('faculty', { code }),
programme: code => addJob('programme', { code }),
teacherLeaderboard: () => addJob('teacherLeaderboard'),
languagecenter: () => addJob('languagecenter'),
studyplansUpdate: days => addJob('studyplansUpdate', { days }, 7200),
closeToGraduation: () => addJob('closeToGraduation'),
}
const addToFlow = async (facultyCode, programmeCodes) => {
await flowProducer.add({
name: `faculty-${facultyCode}`,
data: {
code: facultyCode,
},
queueName,
children: programmeCodes.map(programmeCode => ({
name: `programme-${programmeCode}`,
data: {
code: programmeCode,
},
queueName,
opts: {
jobId: `programme-${programmeCode}`,
...defaultJobOptions,
},
})),
opts: {
jobId: `faculty-${facultyCode}`,
...defaultJobOptions,
},
})
}
module.exports = {
jobMaker,
getJobs,
removeWaitingJobs,
defaultJobOptions,
queueName,
addToFlow,
}
+98
View File
@@ -0,0 +1,98 @@
import { Queue, FlowProducer, BaseJobOptions } from 'bullmq'
import { redis } from '../config'
const connection = {
host: redis,
port: 6379,
}
const defaultJobOptions = {
attempts: 3,
removeOnComplete: true,
removeOnFail: true,
backoff: {
type: 'exponential',
delay: 60 * 1000,
},
}
/**
* Pushes jobs to redis for a worker to pick them up
*/
class JobQueue {
#queue: Queue
#flowProducer: FlowProducer
name = 'refresh-redis-data'
constructor() {
this.#queue = new Queue(this.name, {
connection,
defaultJobOptions,
})
this.#flowProducer = new FlowProducer({
connection,
})
}
#add(type: string, data?: any, jobOpts?: Partial<BaseJobOptions>) {
// name makes the job unique, and we want uniqueness based on faculty/programme code
const name = data?.code ? `${type}-${data.code}` : type
void this.#queue.add(name, { ...data }, { ...jobOpts, jobId: name })
}
async clearWaitingJobs() {
await this.#queue.drain()
}
async getJobs() {
const [waiting, active] = await Promise.all([this.#queue.getWaiting(), this.#queue.getActive()])
return { waiting, active }
}
refreshFaculty(code: string) {
this.#add('faculty', { code })
}
refreshProgramme(code: string) {
this.#add('programme', { code })
}
refreshTeacherLeaderboard() {
this.#add('teacherLeaderboard')
}
refreshLanguagecenter() {
this.#add('languagecenter')
}
refreshStudyplans(days: number) {
this.#add('studyplansUpdate', { days })
}
refreshCloseToGraduation() {
this.#add('closeToGraduation')
}
/** Flow ensures all child jobs (programmes) are processed before the parent (faculty) */
async addToFlow(facultyCode: string, programmeCodes: string[]) {
await this.#flowProducer.add({
name: `faculty-${facultyCode}`,
data: {
code: facultyCode,
},
queueName: this.name,
children: programmeCodes.map(programmeCode => ({
name: `programme-${programmeCode}`,
data: {
code: programmeCode,
},
queueName: this.name,
opts: {
jobId: `programme-${programmeCode}`,
...defaultJobOptions,
},
})),
opts: {
jobId: `faculty-${facultyCode}`,
...defaultJobOptions,
},
})
}
}
export const jobQueue = new JobQueue()
@@ -1,10 +1,10 @@
const Sentry = require('@sentry/node')
const { Worker: BullMQWorker } = require('bullmq')
const dayjs = require('dayjs')
import * as Sentry from '@sentry/node'
import { Worker as BullMQWorker } from 'bullmq'
import dayjs from 'dayjs'
const { redis, concurrentWorkers } = require('../config')
const logger = require('../util/logger').default
const { queueName } = require('./queue')
import { redis, concurrentWorkers } from '../config'
import logger from '../util/logger'
import { jobQueue } from './queue'
const connection = {
host: redis,
@@ -14,7 +14,7 @@ const connection = {
// https://github.com/taskforcesh/bullmq/issues/2075#issuecomment-1646079335
process.execArgv = process.execArgv.filter(arg => !arg.includes('--max_old_space_size='))
const worker = new BullMQWorker(queueName, `${__dirname}/processor.js`, {
const worker = new BullMQWorker(jobQueue.name, `${__dirname}/processor.js`, {
connection,
useWorkerThreads: true,
concurrency: concurrentWorkers,
@@ -38,7 +38,7 @@ worker.on('error', error => {
})
worker.on('failed', (job, error) => {
logger.error(`Job ${job.id} failed. ${error.stack ?? ''}`)
logger.error(`Job ${job?.id ?? 'unknown id'} failed. ${error.stack ?? ''}`)
Sentry.captureException(error)
})
@@ -5,8 +5,8 @@ import { Sequelize } from 'sequelize'
import request, { Response } from 'supertest'
import { PopulationstatisticsResBody } from '@oodikone/shared/routes/populations'
import { dbConnections } from '../src/database/connection'
import { initTests } from './utils'
import { dbConnections } from '../../src/database/connection'
import { initTests } from '../utils'
let app: Express
let connection: Sequelize
@@ -24,7 +24,7 @@ const populationUrl = (
semesters: ('SPRING' | 'FALL')[] = [],
students: string[] = []
) => {
return `/v3/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
return `/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
}
void describe('Population statistics', async () => {
@@ -0,0 +1,131 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics by course', async () => {
await it('should not return anything with missing parameters', async () => {
const res = await request(app)
.get('/populationstatisticsbycourse')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.deepEqual(res.status, 400)
assert.deepEqual(res.body.error, 'The body should have a yearcode and coursecode defined')
})
await it('should return correct amount of students for a course', async () => {
const res = await request(app)
.get('/populationstatisticsbycourse?coursecodes=["MAT21005"]&from=70&to=70')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
// Copied from response
const testStudent = {
firstnames: 'Mira Ilmatar',
lastname: 'Lintula',
started: '2018-08-01T00:00:00.000Z',
studentNumber: '394776',
credits: 265,
hopsCredits: 0,
name: 'Lintula Mira Ilmatar',
gender_code: '2',
email: 'sisutestidata65689@testisisudata.fi',
secondaryEmail: null,
phoneNumber: '+358501234567',
updatedAt: '2026-05-24T13:43:07.827Z',
studyrightStart: '1899-12-31T22:20:11.000Z',
option: null,
birthdate: '1999-03-01T00:00:00.000Z',
sis_person_id: 'hy-hlo-125389506',
citizenships: [
{
en: 'Finland',
fi: 'Suomi',
sv: 'Finland',
},
],
curriculumVersion: null,
hasPersonalIdentityCode: null,
tags: [],
transferredStudyright: false,
studyRights: [
{
admissionType: null,
cancelled: false,
extentCode: 5,
facultyCode: 'H50',
id: 'hy-opinoik-125389507',
semesterEnrollments: [
{ semester: 137, type: 1 },
{ semester: 138, type: 1 },
{ semester: 139, type: 1 },
{ semester: 140, type: 1 },
{ semester: 141, type: 1 },
{ semester: 142, type: 1 },
{ semester: 143, type: 1 },
{ semester: 144, type: 1 },
{ semester: 145, type: 1 },
{ semester: 146, type: 1 },
{ semester: 147, type: 1 },
{ semester: 148, statutoryAbsence: false, type: 2 },
{ semester: 149, type: 3 },
{ semester: 150, type: 3 },
{ semester: 151, type: 3 },
],
startDate: '2018-08-01T00:00:00.000Z',
studyRightElements: [
{
code: 'KH50_001',
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
endDate: '2021-06-11T00:00:00.000Z',
graduated: true,
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
phase: 1,
startDate: '2018-08-01T00:00:00.000Z',
studyTrack: {
code: 'MAT-MAT',
name: {
fi: 'Matematiikka',
},
},
},
{
code: 'MH50_001',
degreeProgrammeType: 'urn:code:degree-program-type:masters-degree',
endDate: '2025-12-31T00:00:00.000Z',
graduated: false,
name: {
en: "Master 's Programme in Mathematics and Statistics",
fi: 'Matematiikan ja tilastotieteen maisteriohjelma',
sv: 'Magisterprogrammet i matematik och statistik',
},
phase: 2,
startDate: '2021-06-12T00:00:00.000Z',
studyTrack: null,
},
],
tvex: false,
},
],
studyplans: [],
}
assert.strictEqual(res.status, 200)
assert.strictEqual(res.body.students.length, 40)
assert.deepStrictEqual(res.body.students.sort((a, b) => b.studentNumber - a.studentNumber).at(-1), testStudent)
})
})
@@ -0,0 +1,23 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics by student number', async () => {
await it('should not return anything with missing parameters', async () => {
const res = await request(app)
.get('/populationstatisticsbystudentnumbers')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.deepEqual(res.status, 400)
})
})
@@ -0,0 +1,101 @@
import { Express } from 'express'
import assert from 'node:assert/strict'
import { describe, it, before } from 'node:test'
import request from 'supertest'
import { initTests } from '../utils'
let app: Express
before(async () => {
app = await initTests()
})
void describe('Population statistics (study programme)', async () => {
await it('should return correctly formatted responses', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
assert.strictEqual(res.status, 200)
assert.strictEqual(Object.keys(res.body).includes('allProgrammes'), true)
assert.strictEqual(Object.keys(res.body).includes('filteredProgrammes'), true)
assert.notStrictEqual(Object.keys(res.body.allProgrammes).length, 0)
assert.notStrictEqual(Object.keys(res.body.filteredProgrammes).length, 0)
})
await it('should return correct programmes to allProgrammes', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
const testMathProgramme = {
code: 'KH50_001',
curriculumPeriodIds: [
'hy-lv-76',
'hy-lv-74',
'hy-lv-75',
'hy-lv-72',
'hy-lv-73',
'hy-lv-71',
'hy-lv-69',
'hy-lv-68',
'hy-lv-70',
],
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
progId: 'MAT',
}
assert.strictEqual(Object.keys(res.body.allProgrammes).length, 972)
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme)
})
await it('should return the programmes, that user has access to, to filteredProgrammes', async () => {
const res = await request(app)
.get('/populationstatistics/studyprogrammes')
.set('shib-session-id', 'test')
.set('uid', 'basic')
.set('hygroupcn', 'grp-oodikone-basic-users')
const testMathProgramme = {
code: 'KH50_001',
curriculumPeriodIds: [
'hy-lv-76',
'hy-lv-74',
'hy-lv-75',
'hy-lv-72',
'hy-lv-73',
'hy-lv-71',
'hy-lv-69',
'hy-lv-68',
'hy-lv-70',
],
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
name: {
en: "Bachelor's Programme in Mathematical Sciences",
fi: 'Matemaattisten tieteiden kandiohjelma',
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
},
progId: 'MAT',
}
assert.strictEqual(Object.keys(res.body.filteredProgrammes).length, 2) // Basic has access to math bachelor + master
assert.deepStrictEqual(res.body.filteredProgrammes.KH50_001, testMathProgramme)
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme) // All programmes should still include filteredProgramme programmes
})
await it.todo(
'should return both degree levels of study programme when searching KH90_001 or MH90_001 (ELK, ELL) in filteredProgrammes'
)
})
+3 -3
View File
@@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2023"],
"target": "ES2024",
"lib": ["ES2024"],
"allowJs": true,
"moduleResolution": "nodenext",
"module": "nodenext",
"moduleResolution": "nodenext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "dist",
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+2 -1
View File
@@ -34,7 +34,8 @@ WORKDIR /opt/app-root/shared
COPY ./shared .
WORKDIR /opt/app-root/frontend
COPY ./frontend/package*.json .
COPY ./frontend/.npmrc .
COPY ./frontend/package*.json ./
RUN npm ci --include=dev
COPY ./frontend .
+4 -2
View File
@@ -1,9 +1,11 @@
FROM node:24-alpine
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/frontend
COPY . .
COPY --chown=1001:1001 . .
USER 1001
RUN npm ci
EXPOSE 3000
+180 -9
View File
@@ -8,7 +8,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.8",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@mui/icons-material": "^6.4.12",
"@mui/material": "^6.4.12",
"@mui/x-date-pickers": "^7.29.4",
@@ -16,12 +16,11 @@
"@reduxjs/toolkit": "^2.11.2",
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"lodash-es": "^4.18.1",
"material-react-table": "^3.2.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
@@ -30,10 +29,12 @@
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
"react-router": "^7.6.2",
"rehype-raw": "7.0.0",
"unfuck-spa-shibboleth-session": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -45,6 +46,7 @@
"../shared": {
"name": "@oodikone/shared",
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
},
@@ -775,10 +777,10 @@
"node": ">=18"
}
},
"node_modules/@fontsource/roboto": {
"node_modules/@fontsource/ibm-plex-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.8.tgz",
"integrity": "sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA==",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz",
"integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -2104,12 +2106,14 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
@@ -2522,6 +2526,18 @@
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -2824,6 +2840,64 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -2851,6 +2925,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -2864,6 +2957,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight-words": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
@@ -2899,6 +3009,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
@@ -3039,9 +3159,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/longest-streak": {
@@ -3841,6 +3961,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -4090,6 +4222,21 @@
"redux": "^5.0.0"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -4476,6 +4623,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -4565,6 +4726,16 @@
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+4 -3
View File
@@ -11,7 +11,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.8",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@mui/icons-material": "^6.4.12",
"@mui/material": "^6.4.12",
"@mui/x-date-pickers": "^7.29.4",
@@ -19,12 +19,11 @@
"@reduxjs/toolkit": "^2.11.2",
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lodash-es": "^4.17.23",
"lodash-es": "^4.18.1",
"material-react-table": "^3.2.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
@@ -33,10 +32,12 @@
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
"react-router": "^7.6.2",
"rehype-raw": "7.0.0",
"unfuck-spa-shibboleth-session": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -1,11 +1,9 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import * as Sentry from '@sentry/browser'
import axios from 'axios'
import axios, { AxiosProgressEvent } from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { actionHistory } from '@/redux/actionHistory'
import { formatToArray } from '@oodikone/shared/util'
import { queryParamsToString } from '@/util/queryparams'
// Set up dev user for development environment, mimicking production admin user
const baseHeaders = isDev
@@ -33,17 +31,18 @@ const getHeaders = () => {
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
const actionTypes = prefix => ({
attempt: `${prefix}ATTEMPT`,
failure: `${prefix}FAILURE`,
success: `${prefix}SUCCESS`,
})
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
export const callApi = async (
url,
method = 'get',
data?,
params = {},
timeout = 0,
progressCallback?: (progress: number) => void
) => {
const options = { headers: getHeaders(), timeout }
const onDownloadProgress = ({ loaded, total }) => {
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
const onDownloadProgress = ({ loaded, total }: AxiosProgressEvent) => {
if (progressCallback) progressCallback(Math.min(100, Math.round((loaded / (total ?? 1)) * 100)))
}
switch (method) {
@@ -60,39 +59,6 @@ export const callApi = async (url, method = 'get', data, params, timeout = 0, pr
}
}
const handleError = (error, actionHistory = []) => {
const { config, request, response } = error
if (response && response.status) {
Sentry.withScope(scope => {
scope.setExtra('config', config)
scope.setExtra('request', request)
scope.setExtra('response', response)
scope.setExtra('actionHistory', JSON.stringify(actionHistory))
Sentry.captureException(error)
})
}
}
export const handleRequest = store => next => async action => {
if (action.requestSettings) {
const { success, failure } = actionTypes(prefix)
const { route, method, data, prefix, query, params, onProgress } = action.requestSettings
// Based no the previous config. We don't actually care when this finishes.
void callApi(route, method, data, params, 0, onProgress)
.then(res => store.dispatch({ type: success, response: res.data, query }))
.catch(error => {
// Handle error first to avoid redux minified error #3 in production.
// We don't need to add the error to the actionHistory as it is already passed to Sentry.
handleError(error, actionHistory)
store.dispatch({ type: failure, response: error, query })
})
}
next(action)
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
@@ -117,16 +83,7 @@ export const RTKApi = createApi({
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
return headers
},
paramsSerializer: params => {
const searchParams = new URLSearchParams()
Object.entries(params).map(([key, val]) => {
const subfix = Array.isArray(val) ? '[]' : ''
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
})
return searchParams.toString()
},
paramsSerializer: params => queryParamsToString(params),
}),
endpoints: () => ({}),
})
@@ -1,14 +1,14 @@
export const closeToGraduationToolTips = `
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each degree programme are as follows:
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each degree programme are as follows:
- All bachelor’s programmes: 140 credits
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
- Degree Programme in Medicine (lääketieteen koulutusohjelma): 330 credits
- Degree Programme in Dentistry (hammaslääketieteen koulutusohjelma): 300 credits
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
- Other master’s programmes: 70 credits
- All bachelor’s programmes: 140 credits
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
- Degree Programme in Medicine (lääketieteen koulutusohjelma): 330 credits
- Degree Programme in Dentistry (hammaslääketieteen koulutusohjelma): 300 credits
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
- Other master’s programmes: 70 credits
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
`
@@ -1,11 +1,11 @@
export const coursePopulationToolTips = {
students: `
Taulukko näyttää oletusarvoisesti vain opiskelijanumerot, mutta harmaata liukukytkintä klikkaamalla saa näkyviin opiskelijoiden nimet.
Taulukko näyttää oletusarvoisesti vain opiskelijanumerot, mutta harmaata liukukytkintä klikkaamalla saa näkyviin opiskelijoiden nimet.
- **Student number:** Klikkaamalla opiskelijanumeron vieressä olevaa sinistä nuolta, siirtyy "student statistic" -näkymään valitun opiskelijan osalta.
- **All credits:** listaa opiskelijan kaikki opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
- **Degree programme**-sarake näyttää opiskelijan uusimman opinto-oikeuden sillä hetkellä, kun hän ilmoittautui valitulle kurssille, tai kurssin suoritusaikana jos kurssi-ilmoittautumista ei tapahtunut. Jos opiskelijalla on ollut silloin opinto-oikeuksia muihinkin koulutusohjelmiin, näkyy ohjelman nimen perässä kolme suurta pistettä. Laittamalla hiiren kyseisen solun kohdalle, kaikki koulutusohjelmat tulevat näkyviin. Ne näkyvät myös Excel-tiedostossa aikajärjestyksessä uusimmasta vanhimpaan.
- **Start year at uni:** Aloitusvuosi yliopistolla.
- **Tags:** Opiskelijoille voi luoda tageja ryhmittelyä varten kohdassa _Degree Program > Overview > haluttu koulutusohjelma_.
`,
- **Student number:** Klikkaamalla opiskelijanumeron vieressä olevaa sinistä nuolta, siirtyy "student statistic" -näkymään valitun opiskelijan osalta.
- **All credits:** listaa opiskelijan kaikki opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
- **Degree programme**-sarake näyttää opiskelijan uusimman opinto-oikeuden sillä hetkellä, kun hän ilmoittautui valitulle kurssille, tai kurssin suoritusaikana jos kurssi-ilmoittautumista ei tapahtunut. Jos opiskelijalla on ollut silloin opinto-oikeuksia muihinkin koulutusohjelmiin, näkyy ohjelman nimen perässä kolme suurta pistettä. Laittamalla hiiren kyseisen solun kohdalle, kaikki koulutusohjelmat tulevat näkyviin. Ne näkyvät myös Excel-tiedostossa aikajärjestyksessä uusimmasta vanhimpaan.
- **Start year at uni:** Aloitusvuosi yliopistolla.
- **Tags:** Opiskelijoille voi luoda tageja ryhmittelyä varten kohdassa _Degree Program > Overview > haluttu koulutusohjelma_.
`,
}
@@ -1,53 +1,53 @@
export const courseStatisticsToolTips = {
tables: {
ATTEMPTS: `
**Table - Attempts**
**Table - Attempts**
Taulukko näyttää suorituskerrat lukuvuositasolla. Samalla opiskelijalla voi olla useampia suorituskertoja.
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv. (sarakkeessa Other passed).
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin).
Taulukko näyttää suorituskerrat lukuvuositasolla. Samalla opiskelijalla voi olla useampia suorituskertoja.
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv. (sarakkeessa Other passed).
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin).
Käyttämällä vipua "Show grades" saa näkyviin suorituskertojen arvosanajakauman.
Käyttämällä vipua "Separate semesters" suoritukset jaetaan kevät- ja syyslukukausien mukaan.
`,
Käyttämällä vipua "Show grades" saa näkyviin suorituskertojen arvosanajakauman.
Käyttämällä vipua "Separate semesters" suoritukset jaetaan kevät- ja syyslukukausien mukaan.
`,
STUDENTS: `
**Table - Students**
**Table - Students**
Taulukko näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
Lukuvuosi määräytyy sen lukuvuoden perusteella, jolloin kyseinen opiskelija on ensimmäisen kerran kurssia yrittänyt.
Taulukko näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
Lukuvuosi määräytyy sen lukuvuoden perusteella, jolloin kyseinen opiskelija on ensimmäisen kerran kurssia yrittänyt.
Esimerkiksi opiskelija, joka on yrittänyt kurssia syksyllä 2017 ja saanut silloin hylätyn arvosanan ja läpäissyt kurssin
myöhemmin keväällä 2019, tulisi merkityksi lukuvuodelle 2017-2018.
Esimerkiksi opiskelija, joka on yrittänyt kurssia syksyllä 2017 ja saanut silloin hylätyn arvosanan ja läpäissyt kurssin
myöhemmin keväällä 2019, tulisi merkityksi lukuvuodelle 2017-2018.
Total-rivillä näkyy niiden opiskelijoiden kokonaismäärä, jotka ovat saaneet kurssista joskus arvosanan.
`,
Total-rivillä näkyy niiden opiskelijoiden kokonaismäärä, jotka ovat saaneet kurssista joskus arvosanan.
`,
},
passRate: {
ATTEMPTS: `
**Pass rate - Attempts**
**Pass rate - Attempts**
Graafi näyttää suorituskerrat lukuvuositasolla jaoteltuina hyväksyttyihin ja hylättyihin suorituksiin.
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv.
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin). "Enrolled, no grade" sisältää opiskelijat,
jotka ovat ilmoittautuneet kurssille, mutta eivät ole saaneet arvosanaa.
Graafi näyttää suorituskerrat lukuvuositasolla jaoteltuina hyväksyttyihin ja hylättyihin suorituksiin.
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv.
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin). "Enrolled, no grade" sisältää opiskelijat,
jotka ovat ilmoittautuneet kurssille, mutta eivät ole saaneet arvosanaa.
Käyttämällä vipua "Show relative" saa näkyviin suorituskertojen suhteellisen määrän.
`,
Käyttämällä vipua "Show relative" saa näkyviin suorituskertojen suhteellisen määrän.
`,
STUDENTS: `
**Pass rate - Students**
**Pass rate - Students**
Graafi näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
Lukuvuosi määräytyy sen lukuvuoden perusteella, jolloin kyseinen opiskelija on ensimmäisen kerran kurssia yrittänyt.
Graafi näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
Lukuvuosi määräytyy sen lukuvuoden perusteella, jolloin kyseinen opiskelija on ensimmäisen kerran kurssia yrittänyt.
Käyttämällä vipua "Show relative" saa näkyviin opiskelijoiden suhteellisen määrän.
`,
Käyttämällä vipua "Show relative" saa näkyviin opiskelijoiden suhteellisen määrän.
`,
},
gradeDistribution: `
**Grade distribution**
**Grade distribution**
Graafi näyttää suorituskertojen arvosanajakauman eri lukuvuosille. Arvosanaksi 0 lasketaan sekä hylätyt (Failed) arvosanat,
että kurssille ilmoittautuneet opiskelijat, joilla ei ole arvosanaa kurssista (Enrolled, no grade).
`,
Graafi näyttää suorituskertojen arvosanajakauman eri lukuvuosille. Arvosanaksi 0 lasketaan sekä hylätyt (Failed) arvosanat,
että kurssille ilmoittautuneet opiskelijat, joilla ei ole arvosanaa kurssista (Enrolled, no grade).
`,
}
@@ -1,91 +1,101 @@
import { studyProgrammeToolTips } from './studyProgramme'
export const facultyToolTips: Record<string, string> = {
programmeToggle: `
**New degree programmes**: Näyttää kaikissa luvuissa ja kuvaajissa pelkästään uuden opetussuunnitelman ohjelmat.
**All degree programmes**: Näyttää kaikissa luvuissa ja kuvaajissa kuvaajissa myös vanhan opetussuunnitelman ohjelmat.
`,
creditsProducedByTheFaculty: `
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan. Kategoriat vastaavat Rapon kategorioita. Pistemäärät ovat osin epätarkkoja ennen vuotta 2022 sisu-oodi-erojen vuoksi.
export const facultyToolTips = {
common: {
yearToggle: studyProgrammeToolTips.common.yearToggle,
graduatedToggle: studyProgrammeToolTips.common.graduatedToggle,
studyRightToggle: studyProgrammeToolTips.common.studyRightToggle,
studentProgress: `
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
Kurssin järjestäjä voi olla joko tiedekunnan koulutusohjelma tai tiedekunta itse. Tiedekunnan pisteet on laskettu yhteen lukemiin, ja näkyy erillisenä koulutusohjelmien kanssa tarkasteltaessa taulukosta aukeavia vuosia.
- Bachelor: Kandidaatin opinto-oikeudella varustettujen opiskelijoiden eteneminen
- Bachelor + Master: Kandi+maisteriopinto-oikeudella opiskelleiden ja siitä maisteriksi ohjelmiin siirtyneiden etenenminen. Aloitusvuodeksi on merkitty kandiohjelman aloitusvuosi.
Opinto-ohjelmaksi on merkitty maisteriohjelma, johon opiskelija on siirtynyt kandiksi valmistumisen jälkeen.
- Master: Pelkällä maisterintutkinnon opinto-oikeudella varustettujen opiskelijoiden eteneminen
- Doctor: Tohtorin opinto-oikeudella varustettujen opiskelijoiden eteneminen
Total-lukuun lasketaan mukaan kaikki opintopisteet **hyväksiluettuja opintopisteitä lukuun ottamatta**.
HUOM! Mukana ovat myös muuhun kuin valittuun koulutusohjelmaan tai opintosuuntaan tehdyt opintopisteet.
Opintopisteet on laskettu opinto-oikeuden alkamisesta, eikä tällä hetkellä ota huomioon mahdollisia hyväksilukuja.
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
Mukana ovat oletusarvoisesti myös ohjelmaan siirtyneet.
`,
programmeToggle: `
**New degree programmes**: Näyttää kaikissa luvuissa ja kuvaajissa pelkästään uuden opetussuunnitelman ohjelmat.
**All degree programmes**: Näyttää kaikissa luvuissa ja kuvaajissa kuvaajissa myös vanhan opetussuunnitelman ohjelmat.
`,
averageGraduationTimes: `
Opiskelijoiden keskimääräiset valmistumisajat tutkintotasoittain.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
- **Bachelor**: Kandidaatin tutkinnon suorittaneet opiskelijat
- **Bachelor + Master**: Opiskelijat, joiden opiskeluoikeuteen kuuluu **sekä kandidaatin että maisterin** tutkinnon suoritusoikeus
- **Master**: Opiskelijat, joiden opiskeluoikeuteen kuuluu **vain** maisterin tutkinnon suoritusoikeus
- **Doctor**: Tohtorin tutkinnon suorittaneet opiskelijat
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
Valmistumisajoista on **vähennetty lakisääteiset poissaolot**.
Luvuissa ei ole mukana opiskelijoita, jotka ovat vaihtaneet koulutuohjelmaa saman opiskeluoikeuden sisällä (pois lukien kandiohjelmasta maisteriohjelmaan siirtyneet).
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
- **Breakdown**/**Median study times**:
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
- **Graduation year**/**Starting year**:
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
`,
},
**Separate**: Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
basicInformationTab: {
studentsOfTheFaculty:
'Taulukon luvut on laskettu **yhdistämällä** tiedekunnan koulutusohjelmien luvut. Näin ollen tämän näkymän luvut täsmäävät *Degree programme* -näkymän lukuihin. Kategorioiden merkitykset ovat seuraavat:\n'
.concat(studyProgrammeToolTips.common.studentsOfTheStudyProgramme)
.replace('Transferred away', 'Transferred out of programme')
.replace('Transferred to', 'Transferred into programme'),
interactiveDataTable: `
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
`,
creditsProducedByTheFaculty: `
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan. Kategoriat vastaavat Rapon kategorioita. Pistemäärät ovat osin epätarkkoja ennen vuotta 2022 sisu-oodi-erojen vuoksi.
**Other university**: Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
Kurssin järjestäjä voi olla joko tiedekunnan koulutusohjelma tai tiedekunta itse. Tiedekunnan pisteet on laskettu yhteen lukemiin, ja näkyy erillisenä koulutusohjelmien kanssa tarkasteltaessa taulukosta aukeavia vuosia.
**Other**: Sisältää tohtorin- ja lisensiaatintutkintoon liittyvät opintopisteet, täydennyskoulutuksen, erikoistumiskoulutuksen, erikoislääkäri- ja erikoishammaslääkärikoulutuksen sekä lukiolaisten opintopisteet.
`,
graduatedOfTheFaculty: `
Sisältää kyseisenä vuonna tiedekunnasta valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty.
Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
`,
thesisWritersOfTheFaculty: `
Sisältää kyseisenä vuonna kandidaatin- tai maisterintutkielman hyväksytysti suorittaneet opiskelijat.
Mukana ei ole mahdollisia arvosanan korotuksia.
`,
averageGraduationTimes: `
Opiskelijoiden keskimääräiset valmistumisajat tutkintotasoittain.
Total-lukuun lasketaan mukaan kaikki opintopisteet **hyväksiluettuja opintopisteitä lukuun ottamatta**.
- **Bachelor**: Kandidaatin tutkinnon suorittaneet opiskelijat
- **Bachelor + Master**: Opiskelijat, joiden opiskeluoikeuteen kuuluu **sekä kandidaatin että maisterin** tutkinnon suoritusoikeus
- **Master**: Opiskelijat, joiden opiskeluoikeuteen kuuluu **vain** maisterin tutkinnon suoritusoikeus
- **Doctor**: Tohtorin tutkinnon suorittaneet opiskelijat
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
Valmistumisajoista on **vähennetty lakisääteiset poissaolot**.
Luvuissa ei ole mukana opiskelijoita, jotka ovat vaihtaneet koulutuohjelmaa saman opiskeluoikeuden sisällä (pois lukien kandiohjelmasta maisteriohjelmaan siirtyneet).
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
- **Breakdown**/**Median study times**:
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
- **Graduation year**/**Starting year**:
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
studentProgress: `
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
- Bachelor: Kandidaatin opinto-oikeudella varustettujen opiskelijoiden eteneminen
- Bachelor + Master: Kandi+maisteriopinto-oikeudella opiskelleiden ja siitä maisteriksi ohjelmiin siirtyneiden etenenminen. Aloitusvuodeksi on merkitty kandiohjelman aloitusvuosi.
Opinto-ohjelmaksi on merkitty maisteriohjelma, johon opiskelija on siirtynyt kandiksi valmistumisen jälkeen.
- Master: Pelkällä maisterintutkinnon opinto-oikeudella varustettujen opiskelijoiden eteneminen
- Doctor: Tohtorin opinto-oikeudella varustettujen opiskelijoiden eteneminen
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
HUOM! Mukana ovat myös muuhun kuin valittuun koulutusohjelmaan tai opintosuuntaan tehdyt opintopisteet.
Opintopisteet on laskettu opinto-oikeuden alkamisesta, eikä tällä hetkellä ota huomioon mahdollisia hyväksilukuja.
**Separate**: Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
Mukana ovat oletusarvoisesti myös ohjelmaan siirtyneet.
`,
**Other university**: Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
**Other**: Sisältää tohtorin- ja lisensiaatintutkintoon liittyvät opintopisteet, täydennyskoulutuksen, erikoistumiskoulutuksen, erikoislääkäri- ja erikoishammaslääkärikoulutuksen sekä lukiolaisten opintopisteet.
`,
graduatedOfTheFaculty: `
Sisältää kyseisenä vuonna tiedekunnasta valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty.
Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
`,
thesisWritersOfTheFaculty: `
Sisältää kyseisenä vuonna kandidaatin- tai maisterintutkielman hyväksytysti suorittaneet opiskelijat.
Mukana ei ole mahdollisia arvosanan korotuksia.
`,
},
studentsByStartingYearTab: {
studentsStatsOfTheFaculty: studyProgrammeToolTips.common.studyTrackOverview.replace(
'Yläosan valikosta on mahdollista valita tarkasteluun yhden opintosuunnan opiskelijat. Luvut kuvaavat tällöin kyseisen opintosuunnan opiskelijoita.',
''
),
},
// Used under University
bachelorMasterProgress: `
The starting year is the study right start in the bachelor programme. The credits are computed by the
start date of the bachelor programme and at the moment, they do not include any transferred credits.
Thus, in these statistics some students have fewer credits than in reality.
`,
interactiveDataTable: `
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
`,
The starting year is the study right start in the bachelor programme. The credits are computed by the
start date of the bachelor programme and at the moment, they do not include any transferred credits.
Thus, in these statistics some students have fewer credits than in reality.
`,
}
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
'Yläosan valikosta on mahdollista valita tarkasteluun yhden opintosuunnan opiskelijat. Luvut kuvaavat tällöin kyseisen opintosuunnan opiskelijoita.',
''
)
facultyToolTips.studentsOfTheFaculty =
'Taulukon luvut on laskettu **yhdistämällä** tiedekunnan koulutusohjelmien luvut. Näin ollen tämän näkymän luvut täsmäävät *Degree programme* -näkymän lukuihin. Kategorioiden merkitykset ovat seuraavat:\n'
.concat(studyProgrammeToolTips.studentsOfTheStudyProgramme)
.replace('Transferred away', 'Transferred out of programme')
.replace('Transferred to', 'Transferred into programme')
facultyToolTips.graduatedToggle = studyProgrammeToolTips.graduatedToggle
facultyToolTips.studyRightToggle = studyProgrammeToolTips.studyRightToggle
facultyToolTips.yearToggle = studyProgrammeToolTips.yearToggle
@@ -1,25 +1,28 @@
export const filterToolTips = {
citizenship:
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
courseCredits:
'Rajaa opiskelijoiden kurssisuorituksia ja ilmoittautimisia määritetyn ajanjakson mukaan. Huom! ilmoittautumiset voivat tapahtua huomattavasti kurssisuorituksia aikaisemmin.',
enrollmentStatus: `
Suodata opiskelijoita sen mukaan, ovatko he ilmoittautuneet **läsnäolevaksi** (present), **poissaolevaksi** (absent) vai **laiminlyöneet ilmoittautumisen** (passive) valituille lukukausille. Jos lukukausia on valittuna enemmän kuin yksi, opiskelijan ilmoittautumisen tulee täsmätä jokaisella valitulla lukukaudella.
courseCredits: `Rajaa opiskelijoiden **kurssisuorituksia ja ilmoittautumisia** määritetyn ajanjakson mukaan.
Huom! ilmoittautumiset voivat tapahtua huomattavasti kurssisuorituksia aikaisemmin.
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
`,
**Suodattaa opiskelijoiden kurssisuorituksia ja ilmoittautumisia, ei vaikuta opiskelijoiden lukumäärään.**
`,
enrollmentStatus: `
Suodata opiskelijoita sen mukaan, ovatko he ilmoittautuneet **läsnäolevaksi** (present), **poissaolevaksi** (absent) vai **laiminlyöneet ilmoittautumisen** (passive) valituille lukukausille. Jos lukukausia on valittuna enemmän kuin yksi, opiskelijan ilmoittautumisen tulee täsmätä jokaisella valitulla lukukaudella.
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
`,
hetu: 'Rajaa opiskelijoita sen mukaan onko heillä suomalaista henkilötunnusta. Filters students based on if they have a Finnish personal identity code.',
startingYear:
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
studentNumber:
'Rajaa yksittäisiä opiskelijoita pois tilastoista (excluded student numbers) tai näytä **vain** määritellyt opiskelijat (allowed student numbers). Vain täsmälliset tarkasteltavan populaation joukosta löytyvät opiskelijanumerot toimivat. Suodatin tukee useamman opiskelijanumeron syöttämistä kerrallaan.',
studyRightStatus: `
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
Opiskeluoikeus on **passiivinen** (passive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
Opiskeluoikeus on **passiivinen** (passive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
`,
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
`,
transferred:
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta. Oletusarvoisesti pois päältä Class statistics -näkymässä.',
}

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