Compare commits

...
290 Commits
Author SHA1 Message Date
joonas-a 70120f27bc [Updater][StudyRightElements] fix error when student has many phase 2 educations before phase 1 completion 2026-07-01 13:15:22 +03:00
joonas-a 96889eca28 [Logging] Ignore logs to /updater/jobs endpoint as it is polled very often 2026-07-01 12:53:13 +03:00
joonas-a 0336c27aac [Updater] add logs for studyrightelementmapper errors 2026-07-01 12:40:16 +03:00
Santeri Rajamäki 5e6a320c88 [Frontend] Move PopulationComponent Students Table component tree 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 092aefb177 [Frontend] Move PopulationStudents component tree 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 8ce01807c2 [CoursePopulation] Add fetch skip on invalid mainCodes array 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 8331e462f1 [Frontend] Remove "index" from path subfixes 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 22a672bbe8 [Frontend] Consolidate ProgrammeDist infobox 2026-06-30 12:12:18 +03:00
Santeri Rajamäki d21f383b17 [Frontend] Convert relative paths to absolute \@ paths 2026-06-30 12:12:16 +03:00
Santeri Rajamäki ca826a387b [Frontend] Move ProgrammeDist component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 17cbaaa024 [Frontend] Move CourseTableModeSelector component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 9aa8e84dbc [Frontend] Move PopulationCourses component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki f69c96e807 [Frontend] Move AgeStats component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 6ec7153c6f [Frontend] Move CreditGainStats component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 98bf2de51f [Frontend] Move CreditAccumulation component 2026-06-30 12:10:34 +03:00
joonas-a 34cb0c83d9 [Update][Studyrights] prevent creating phase 2 elements before if there is no phase 1 graduation 2026-06-29 16:39:13 +03:00
Aaro Koivukangas 0b76807706 [E2E] Test number fix 2026-06-29 14:24:55 +03:00
Aaro Koivukangas e6676044a9 [ProgrammeCourses+CourseStats+ApiTests] Fix calculations and make numbers match between the two pages 2026-06-29 13:52:15 +03:00
joonas-a 4d98abcb39 [Frontend][Graduation times] Guarantee years sorted correctly 2026-06-26 16:56:49 +03:00
joonas-a 463541a08c [Frontend][Graduation times] Show infotext if no data for current level 2026-06-26 16:41:26 +03:00
joonas-a be835b7ed5 [Backend][Graduation times] make faculties not send each individual graduation time to client 2026-06-26 16:31:29 +03:00
joonas-a db0e6ebb63 [Backend][Graduation times] Fix undefined programme names 2026-06-26 15:15:22 +03:00
joonas-a 5f3f3caf3b [Infobox][Graduation times] clarify what groups are included in numbers
Further clarification possibly needed
2026-06-26 14:57:15 +03:00
joonas-a 3cb4828eb4 [Frontend][Graduation times] add hints about chart level breakdown feature 2026-06-26 14:47:44 +03:00
joonas-a 418747e6c6 [Updater] Fix "ACTIVE" enrolments on "DELETED" CURs causing an error
There was no document_state check on CURs, and deleted CURs seem to set
validity period to year 1900 which is outside of DKN's semester logic ->
error
2026-06-26 12:54:23 +03:00
joonas-a 57fab874bc [DX] Add button for admins to open page in devbuild in prod 2026-06-26 11:28:24 +03:00
joonas-a c000d1a57d [Faculty graduations] simplify logic
Fixes bug where by start year switch caused a random program to be
counted twice
2026-06-25 22:14:23 +03:00
Aaro Koivukangas b6e5b92bdc [CompontentTests] Replace few more e2e tests with component tests 2026-06-25 15:13:16 +03:00
Aaro Koivukangas 11819d223a [CI] Also install shared's node_modules during component testing 2026-06-25 14:31:18 +03:00
Aaro Koivukangas bb68879566 [ComponentTests] Add proof-of-concept component test with api calls 2026-06-25 14:22:57 +03:00
joonas-a 4acee84b14 [DX] Click dev chip to open current page in prod 2026-06-25 13:21:15 +03:00
Aaro Koivukangas 8c261458ba [Docker] Change user 1001 to the correct place for dev.Dockerfiles 2026-06-25 12:18:37 +03:00
Aaro Koivukangas eb634285eb [DockerCompose] Acually acually change incorrect volume name 2026-06-25 11:53:39 +03:00
Aaro Koivukangas 5de3afe2ae [DockerCompose] Acually change incorrect volume name 2026-06-25 11:50:40 +03:00
Aaro Koivukangas 12a9396b8f [DockerCompose] Change incorrect volume name 2026-06-25 11:48:38 +03:00
Aaro Koivukangas 40a37e001f [ProgrammeOverview][ProgrammeCourses] Change default value of credit/students toggle to be students 2026-06-25 09:23:36 +03:00
joonas-a 8e9b0f7844 [Updater] studyRightElement rework
Should fix bugs related to elements ending before their start date, and
dates being a mess in general.
2026-06-24 19:30:28 +03:00
joonas-a ba93dc8927 [Faculty Graduation times] fix semesters labelled as months in excel export 2026-06-24 19:30:27 +03:00
joonas-a 4935d84842 [Graduation times] make medians chart target marker ignore mouse events
it would trigger an invalid tooltip
2026-06-24 19:30:27 +03:00
Aaro Koivukangas 0b207c899a [E2E] Fix broken e2e test 2026-06-24 18:09:21 +03:00
Aaro Koivukangas 99dcdcd5b3 [Scripts] Add docker:down to oodikone npm script 2026-06-24 17:56:56 +03:00
Aaro Koivukangas ed330f70a7 [Meta] Change enrollments to by default be excluded before OK sisu-migration 2021-05-31 2026-06-24 17:55:25 +03:00
Aaro Koivukangas e06239f8a0 [CI] Trigger staging gh action workflow also if docker-compose.ci.yml changes 2026-06-24 15:46:54 +03:00
Aaro Koivukangas fcdf2b8fc6 [DockerCompose] Exclude shared node_modules from being overridden 2026-06-24 15:44:01 +03:00
Aaro Koivukangas dd6e44d6c6 [Dockerfile] Add shared node_modules to also dev.Dockerfiles 2026-06-24 15:34:13 +03:00
Aaro Koivukangas 6d838a9acc [Dockerfile] Fix previous fix, add shared's package*.json to backend docker image 2026-06-24 15:18:01 +03:00
Aaro Koivukangas 629ea3a516 [Dockerfile] Add missing shared npm ci step to both dockerfiles 2026-06-24 15:14:13 +03:00
Aaro Koivukangas 980168eb9e [E2E] Fix broken e2e tests 2026-06-24 15:02:55 +03:00
Aaro Koivukangas df88a88550 [E2E] Skipping many e2e tests, writing these in the future as api tests 2026-06-24 14:53:44 +03:00
Aaro Koivukangas ccfb09eab4 [CourseStats] Calculate Enrolled, no grade properly by enrollment_date_time, not semesterCode 2026-06-24 14:49:21 +03:00
Aaro Koivukangas 582637942e [ApiTests] Add api tests for /courseyearlystats, used by Course statistics 2026-06-24 10:48:35 +03:00
Aaro Koivukangas 742e12201c [E2E] Wait for a toggle to change data 2026-06-23 16:35:08 +03:00
Aaro Koivukangas ff015d04bf [ApiTests] Move to use nested tests + fix handleQueryArrays to work with undefined values 2026-06-23 16:07:42 +03:00
Aaro Koivukangas 9b59f53f1b [Meta] Remove winston-gelf-transporter 2026-06-23 16:04:55 +03:00
Aaro Koivukangas f2c1181a1d [Programme overview][Programme courses] Change default value of combineSubstitutions to be false 2026-06-22 15:38:05 +03:00
Aaro Koivukangas 2b42935d2d [E2E] Fix broken test 2026-06-22 15:32:40 +03:00
Aaro Koivukangas 8deea058af [E2E] Fix broken e2e tests 2026-06-22 15:21:53 +03:00
Aaro Koivukangas a53e46568e [Programme overview][Programme courses] Course links now include substitutions by default 2026-06-22 15:09:45 +03:00
Aaro Koivukangas cf4ae69ae4 [CI] Re-enable removed action correctly 2026-06-22 14:57:47 +03:00
Aaro Koivukangas 6a3ddb865c [Meta] In frontend default to Academic year instead of Calendar year 2026-06-22 14:51:42 +03:00
Aaro Koivukangas cec8dc0c0b [CI] Remove unused action 2026-06-22 14:45:55 +03:00
Aaro Koivukangas 7fa14c10f8 [CI] More typo fixes 2026-06-22 14:36:11 +03:00
Aaro Koivukangas 96a9770fbf [CI] Revert back to ubuntu-latest 2026-06-22 14:22:49 +03:00
Aaro Koivukangas 4b1e416a18 [CI] Fix typo 2026-06-22 13:58:50 +03:00
Aaro Koivukangas 258996ea17 [CI] Change staging to use runner-scaleset, add frontend unit and component tests to actions 2026-06-22 13:57:52 +03:00
Aaro Koivukangas 5c542f8a91 [CI] Change tests to use self-hosted runner, disable dependabot triggering workflow runs 2026-06-22 13:21:01 +03:00
Aaro Koivukangas ab69044d79 [Meta] Add frontend component testing 2026-06-22 10:51:43 +03:00
joonas-a 2eded61803 [Lint] fix 2026-06-17 17:47:43 +03:00
joonas-a 3fe589811e [Backend][Refactor] unify graduation time typing and fix bugs 2026-06-17 17:42:47 +03:00
Aaro Koivukangas 20d5884722 [Scripts] Handpick logs-script containers 2026-06-17 09:44:21 +03:00
joonas-a 8a924487ce [Infobox] update 2026-06-16 17:03:49 +03:00
joonas-a cf77473e77 [Percentiles] Add credit gain target diagonal line
60 credits per academic year
2026-06-16 17:03:49 +03:00
joonas-a 9383a8bb73 [Percentiles] rotate item order and introduce new color scheme 2026-06-16 17:03:49 +03:00
Aaro Koivukangas 21de703da9 [Docker] Fix broken commands from oodikone- prefix addition 2026-06-16 16:58:43 +03:00
Aaro Koivukangas b58dd5f25c [Docker] Add oodikone- prefix to container and volume names 2026-06-16 15:54:44 +03:00
Aaro Koivukangas dc10aeb14c [E2E] Update broken e2e test values 2026-06-15 17:29:17 +03:00
Aaro Koivukangas 4b38d6dd96 [ClassStats][Students][Progress] Fix single course progress in Excel export 2026-06-15 17:25:55 +03:00
Aaro Koivukangas deaa41565d [ProgrammeOverview][ProgrammeCourses] Remove 95% of (duplicate) 'Not completed' entries 2026-06-15 15:33:03 +03:00
joonas-a 5359eae68d [Updater] Fix studyplan curriculum periods
Previously shown was the hops creation date, not which curriculum the
hops for each "phase" shows
2026-06-12 18:11:12 +03:00
Aaro Koivukangas 0717fc0703 [ClassStats][CreditStats][DistributionDevel] Fix Divide by Calendar year's start date 2026-06-11 17:41:27 +03:00
Aaro Koivukangas 508cce0b36 [ClassStats][CreditStats][DistributionDevel] Fix non-cumulative graduated count 2026-06-11 17:41:27 +03:00
joonas-a d2fed921e4 [Regression] fix infobox formatting 2026-06-11 15:04:31 +03:00
Aaro Koivukangas 514407abf8 [ClassStatistics][Students][Courses] Fix Total planned and passed courses 2026-06-11 14:42:59 +03:00
Santeri Rajamäki af9f5b15d7 [DOCS] Add grafana entry to README 2026-06-10 13:53:56 +03:00
Santeri Rajamäki ea9b61d473 [Updater] Cleanup db connections 2026-06-10 13:53:56 +03:00
joonas-a 7171bf198e [Completed courses] fix regression + add types 2026-06-10 13:42:07 +03:00
joonas-a 14c22af2ef [Completed courses] add substitutions to infobox 2026-06-10 13:42:07 +03:00
Aaro Koivukangas 2f05de3258 [Infobox] Fix incorrect formatting in a infobox 2026-06-10 12:32:36 +03:00
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
joonas-a 3d0fd3563f Delete highcharts 2026-05-15 17:49:11 +03:00
joonas-a a929613e83 [Course stats] enable data view on chart + cleanup 2026-05-15 17:48:36 +03:00
joonas-a d64aca5285 [Course stats] convert grade dist to echarts 2026-05-15 17:46:38 +03:00
joonas-a 9e1307595c [Course stats] convert pass rate chart to echarts 2026-05-15 17:37:15 +03:00
joonas-a 0e9ba8490d Delete old files 2026-05-15 17:17:47 +03:00
joonas-a 50b4166a7f [Charts] cleanup 2026-05-15 17:01:03 +03:00
joonas-a 3843937429 [Graduation times chart] more refactor 2026-05-15 15:55:42 +03:00
joonas-a b60b1dd88b [Graduation times chart] refactor 2026-05-15 14:56:49 +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
joonas-a ed513c14bf [Error background] put veterans to rest 2026-05-12 16:23:38 +03:00
joonas-a 8e2b8e5443 [University][Backend] fix graduation targets
from months to semesters like frontend expects
2026-05-12 15:39:58 +03:00
joonas-a bb215bb785 [Charts] bring back missing legends oops 2026-05-12 15:38:27 +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
joonas-a 635fb97b25 [Charts] final tweaks 2026-05-12 14:54:07 +03:00
joonas-a a5334c8e2f [Progres bar chart] Change emphasis strategy
fixes incorrect highlighting bug when clicking categories from the
legend
2026-05-12 14:25:18 +03:00
joonas-a 18b8daabff [Charts] convert horizontal stacked bar chart 2026-05-12 14:24:26 +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
joonas-a b181fda7fc [Charts] Fix styling issues 2026-05-12 13:27:23 +03:00
joonas-a 747c37697c [Median graduations] add guard for undefined data and fix style 2026-05-12 12:05:12 +03:00
joonas-a 76d8fef4eb [Dist devlpmnt] Update infobox 2026-05-12 11:51:06 +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
joonas-a 743ccb6797 [Credit accumulation] infobox update 2026-05-11 18:00:13 +03:00
joonas-a dab81f994d [Charts][Crdt accmltn] bring back click to open individual student 2026-05-11 16:51:42 +03:00
joonas-a b17fa2a11d [Charts][Graduation times] Convert breakdown chart 2026-05-11 15:25:09 +03:00
joonas-a ea8a84420f [Charts][Graduation times] Invert order to match previous behaviour 2026-05-11 15:24:43 +03:00
joonas-a 56d98e16cd [Charts][Graduation times] convert median chart 2026-05-11 13:56:52 +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
joonas-a b9b90876f1 [Charts][Year expansion chart] first attempt at echarts conversion 2026-04-29 16:08:35 +03:00
joonas-a 777a19b291 [Charts][LineGraph] Add emphasis strategy
Will make it so when hovering only one item blurs all the others
2026-04-29 11:13:15 +03:00
joonas-a 362930c481 [Student tab] change credit acc graph dot icon 2026-04-28 11:42:52 +03:00
joonas-a 8ca1636517 [Charts][Progress of students] convert to echarts 2026-04-27 17:22:29 +03:00
joonas-a d6beafa13a [Charts][Graduated/thesis writers] convert graph to echart 2026-04-27 14:03:49 +03:00
joonas-a f92d72b77a [Charts][StackedBarChart] echart conversion 2026-04-27 13:42:46 +03:00
joonas-a dca144d58b [Charts] Replace students of x with echarts 2026-04-27 13:08:21 +03:00
Santeri Rajamäki 915d5c097c [Backend] Remove unused eslint disable 2026-04-24 09:58:35 +03:00
joonas-a 9a9f723b46 [Charts][Distribution development] Migrate to echarts 2026-04-21 12:41:39 +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
joonas-a e6686c024a [Charts][Student details] final touches 2026-04-20 14:12:21 +03:00
joonas-a 2aa6e9c549 [Student grade graph] Move legend to right for consistency 2026-04-20 12:39:44 +03:00
joonas-a 898a2b1f1b [Charts][Student details] Renew grade graphs 2026-04-20 12:25:39 +03:00
joonas-a 6a9a49952b [Frontend] Further enhance StyledMessage 2026-04-20 12:02:49 +03:00
joonas-a 0f76e50e4b [Frontend] Enhance StyledMessage 2026-04-20 11:59:16 +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
joonas-a d9695901b2 [Charts][Student details] remake credit graph 2026-04-16 16:08:15 +03:00
joonas-a b85edaf6f0 [Charts][Custom pop] Use new credit acc
Also add graduations if programme set.
2026-04-16 14:30:53 +03:00
joonas-a b165ece770 [Lint] fix 2026-04-16 14:19:51 +03:00
joonas-a a90e82a31e [Charts][SGG] use new credit acc 2026-04-16 14:18:58 +03:00
joonas-a 651e37051d [Charts][Credit acc] fix combined programme graduation markers 2026-04-16 13:55:21 +03:00
joonas-a 77b6d1cb16 [Charts] cleanup and edge case handling 2026-04-16 12:48:30 +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
joonas-a afa6804d09 [Charts] Credit acc done for non bscmsc classes 2026-04-14 16:19:46 +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
joonas-a a57f2f01ca [Charts][Credit accmltn] hops filter functionality 2026-04-13 14:40:44 +03:00
joonas-a 1b4bdff37e [Charts][poc] credit accumulation 2026-04-02 16:14:04 +03:00
482 changed files with 14057 additions and 9413 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
+10 -5
View File
@@ -8,7 +8,6 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'docker-compose.ci.yml'
- 'package*.json'
@@ -16,17 +15,22 @@ jobs:
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
steps:
- uses: actions/checkout@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_api:
name: 'Test backend api'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -50,12 +54,15 @@ jobs:
run: npm run oodikone:ci:api
test_end_to_end:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
- test_api
strategy:
fail-fast: false
matrix:
include:
- name: 'Test course statistics'
@@ -78,15 +85,13 @@ 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'
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v6
- name: 'Run tests'
+3 -5
View File
@@ -11,9 +11,9 @@ on:
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'package*.json'
jobs:
@@ -92,12 +92,11 @@ jobs:
report_success:
name: 'Report deployment success to the "oodikone_notifications" channel'
runs-on: ubuntu-latest
if: success()
needs:
- release_frontend
- release_backend
if: success()
steps:
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
@@ -111,12 +110,11 @@ jobs:
report_failure:
name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: ubuntu-latest
if: failure()
needs:
- release_frontend
- release_backend
if: failure()
steps:
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
+324 -146
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,138 +53,36 @@ 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
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
outputs:
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v6
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
with:
filters: |
kone_db_migrations:
- 'services/backend/src/database/migrations_kone/**'
- '.github/workflows/test.yaml'
user_db_migrations:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
sis_db_migrations:
- 'updater/sis-updater-worker/src/db/migrations/**'
- '.github/workflows/test.yaml'
# - name: 'Upload image for other jobs'
# uses: actions/upload-artifact@v7
# with:
# if-no-files-found: error
# name: frontend-image
# path: /tmp/frontend.tar
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
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
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: '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
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:
- build_backend
- build_frontend
- check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v6
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- 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 kone-db
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
needs:
- build_backend
- build_frontend
- check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: check_migrations
with:
filters: |
migrations:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- 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'
needs:
- 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'
pull_docker_images:
name: 'Pull all needed docker images and cache them'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -193,16 +93,8 @@ jobs:
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: '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: |
@@ -223,23 +115,171 @@ jobs:
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
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
outputs:
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v6
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
with:
filters: |
kone_db_migrations:
- 'services/backend/src/database/migrations_kone/**'
- '.github/workflows/test.yaml'
user_db_migrations:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
sis_db_migrations:
- 'updater/sis-updater-worker/src/db/migrations/**'
- '.github/workflows/test.yaml'
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
needs:
- pull_docker_images
- check_migration_files
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: '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 --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'
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
needs:
- pull_docker_images
- build_backend
- build_frontend
- check_migration_files
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
sleep 30 # wait for backend to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
needs:
- pull_docker_images
- build_backend
- build_frontend
- check_migration_files
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: check_migrations
with:
filters: |
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
# === Backend api-tests ===
test_api:
name: 'Run backend api tests'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
needs:
- test_service_exit_statuses
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
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- 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,17 +301,90 @@ 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
# === Frontend unit testing ===
test_unit_frontend:
name: 'Run frontend unit tests'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: npm ci
working-directory: ./services/frontend
- name: Run unit tests
run: npm run test:unit
working-directory: ./services/frontend
# === Frontend component testing ===
test_components:
name: 'Test frontend components'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: npm ci --prefix shared && npm ci --prefix frontend
working-directory: ./services
- name: Install Playwright Browsers
run: npm exec -- playwright install --with-deps
working-directory: ./services/frontend
- name: Run Playwright tests
run: npm run test:component
working-directory: ./services/frontend
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
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
strategy:
fail-fast: false
matrix:
@@ -296,17 +409,13 @@ 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'
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -321,9 +430,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 +453,60 @@ jobs:
password: ${{ secrets.password }}
spec: ${{ matrix.spec }}
test_name: ${{ matrix.name }}
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
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=
+17 -5
View File
@@ -1,4 +1,5 @@
# Oodikone
[![Maintainability](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone/maintainability.svg)](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
@@ -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`
@@ -149,8 +149,8 @@ It is recommended to spend some time to become familiar with `docker` and `docke
- `docker compose ps`: view the containers in the running environment
- `docker compose logs frontend`: print logs for just frontend
- `docker compose logs --follow --tail 100 backend`: print last hundred rows of backend logs and begin to follow them in your terminal window
- `docker exec -it backend sh`: open bash terminal inside backend container
- `docker exec -it sis-db psql -U postgres sis-db-real`: open psql client to investigate sis real data database
- `docker exec -it oodikone-backend sh`: open bash terminal inside backend container
- `docker exec -it oodikone-sis-db psql -U postgres sis-db-real`: open psql client to investigate sis real data database
### User types for development
@@ -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
@@ -240,6 +238,20 @@ npm run cypress:run
Remember to revert the `baseUrl` in [cypress.config.js](./cypress.config.js) to its original value when you're done.
## How to read clients minds
Search for client queries in grafana using a variation of this query:
```
{app="oodikone"} | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
```
For quick access to _any and all params_ under certain path:
```
{app="oodikone"} |= `path` | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
```
## ✌🏼 Maintainers and contribution
[Toska](https://toska.dev) - University of Helsinki
+1 -1
View File
@@ -43,6 +43,6 @@ fi
infomsg "Committing and pushing to registry"
database=${args[0]}
docker commit "$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
docker commit "oodikone-$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
docker push "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
successmsg "Succesfully pushed"
+5 -5
View File
@@ -52,13 +52,13 @@ fi
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
database=${args[0]}
docker-compose down --remove-orphans
docker-compose up -d "$database"
database="${args[0]}"
docker compose down --remove-orphans
docker compose up -d "$database"
database_dump="$database.sqz"
infomsg "Attempting to restore database $database from dump $database_dump"
retry docker-compose exec "$database" pg_isready --dbname="$database"
docker exec -i "$database" /bin/bash -c "pg_restore --username=postgres \
retry docker compose exec "$database" pg_isready --dbname="$database"
docker exec -i "oodikone-$database" /bin/bash -c "pg_restore --username=postgres \
--format=custom --dbname=$database --no-owner" < "$database_dump"
successmsg "Database $database succesfully created"
+4 -4
View File
@@ -4,7 +4,7 @@ x-defaults: &defaults
services:
kone-db:
<<: *defaults
container_name: kone-db
container_name: oodikone-kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
@@ -13,7 +13,7 @@ services:
sis-db:
<<: *defaults
container_name: sis-db
container_name: oodikone-sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
@@ -23,7 +23,7 @@ services:
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
container_name: oodikone-sis-importer-db
environment:
PGDATA: /data
POSTGRES_DB: sis-importer-db
@@ -32,7 +32,7 @@ services:
user-db:
<<: *defaults
container_name: user-db
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
+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 "oodikone-$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
+3 -3
View File
@@ -18,11 +18,11 @@ source "$PROJECT_ROOT"/scripts/functions_for_setup.sh
# Try to run cleanup function if things fail.
trap cleanup SIGINT SIGTERM ERR EXIT
# Run docker-compose down on cleanup
# Run docker compose down on cleanup
cleanup() {
trap - SIGINT SIGTERM ERR EXIT
warningmsg "Trying to run docker-compose down and remove orphans"
docker-compose --profile oodikone --profile updater down --remove-orphans
warningmsg "Trying to run docker compose down and remove orphans"
docker compose --profile oodikone --profile updater down --remove-orphans
}
# === CLI ===
+302 -278
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', () => {
@@ -516,7 +537,8 @@ describe('Basic user', () => {
cy.contains('Show population').should('be.enabled')
})
describe('Students tab', () => {
// TODO: Writing these as backend api tests
describe.skip('Students tab', () => {
describe('Info boxes', () => {
it('Student statistics table', () => {
cy.cs('StudentStatistics-info-box-button').click()
@@ -538,14 +560,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 +576,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 +600,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 +617,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()
@@ -618,7 +640,8 @@ describe('Basic user', () => {
})
})
describe('Attempts tab', () => {
// TODO: Writing these as backend api tests
describe.skip('Attempts tab', () => {
beforeEach(() => {
openAttemptsTab()
})
@@ -644,15 +667,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 +683,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 +707,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 +724,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 +755,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', () => {
@@ -753,22 +776,23 @@ describe('Basic user', () => {
cy.contains('Show population').should('be.enabled')
})
describe('Students tab', () => {
// TODO: Writing these as backend api tests
describe.skip('Students tab', () => {
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 +814,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 +872,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 +906,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 %'],
@@ -939,7 +963,8 @@ describe('Basic user', () => {
})
})
describe('Attempts tab', () => {
// TODO: Writing these as backend api tests
describe.skip('Attempts tab', () => {
beforeEach(() => {
openAttemptsTab()
})
@@ -947,18 +972,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 +1005,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 +1063,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 +1097,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 +1181,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,14 +1205,13 @@ 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()
})
})
})
describe('Only course statistics', () => {
// TODO: Writing these as backend api tests
describe.skip('Only course statistics', () => {
beforeEach(() => {
cy.init('/coursestatistics', 'onlycoursestatistics')
cy.url().should('include', '/coursestatistics')
@@ -1213,15 +1237,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'),
+74 -67
View File
@@ -32,36 +32,21 @@ const deleteTag = tagName => {
describe('Degree programme overview', () => {
describe('Degree programme selector', () => {
beforeEach(() => {
cy.init('/study-programme', 'admin')
})
it('Correct categories', () => {
cy.contains('Bachelor programmes')
cy.contains('Master programmes')
cy.contains('Combined programmes')
cy.contains('Doctoral programmes')
})
it('Degree programme search filter', () => {
cy.init('/study-programme', 'admin')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
})
it('Old and specialized programmes filter', () => {
cy.contains('Biotekniikka (ylempi)').should('not.exist')
cy.cs('filter-old-programmes-toggle').click()
cy.contains('Biotekniikka (ylempi)').should('exist')
})
})
describe('Basic information tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('year-toggle').click()
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
@@ -207,19 +192,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')
@@ -234,34 +220,39 @@ describe('Degree programme overview', () => {
it('are split into two graphs', () => {
cy.init('/study-programme')
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
cy.cs('year-toggle').click() // Tests are written for calendar year
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 +260,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()
@@ -405,19 +396,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 +429,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')
@@ -471,7 +473,8 @@ describe('Degree programme overview', () => {
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 +484,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 +492,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')
@@ -513,6 +516,8 @@ describe('Degree programme overview', () => {
describe('By credit type tab', () => {
beforeEach(() => {
cy.cs('show-credits-students-toggle').click()
cy.cs('year-toggle').click()
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
@@ -527,7 +532,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11004')
cy.get('td').eq(2).contains('Differentiaalilaskenta')
cy.get('td').eq(3).contains('1170')
cy.get('td').eq(3).contains('1240')
})
})
})
@@ -551,7 +556,7 @@ describe('Degree programme overview', () => {
})
it('year toggle works', () => {
cy.cs('year-toggle').click()
cy.cs('year-toggle').click() // Default view now Academic years
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2017').click()
@@ -565,7 +570,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('1220')
cy.get('td').eq(3).contains('1265')
})
})
})
@@ -624,6 +629,8 @@ describe('Degree programme overview', () => {
})
it("'Show credits/Show students' toggle works", () => {
cy.cs('year-toggle').click()
cy.wait(500) // So that the toggle has time to load
cy.get('thead tr')
.eq(0)
.within(() => {
@@ -698,7 +705,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('375')
cy.get('td').eq(3).contains('277')
})
})
})
+5 -5
View File
@@ -110,15 +110,15 @@ describe('Faculty statistics', () => {
})
it('Toggle years works', () => {
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
})
it('Toggle programmes works', () => {
+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')
})
})
})
+8 -8
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')
@@ -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', () => {
+3 -12
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', () => {
@@ -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,
})
})
+16 -12
View File
@@ -1,10 +1,12 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
services:
backend:
<<: *defaults
container_name: backend
container_name: oodikone-backend
depends_on:
- kone-db
- redis
@@ -25,21 +27,23 @@ services:
build:
context: services
dockerfile: backend/Dockerfile
image: oodikone-backend:staging
profiles:
- oodikone
frontend:
<<: *defaults
container_name: frontend
container_name: oodikone-frontend
build:
context: services
dockerfile: frontend/Dockerfile
image: oodikone-frontend:staging
profiles:
- oodikone
kone-db:
<<: *defaults
container_name: kone-db
container_name: oodikone-kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
@@ -48,16 +52,16 @@ services:
redis:
<<: *defaults
container_name: oodikone-redis
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:7.2.6
container_name: redis
ports:
- 6379:6379
sis-db:
<<: *defaults
container_name: sis-db
container_name: oodikone-sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
environment:
PGDATA: /data
@@ -67,7 +71,7 @@ services:
user-db:
<<: *defaults
container_name: user-db
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
@@ -76,6 +80,7 @@ services:
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
environment:
- npm_config_cache=/tmp
@@ -84,21 +89,20 @@ services:
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
ports:
- 3003:3003
container_name: jami
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
container_name: jami-db
ports:
- 5433:5432
nginx:
container_name: oodikone-nginx
image: nginx:latest
container_name: nginx
depends_on:
- frontend
- backend
@@ -112,16 +116,16 @@ services:
# Following services are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
sis-updater-worker:
<<: *defaults
container_name: oodikone-updater-worker
build:
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
@@ -140,9 +144,9 @@ services:
sis-updater-scheduler:
<<: *defaults
container_name: oodikone-updater-scheduler
build:
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
depends_on:
- redis
- sis-importer-db
+3
View File
@@ -1,3 +1,5 @@
name: oodikone
services:
backend:
environment:
@@ -20,3 +22,4 @@ services:
volumes:
redis-data-real:
name: oodikone-redis-data-real
+9 -4
View File
@@ -1,9 +1,14 @@
services:
api-tests:
container_name: oodikone-api-tests
build:
context: services
dockerfile: backend/dev.Dockerfile
volumes:
- backend-tests-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
- backend-tests-nodemod:/opt/app-root/backend/node_modules
- shared-tests-nodemod:/opt/app-root/shared/node_modules
environment:
CI: 'true'
NODE_ENV: 'test'
@@ -17,10 +22,10 @@ services:
JAMI_URL: 'http://jami:3003/'
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
build:
context: services/backend
dockerfile: dev.Dockerfile
command: npm run test
volumes:
backend-tests-nodemod:
name: oodikone-backend-tests-nodemod
shared-tests-nodemod:
name: oodikone-shared-tests-nodemod
+34 -18
View File
@@ -1,3 +1,5 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
@@ -7,7 +9,7 @@ x-build_defaults: &build_defaults
services:
adminer:
<<: *defaults
container_name: adminer
container_name: oodikone-adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
@@ -18,10 +20,10 @@ services:
backend:
<<: *defaults
container_name: oodikone-backend
build:
<<: *build_defaults
context: services/backend
container_name: backend
dockerfile: backend/dev.Dockerfile
context: services
depends_on:
- kone-db
- redis
@@ -45,28 +47,30 @@ services:
profiles:
- oodikone
volumes:
- backend-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
- shared-nodemod:/opt/app-root/shared/node_modules
- backend-nodemod:/opt/app-root/backend/node_modules
frontend:
<<: *defaults
container_name: oodikone-frontend
build:
<<: *build_defaults
context: services/frontend
container_name: frontend
dockerfile: frontend/dev.Dockerfile
context: services
ports:
- 3000:3000
profiles:
- oodikone
volumes:
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
- shared-nodemod:/opt/app-root/shared/node_modules
- frontend-nodemod:/opt/app-root/frontend/node_modules
kone-db:
<<: *defaults
container_name: kone-db
container_name: oodikone-kone-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
profiles:
- oodikone
@@ -75,8 +79,8 @@ services:
redis:
<<: *defaults
container_name: oodikone-redis
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:7.2.6
ports:
- '6379:6379'
@@ -85,7 +89,7 @@ services:
sis-db:
<<: *defaults
container_name: sis-db
container_name: oodikone-sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
volumes:
- sis-db-data:/data
@@ -95,7 +99,7 @@ services:
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
@@ -106,10 +110,10 @@ services:
sis-updater-scheduler:
<<: *defaults
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
depends_on:
- redis
- sis-importer-db
@@ -130,10 +134,10 @@ services:
sis-updater-worker:
<<: *defaults
container_name: oodikone-updater-worker
build:
<<: *build_defaults
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
@@ -154,7 +158,7 @@ services:
user-db:
<<: *defaults
container_name: user-db
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
profiles:
- oodikone
@@ -163,6 +167,7 @@ services:
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
env_file:
- .env
@@ -173,15 +178,14 @@ services:
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
container_name: jami-db
ports:
- 5433:5432
volumes:
@@ -189,12 +193,24 @@ services:
volumes:
backend-nodemod:
name: oodikone-backend-nodemod
frontend-nodemod:
name: oodikone-frontend-nodemod
shared-nodemod:
name: oodikone-shared-nodemod
kone-db-data:
name: oodikone-kone-db-data
redis-data:
name: oodikone-redis-data
sis-db-data:
name: oodikone-sis-db-data
sis-importer-db-data:
name: oodikone-sis-importer-db-data
sis-updater-scheduler-nodemod:
name: oodikone-updater-scheduler-nodemod
sis-updater-worker-nodemod:
name: oodikone-updater-worker-nodemod
user-db-data:
name: oodikone-user-db-data
jami-data:
name: oodikone-jami-data
+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.
+3 -3
View File
@@ -8,7 +8,7 @@ These instructions explain how to update the schemas for the following databases
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
> [!NOTE]
> [!NOTE]
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
## General guidelines
@@ -25,7 +25,7 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
- Use **snake_case** in the actual database columns.
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
> [!IMPORTANT]
> [!IMPORTANT]
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
### Verifying the migration locally
@@ -73,7 +73,7 @@ If you need to **revert a migration locally**, follow these steps:
## `sis-db`
> [!NOTE]
> [!NOTE]
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
1. **Update the updater model:**
+68 -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,17 @@ export default tseslint.config(
'no-promise-executor-return': 'off',
},
},
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
// Frontend tests
{
files: ['services/frontend/tests/**/*.{ts,tsx}', "services/frontend/playwright/**/*.{ts,tsx}", 'services/frontend/playwright-ct.config.ts'],
rules: {
'import-x/no-extraneous-dependencies': 'off',
'import-x/no-unused-modules': 'off',
'import-x/no-default-export': 'off',
'react/jsx-filename-extension': 'off',
},
},
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
)
+8 -11
View File
@@ -6,15 +6,14 @@
},
"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",
"prettier": "prettier",
"cli": "./cli.sh",
"oodikone": "./run.sh oodikone anon up --detach",
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami -f",
"oodikone": "npm run docker:down && ./run.sh oodikone anon up --detach",
"oodikone:real": "./run.sh oodikone real up --detach",
"oodikone:ci:setup": "docker compose --file docker-compose.ci.yml --profile tests up --detach",
"oodikone:ci:api": "docker compose --file docker-compose.test.yml up --exit-code-from api-tests",
@@ -23,16 +22,14 @@
"both": "./run.sh both anon up --detach",
"both:real": "./run.sh both real up --detach",
"docker:down": "./run.sh both anon down --remove-orphans; ./run.sh both real down --remove-orphans",
"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",
"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 oodikone-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:docker": "docker exec oodikone-frontend npm i && docker exec oodikone-backend npm i && docker exec oodikone-updater-scheduler npm i && docker exec oodikone-updater-worker npm i",
"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 oodikone-frontend npm ci && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
+1 -1
View File
@@ -23,7 +23,7 @@ Usage: $(basename "${BASH_SOURCE[0]}") option [version] command --flag
Parameters:
* Option: oodikone/updater/both
* Version: anon/real. Not necessary when running down/logs/pull.
* Command: will be passed to docker-compose.
* Command: will be passed to docker compose.
EOF
}
+5 -4
View File
@@ -12,7 +12,7 @@ DUMP_DIR="$PROJECT_ROOT/.databasedumps"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
## Following the naming convention in docker-compose, these are names for database
## Following the naming convention in docker compose, these are names for database
## services. The real data databases inside service will have names suffixed by "-real".
KONE_DB_NAME="kone-db"
SIS_DB_NAME="sis-db"
@@ -112,11 +112,11 @@ reset_databases() {
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
npm run docker:down
docker-compose up -d "${databases[@]}"
docker compose up -d "${databases[@]}"
for database in "${databases[@]}"; do
local database_dump="$database_dump_dir/$database.sql.gz"
local database_container="$database"
local database_container="oodikone-$database"
local database_name="$database-real"
infomsg "Attempting to create database $database_name from dump $database_dump inside container $database_container"
@@ -215,6 +215,7 @@ set_up_oodikone_from_scratch() {
"$PROJECT_ROOT"
"$PROJECT_ROOT/services/frontend"
"$PROJECT_ROOT/services/backend"
"$PROJECT_ROOT/services/shared"
"$PROJECT_ROOT/updater/sis-updater-scheduler"
"$PROJECT_ROOT/updater/sis-updater-worker"
)
@@ -223,7 +224,7 @@ set_up_oodikone_from_scratch() {
"$PROJECT_ROOT"/run.sh both down --remove-orphans --volumes --rmi all || infomsg "Cleaning errored, but will continue"
for folder in "${folders_to_set_up[@]}"; do
cd "$folder" || die "Couldn't change directory to folder $folder"
rm -rf node_modules|| die "Couldn't remove node_modules in folder $folder"
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
done
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting"
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+5 -1
View File
@@ -50,9 +50,13 @@ ENV SENTRY_RELEASE=$SENTRY_RELEASE
WORKDIR /opt/app-root/shared
COPY --from=build /opt/app-root/shared/dist .
COPY --from=build /opt/app-root/shared/.npmrc .
COPY --from=build /opt/app-root/shared/package*.json ./
RUN npm ci --omit=dev
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
+10 -2
View File
@@ -1,12 +1,20 @@
FROM node:24-alpine
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
USER 1001
WORKDIR /opt/app-root/shared
COPY --chown=1001:1001 ./shared .
RUN npm ci
WORKDIR /opt/app-root/backend
COPY . .
COPY --chown=1001:1001 ./backend .
RUN npm ci
EXPOSE 8080
+22 -150
View File
@@ -22,19 +22,17 @@
"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",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"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",
@@ -2039,22 +2037,21 @@
}
},
"node_modules/@types/express": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2101,13 +2098,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/morgan": {
"version": "1.9.10",
"resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz",
@@ -2163,9 +2153,9 @@
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
@@ -2177,26 +2167,24 @@
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
@@ -2669,15 +2657,6 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
"license": "MIT",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/colorspace": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
@@ -2793,12 +2772,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -3162,6 +3135,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fastq": {
@@ -3363,15 +3337,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gelf-pro": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.4.0.tgz",
"integrity": "sha512-VXQ1DHQjQr8/Xil83hHgozqeCcVrggGAA6A8hgjojQSatiF6PErR+nnYv/DCBxFBolO6licomD7Abaj0Iec7Qg==",
"license": "MIT",
"dependencies": {
"lodash": "~4.17.21"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
@@ -3780,12 +3745,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jju": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
@@ -4488,12 +4447,6 @@
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -4681,7 +4634,8 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
@@ -5641,88 +5595,6 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-gelf-transporter": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/winston-gelf-transporter/-/winston-gelf-transporter-1.0.2.tgz",
"integrity": "sha512-N1+D778Ju5q6u7jIKgwwyYsfND8SKhw4QLtUXAS83BYgkQvRtjvNs8PWS2oY4U4voPKh3U6DcjWkPtpfgKN5dw==",
"license": "MIT",
"dependencies": {
"gelf-pro": "1.3.3",
"logform": "2.1.2",
"winston-transport": "4.3.0"
},
"engines": {
"node": ">= 8.x.x"
}
},
"node_modules/winston-gelf-transporter/node_modules/fecha": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz",
"integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==",
"license": "MIT"
},
"node_modules/winston-gelf-transporter/node_modules/logform": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.1.2.tgz",
"integrity": "sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==",
"license": "MIT",
"dependencies": {
"colors": "^1.2.1",
"fast-safe-stringify": "^2.0.4",
"fecha": "^2.3.3",
"ms": "^2.1.1",
"triple-beam": "^1.3.0"
}
},
"node_modules/winston-gelf-transporter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/winston-gelf-transporter/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/winston-gelf-transporter/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/winston-gelf-transporter/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/winston-gelf-transporter/node_modules/winston-transport": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.3.0.tgz",
"integrity": "sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.3.6",
"triple-beam": "^1.2.0"
},
"engines": {
"node": ">= 6.4.0"
}
},
"node_modules/winston-loki": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/winston-loki/-/winston-loki-6.1.3.tgz",
+2 -9
View File
@@ -3,7 +3,7 @@
"private": true,
"scripts": {
"build": "tsc",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
"test": "node --import=tsx --test --test-force-exit --test-reporter spec tests/**/*.ts"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
@@ -22,19 +22,17 @@
"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",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"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",
@@ -44,10 +42,5 @@
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.4.0"
}
}
}
-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'
@@ -38,6 +38,11 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
meta['req-route'] = req.route.path
}
// Ignore admin route that is frequently pinged for updates.
if (/.*\/updater\/jobs$/.test(meta.url ?? '')) {
return
}
const message = [
`${user.mockedBy ? '(mocking) ' : ''}${user.name}`,
`${(tokens['response-time'](req, res) ?? '0').split('.')[0]} ms`.padEnd(8, ' '),
@@ -52,7 +57,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))
+3 -5
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) {
@@ -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()
@@ -2,13 +2,14 @@ import { Router } from 'express'
import { difference, intersection } from 'lodash-es'
import { CanError } from '@oodikone/shared/routes'
import type { CompletedCoursesStudent, CompletedCoursesCourse } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import { getCompletedCourses } from '../services/completedCoursesSearch'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
getUserSearches,
createNewUserSearch,
deleteUserSearch,
updateUserSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
@@ -19,11 +20,14 @@ const router = Router()
const importerClient = getImporterClient()
export type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & {
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
courses: CompletedCoursesCourse[]
}
>
export type SearchReqBody = never
export type SearchQuery = {
type SearchReqBody = never
type SearchQuery = {
studentlist: string
courselist: string
}
@@ -72,7 +76,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 +102,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 +134,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 +159,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' })
}
+27 -10
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
@@ -46,14 +51,16 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
}
)
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
export 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)
res.json(results)
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
return 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
+18 -6
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import type { CanError, GenericApplicationError } from '@oodikone/shared/routes'
import type {
FacultyGraduationStatistics,
Graduated,
Name,
NameWithCode,
@@ -22,7 +23,6 @@ import {
setBasicStats,
getThesisWritersStats,
setThesisWritersStats,
getGraduationStats,
setGraduationStats,
getFacultyStudentStats,
setFacultyStudentStats,
@@ -30,7 +30,7 @@ import {
setFacultyProgressStats,
type BasicData,
type ThesisWriterData,
GraduationData,
getGraduationStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress } from '../services/faculty/facultyStudentProgress'
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
@@ -135,17 +135,29 @@ router.get<
return res.json(await getFacultyCredits(faculty.code, yearType === 'ACADEMIC_YEAR'))
})
type GetGraduationStatsResBody = GraduationData
type GetGraduationStatsQuery = {
programme_filter: ProgrammeFilter
}
router.get<
GetStatsParams,
CanError<GetGraduationStatsResBody, GenericApplicationError>,
CanError<FacultyGraduationStatistics, GenericApplicationError>,
GetStatsReqBody,
GetGraduationStatsQuery
>('/:id/graduationtimes', auth.roles(['facultyStatistics']), async (req, res) => {
// Recursively clears graduation time arrays "times" from result.
// We don't need to send them to client,
// but university view (uses same data) needs it to compute medians.
const clearTimes = (data: any) => {
if (Array.isArray(data)) {
data.forEach(clearTimes)
} else if (!!data && typeof data === 'object') {
if (data.times) data.times = []
Object.values(data).forEach(clearTimes)
}
return data
}
const { id: facultyId } = req.params
const { programme_filter: programmeFilter } = req.query
@@ -153,7 +165,7 @@ router.get<
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
const data = await getGraduationStats(faculty.code, programmeFilter)
if (data) return res.json(data)
if (data) return res.json(clearTimes(data))
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
@@ -161,7 +173,7 @@ router.get<
const updatedStats = await countGraduationTimes(faculty.code, programmes)
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
return res.json(updatedStats)
return res.json(clearTimes(updatedStats))
})
type GetProgressStatsResBody = {
+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()
+30 -21
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,21 +102,17 @@ 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
const requiredFields = [years, semesters, programme]
if (requiredFields.some(field => !field)) {
if (requiredFields.some(field => !field.length)) {
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
}
@@ -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,25 @@ 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',
router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
'/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body
const { studentNumbers, tags } = req.body ?? { studentNumbers: [] }
const { id: userId, roles, studentsUserCanAccess } = req.user
const students = handleQueryArrays(studentNumbers)
if (!students.length) {
return res.status(422).json({ error: 'Body should include student numbers' })
}
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
? students
: intersection(students, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
@@ -242,14 +251,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 +284,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 (
!(
+80 -74
View File
@@ -1,11 +1,12 @@
import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash-es'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { FacultyGraduationStatistics, Graduated, NameWithCode } from '@oodikone/shared/types'
import { MediansByCategory, UniversityGraduationStatistics } from '@oodikone/shared/types/graduations'
import { magicFacultyCode } from '../config/organizationConstants'
import { OrganizationModel } from '../models'
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
import { countGraduationTimes, LevelGraduationStats } from '../services/faculty/facultyGraduationTimes'
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
import {
getFacultyProgressStats,
@@ -14,6 +15,7 @@ import {
setFacultyProgressStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { GraduationTarget } from '../services/graduationHelpers'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import logger from '../util/logger'
@@ -21,13 +23,12 @@ const router = Router()
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
const getProgrammeNames = (faculties: OrganizationModel[]) => {
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
const getProgrammeNames = (faculties: OrganizationModel[]) =>
faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
const { name, code } = faculty.toJSON()
obj[faculty.code] = { code, ...name }
return obj
}, {})
}
interface GetProgressStatsRequest extends Request {
query: {
@@ -119,16 +120,20 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
res.status(200).json(universityData)
})
router.get('/allgraduationstats', async (_req: Request, res: Response) => {
const degreeNames = ['bachelor', 'bcMsCombo', 'master', 'doctor'] as const
router.get('/allgraduationstats', async (_req: Request, res: Response<UniversityGraduationStatistics>) => {
const degreeTypes = ['bachelor', 'bcMsCombo', 'master', 'doctor'] as const
/** University view does not support old degree programmes */
const programmeFilter = 'NEW_DEGREE_PROGRAMMES' as const
const allFaculties = await getSortedFaculties()
const facultyCodes = allFaculties.map(faculty => faculty.code)
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
const programmeFilter = 'NEW_DEGREE_PROGRAMMES'
const facultyData: Record<string, FacultyGraduationStatistics> = {}
for (const facultyCode of facultyCodes) {
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
let data = await getGraduationStats(facultyCode, programmeFilter, true)
if (!data) {
logger.info(`Data missing from server: Refreshing graduation faculty data for faculty ${facultyCode}`)
logger.info(`Data missing from redis: Refreshing graduation faculty data for faculty ${facultyCode}`)
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, true)
data = await countGraduationTimes(facultyCode, programmes)
await setGraduationStats(data, programmeFilter)
@@ -136,43 +141,50 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
facultyData[facultyCode] = data
}
const universityData = {
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
programmeNames: getProgrammeNames(allFaculties),
byGradYear: {
medians: {} as Record<string, LevelGraduationStats[]>,
programmes: {
medians: {} as Record<
string,
Record<string, { programmes: string[]; data: Array<LevelGraduationStats & { name: string; code: string }> }>
>,
const goals: UniversityGraduationStatistics['goals'] = {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
}
const programmeNames = getProgrammeNames(allFaculties)
const byGradYear: UniversityGraduationStatistics['byGradYear'] = {
medians: {
bachelor: [],
bcMsCombo: [],
master: [],
doctor: [],
},
programmes: {
medians: {
bachelor: {},
bcMsCombo: {},
master: {},
doctor: {},
},
},
classSizes: {
bachelor: null as Record<string, number> | null,
bcMsCombo: null as Record<string, number> | null,
master: null as Record<string, number> | null,
doctor: null as Record<string, number> | null,
programmes: {} as Record<string, Record<string, Record<string, number>>>,
},
}
const classSizes: UniversityGraduationStatistics['classSizes'] = {
programmes: {},
}
const unifyTotals = (facultyData: Record<string, LevelGraduationStats[]>, isLast: boolean) => {
const mediansForUniversity = universityData.byGradYear.medians
for (const degree of degreeNames) {
if (!mediansForUniversity[degree]) mediansForUniversity[degree] = []
if (!facultyData[degree]) continue
for (const yearStats of facultyData[degree]) {
const universityStats = mediansForUniversity[degree]
const universityYearStats = mediansForUniversity[degree].find(stats => stats.name === yearStats.name)
/** Computes university level stats */
const unifyTotals = (facultyData: MediansByCategory, isLast: boolean) => {
const mediansForUniversity = byGradYear.medians
for (const degreeType of degreeTypes) {
if (!facultyData[degreeType]) continue
for (const facultyYearStats of facultyData[degreeType]) {
const universityYearStats = mediansForUniversity[degreeType].find(stats => stats.name === facultyYearStats.name)
if (!universityYearStats) {
universityStats.push(yearStats)
mediansForUniversity[degreeType].push(facultyYearStats)
} else {
universityYearStats.times.push(...yearStats.times)
universityYearStats.amount += yearStats.amount
universityYearStats.statistics.onTime += yearStats.statistics.onTime
universityYearStats.statistics.yearOver += yearStats.statistics.yearOver
universityYearStats.statistics.wayOver += yearStats.statistics.wayOver
universityYearStats.times.push(...facultyYearStats.times)
universityYearStats.amount += facultyYearStats.amount
universityYearStats.statistics.onTime += facultyYearStats.statistics.onTime
universityYearStats.statistics.yearOver += facultyYearStats.statistics.yearOver
universityYearStats.statistics.wayOver += facultyYearStats.statistics.wayOver
if (isLast) {
universityYearStats.median = getMedian(universityYearStats.times)
}
@@ -181,29 +193,30 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
}
}
const unifyProgrammeStats = (facultyData: Record<string, LevelGraduationStats[]>, facultyCode: string) => {
const mediansForUniversity = universityData.byGradYear.programmes.medians
for (const degree of degreeNames) {
if (!facultyData[degree]) continue
if (!mediansForUniversity[degree]) mediansForUniversity[degree] = {}
for (const yearData of facultyData[degree]) {
if (!mediansForUniversity[degree][yearData.name]) {
mediansForUniversity[degree][yearData.name] = { programmes: [], data: [] }
/** Computes faculty breakdown */
const unifyFacultyStats = (facultyData: MediansByCategory, facultyCode: string) => {
const mediansForUniversity = byGradYear.programmes.medians
for (const degreeType of degreeTypes) {
if (!facultyData[degreeType]) continue
for (const facultyYearStats of facultyData[degreeType]) {
if (!mediansForUniversity[degreeType][facultyYearStats.name]) {
mediansForUniversity[degreeType][facultyYearStats.name] = { programmes: [], data: [] }
}
const uniYearStats = mediansForUniversity[degree][yearData.name]
const uniYearStats = mediansForUniversity[degreeType][facultyYearStats.name]
if (!uniYearStats.programmes.find(prog => prog === facultyCode)) {
uniYearStats.programmes.push(facultyCode)
}
const uniYearFacultyStats = uniYearStats.data.find(item => item.code === facultyCode)
if (!uniYearFacultyStats) {
uniYearStats.data.push({ ...cloneDeep(yearData), name: facultyCode, code: facultyCode })
uniYearStats.data.push({ ...cloneDeep(facultyYearStats), name: facultyCode, code: facultyCode })
} else {
uniYearFacultyStats.times.push(...uniYearFacultyStats.times)
uniYearFacultyStats.median = getMedian(uniYearFacultyStats.times)
uniYearFacultyStats.amount += yearData.amount
uniYearFacultyStats.statistics.onTime += yearData.statistics.onTime
uniYearFacultyStats.statistics.yearOver += yearData.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += yearData.statistics.wayOver
uniYearFacultyStats.amount += facultyYearStats.amount
uniYearFacultyStats.statistics.onTime += facultyYearStats.statistics.onTime
uniYearFacultyStats.statistics.yearOver += facultyYearStats.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += facultyYearStats.statistics.wayOver
}
}
}
@@ -212,27 +225,20 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
for (let i = 0; i < facultyCodes.length; i++) {
const facultyCode = facultyCodes[i]
const data = facultyData[facultyCode]
unifyTotals(data.byGradYear.medians, i === facultyCodes.length - 1)
unifyProgrammeStats(data.byGradYear.medians, facultyCode)
for (const degree of degreeNames) {
if (!data.classSizes[degree]) {
continue
}
const facultyClassSizes = data.classSizes[degree] as Record<string, number>
if (!universityData.classSizes[degree]) {
universityData.classSizes[degree] = facultyClassSizes
} else {
Object.entries(facultyClassSizes).forEach(([key, value]) => {
universityData.classSizes[degree]![key] += value
})
}
}
const { programmes, ...rest } = data.classSizes
universityData.classSizes.programmes[facultyCode] = rest as Record<string, Record<string, number>>
unifyTotals(data.byGradYear.medians, i === facultyCodes.length - 1)
unifyFacultyStats(data.byGradYear.medians, facultyCode)
const { programmes: _, ...rest } = data.classSizes
classSizes.programmes[facultyCode] = rest
}
res.status(200).json(universityData)
res.status(200).json({
goals,
programmeNames,
byGradYear,
classSizes,
})
})
export default router
+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()
})
+15 -28
View File
@@ -1,32 +1,35 @@
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, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
auth.roles(['admin']),
@@ -39,11 +42,6 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
@@ -56,18 +54,11 @@ router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
}
)
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 }
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' })
@@ -79,10 +70,7 @@ router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['
return res.status(200).end()
})
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
router.post<never, CanError<void>, ElementsReqBody, ElementsParams>(
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -93,7 +81,7 @@ router.post<never, CanError<void>, ElementsReqBody, ElementsParams>(
}
)
router.delete<never, CanError<void>, ElementsReqBody, ElementsParams>(
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -104,7 +92,6 @@ router.delete<never, CanError<void>, ElementsReqBody, ElementsParams>(
}
)
type ChangeLanguageReqBody = { language: Language }
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
@@ -1,64 +1,30 @@
import { col, fn, Op, where } from 'sequelize'
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import {
CompletedCoursesCourse,
CompletedCoursesStudent,
CreditTypeCode,
EnrollmentState,
StudentCredits,
} from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold, omitKeys } from '@oodikone/shared/util'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>
interface StudentCredit {
date: Date
courseCode: string
creditType: CreditTypeCode
substitution: string | null
}
interface StudentEnrollment {
date: Date
courseCode: string
substitution: string | null
}
interface StudentInfo {
firstnames: string
lastname: string
email: string
sis_person_id: string
coursesInStudyPlan: string[]
secondaryEmail: string | null
credits: StudentCredit[]
enrollments: Record<string, StudentEnrollment>
}
type StudentCredits = Record<
string,
Omit<StudentInfo, 'credits' | 'enrollments'> & {
credits: StudentCredit[]
enrollments: StudentEnrollment[]
}
>
export interface FormattedStudent extends StudentInfo {
studentNumber: string
allEnrollments: StudentEnrollment[]
}
interface StudentWithStudyplanNested
extends Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> {
type StudentWithStudyplanNested = Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> & {
studyplans: { included_courses: string[] }[]
}
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
type StudentWithCourses = Omit<StudentWithStudyplanNested, 'studyplans'> & {
coursesInStudyPlan: string[]
}
const getCourses = async (courseCodes: string[]) => {
const courses: Courses = (
const courses: CompletedCoursesCourse[] = (
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,44 +33,76 @@ const getCourses = async (courseCodes: string[]) => {
return courses
}
const getCredits = async (
courses: Courses,
const getPassedCredits = async (
courses: CompletedCoursesCourse[],
fullCourseCodes: string[],
courseCodes: string[],
studentNumbers: string[]
) => {
const credits: Array<
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
> = await CreditModel.findAll({
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
where: {
course_code: {
[Op.in]: fullCourseCodes,
const credits: 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,
},
student_studentnumber: {
[Op.in]: studentNumbers,
},
credittypecode: {
[Op.not]: CreditTypeCode.FAILED,
},
},
student_studentnumber: {
[Op.in]: studentNumbers,
},
},
})
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
}
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
const getEnrollments = async (
courses: CompletedCoursesCourse[],
fullCourseCodes: string[],
studentNumbers: string[]
) => {
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,
@@ -115,17 +113,45 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
state: {
[Op.eq]: EnrollmentState.ENROLLED,
},
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
},
})
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
}
@@ -156,18 +182,23 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
export const getCompletedCourses = async (
studentNumbers: string[],
courseCodes: string[]
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
): Promise<{ students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]; courses: CompletedCoursesCourse[] }> => {
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)
@@ -223,27 +254,30 @@ export const getCompletedCourses = async (
})
})
const students = Object.keys(studentCredits).reduce((acc: FormattedStudent[], studentNumber) => {
const student = studentCredits[studentNumber]
acc.push({
studentNumber,
sis_person_id: student.sis_person_id,
credits: student.credits,
enrollments: {},
allEnrollments: student.enrollments,
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
}, [] as FormattedStudent[])
const students = Object.keys(studentCredits).reduce<CompletedCoursesStudent[]>(
(acc: CompletedCoursesStudent[], studentNumber) => {
const student = studentCredits[studentNumber]
acc.push({
studentNumber,
sis_person_id: student.sis_person_id,
credits: student.credits,
enrollments: {},
allEnrollments: student.enrollments,
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
},
[]
)
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,10 @@
import { Op } from 'sequelize'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
import {
StudentModel,
CreditModel,
SemesterModel,
OrganizationModel,
@@ -11,37 +14,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 +133,65 @@ 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', 'studyright_id'],
include: [
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
[Op.lte]: new Date(),
},
},
},
],
where: {
course_code: {
[Op.in]: allCourseCodes,
},
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
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
}
+201 -60
View File
@@ -1,11 +1,14 @@
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 { enrollmentTimeDateThreshold, getSemesterCodeAt } from '@oodikone/shared/util'
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 { getSemestersAndYears, SemestersAndYears } from '../semesters'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
getCreditsForCourses,
@@ -14,13 +17,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 +43,37 @@ 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)
// TODO: Calculate substitutions correctly once course_unit_realisation hits
const attainment =
creditGroup
.sort((a, b) => b.attainment_date.getTime() - a.attainment_date.getTime()) // desc
.find(credit => CreditModel.passed(credit)) ??
creditGroup.find(credit => CreditModel.failed(credit)) ??
creditGroup.at(0)!
const courseCode = singleCourse ? attainment.course_code : mainCourseCode
const grade = singleCourse ? attainment.grade : 'substituted'
const credits = singleCourse ? attainment.credits : creditGroup.reduce((acc, credit) => acc + credit.credits, 0)
const {
semester,
grade,
course_code: courseCode,
credits,
attainment_date: attainmentDate,
student_studentnumber: studentNumber,
studyright_id: studyRightId,
} = credit
} = attainment
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const programmeOfCredit: SISStudyRightElementModel | undefined =
@@ -87,7 +100,7 @@ const parseCredit = (
attainmentDate,
courseCode,
grade,
passed: !CreditModel.failed(credit),
passed: singleCourse ? !CreditModel.failed(attainment) : true,
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
programme,
credits,
@@ -95,36 +108,77 @@ const parseCredit = (
}
type FormattedEnrollment = {
yearCode: number
yearName: string
semesterCode: number
semesterName: Name
courseCode: string
enrollmentDateTime: Date
programme: FormattedProgramme
studentNumber: string
}
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
const parseEnrollment = (
enrollment: Enrollment,
anonymizationSalt: string | null,
studyRightElements: Array<SISStudyRightElementModel>
): FormattedEnrollment => {
const {
studentnumber: studentNumber,
semester,
enrollment_date_time: enrollmentDateTime,
course_code: courseCode,
studyright_id: studyRightId,
} = enrollment
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const programmeOfCredit: SISStudyRightElementModel | undefined =
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
studyRightElements
.filter(({ startDate, endDate }) => dateIsBetween(enrollmentDateTime, startDate, endDate))
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.at(0) // The newest studyRightElement
const programme = programmeOfCredit
? formatStudyRightElement(programmeOfCredit)
: {
code: 'OTHER',
name: { en: 'Other', fi: 'Muu', sv: 'Andra' },
facultyCode: 'OTHER',
organization: { name: { en: 'Other', fi: 'Muu', sv: 'Andra' } },
}
return {
yearCode,
yearName,
semesterCode,
semesterName,
courseCode,
enrollmentDateTime,
programme,
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
}
}
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 getSemesterAndYearByDate = (
date: Date,
semesters: SemestersAndYears['semesters'],
years: SemestersAndYears['years']
) => {
const semesterCode = getSemesterCodeAt(semesters, date) ?? getSemesterCodeAt(semesters, new Date())!
const semester = semesters[semesterCode]
const year = years[semester.yearcode]
return { year, semester }
}
const getYearlyStatsOfNew = async (
course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null,
courseCode: string,
separate: boolean,
unification: Unification,
@@ -132,27 +186,38 @@ 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 { semesters, years } = await getSemestersAndYears()
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 +228,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,
@@ -178,38 +248,68 @@ const getYearlyStatsOfNew = async (
programme.organization
)
// Credits/attainments have quaranteed matching attainment_date and semesters/years
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,
courseCode: enrollmentCourseCode,
enrollmentDateTime,
programme,
} = parseEnrollment(
enrollment,
anonymizationSalt,
studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
)
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
// Enrollments can have conflicting enrollment_date_time and semestercode, so we need to manually select
// semestercode matching enrollment_date_time
const { semester, year } = getSemesterAndYearByDate(enrollment.enrollment_date_time, semesters, years)
counter.markStudyProgramme(
studentNumber,
year.yearcode,
false, // passed
0, // credits
programme.code,
programme.name,
programme.facultyCode,
programme.organization
)
const groupCode = separate ? semester.semestercode : year.yearcode
const groupName = separate ? semester.name : year.yearname
counter.markEnrollmentToGroup(
studentNumber,
enrollmentDateTime,
groupCode,
groupName,
enrollmentCourseCode,
year.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 +323,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)
@@ -259,6 +359,7 @@ export const getCourseYearlyStats = async (
[Op.in]: courseCodes,
},
state: EnrollmentState.ENROLLED,
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
},
}),
])
@@ -277,8 +378,20 @@ 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 },
raw: true,
})
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 +400,7 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.REGULAR,
@@ -295,6 +409,7 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.UNIFY,
@@ -327,3 +442,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,35 +1,78 @@
import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { Name, DegreeProgrammeType, FacultyGraduationStatistics, ClassSizes } from '@oodikone/shared/types'
import type {
GraduationStatistics,
MedianEntry,
MediansByCategory,
MediansByProgrammes,
ProgrammeGraduationStats,
ProgrammeMedians,
} from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getGraduationStatsForStudyTrack } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrackStats'
import type { ProgrammesOfOrganization } from './faculty'
export type LevelGraduationStats = Omit<Unarray<GraduationTimes['medians']>, 'y'> & { median: number }
type ProgrammeStats = {
data: Array<Omit<LevelGraduationStats, 'times'> & { code: string }>
programmes: string[]
}
export const programmeTypes = {
[DegreeProgrammeType.BACHELOR]: 'bachelor',
[DegreeProgrammeType.MASTER]: 'master',
[DegreeProgrammeType.DOCTOR]: 'doctor',
} as const
const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
const newStats: ProgrammeGraduationStats[] = []
const medians: Record<string, LevelGraduationStats[]> = {}
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
const setYearlyBreakdown = (code: string, progId: string, level: ProgrammeMedians, year: MedianEntry) => {
if (year.y === 0) return
level[year.name] ??= { data: [], programmes: [] }
level[year.name].programmes.push(code)
level[year.name].data.push(
omitKeys({ ...structuredClone(year), name: progId, code, median: year.y }, ['y', 'classSize'])
)
}
const mergeStats = (
medians: MediansByCategory,
level: keyof MediansByCategory,
statsForYear: MedianEntry,
classSizes?: ClassSizes
) => {
if (classSizes) {
classSizes[level][statsForYear.name] ??= 0
classSizes[level][statsForYear.name] += statsForYear.classSize ?? 0
}
const gradStatsForYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!gradStatsForYear) {
medians[level].push({ ...omitKeys(structuredClone(statsForYear), ['y']), median: statsForYear.y })
} else {
gradStatsForYear.times.push(...statsForYear.times)
gradStatsForYear.median = getMedian(gradStatsForYear.times)
gradStatsForYear.amount += statsForYear.amount
gradStatsForYear.statistics.onTime += statsForYear.statistics.onTime
gradStatsForYear.statistics.yearOver += statsForYear.statistics.yearOver
gradStatsForYear.statistics.wayOver += statsForYear.statistics.wayOver
}
}
const getStatsByGraduationYear = async (
facultyProgrammes: ProgrammesOfOrganization
): Promise<GraduationStatistics['byGradYear']> => {
const newStats: Pick<ProgrammeGraduationStats, 'id' | 'graduationTimes' | 'comboTimes'>[] = []
const medians: MediansByCategory = { bachelor: [], master: [], bcMsCombo: [], doctor: [] }
const programmes: { medians: MediansByProgrammes } = {
medians: {
bachelor: {},
master: {},
bcMsCombo: {},
doctor: {},
},
}
for (const programme of facultyProgrammes) {
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
continue
}
const statsFromRedis = await getGraduationStats(programme.code, '', 'CALENDAR_YEAR', 'SPECIAL_INCLUDED')
@@ -50,92 +93,44 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
}
for (const { id: programmeCode, graduationTimes, comboTimes } of newStats) {
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
if (!medians[level]) {
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omit(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 })
continue
}
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
}
}
if (level === 'master' && comboTimes.medians.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omit(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 })
continue
}
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
}
}
}
if (!programmes.medians[level]) {
programmes.medians[level] = {}
}
for (const year of graduationTimes.medians) {
if (year.y === 0) continue
if (!programmes.medians[level][year.name]) {
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'])
)
programmes.medians[level][year.name].programmes.push(code)
mergeStats(medians, level, year)
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], year)
}
if (level === 'master' && comboTimes.medians.length) {
if (!programmes.medians.bcMsCombo) {
programmes.medians.bcMsCombo = {}
}
for (const year of comboTimes.medians) {
if (year.y === 0) continue
if (!programmes.medians.bcMsCombo[year.name]) {
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'])
)
programmes.medians.bcMsCombo[year.name].programmes.push(code)
mergeStats(medians, 'bcMsCombo', year)
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, year)
}
}
}
return { medians, programmes }
}
const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
const newStats: Array<Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>> = []
const medians: Record<string, LevelGraduationStats[]> = {}
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
const classSizes: Record<string, Record<string, number | Record<string, number>>> = { programmes: {} }
const getStatsByStartYear = async (
facultyProgrammes: ProgrammesOfOrganization
): Promise<GraduationStatistics['byStartYear'] & Pick<GraduationStatistics, 'classSizes'>> => {
const classSizes: ClassSizes = { bachelor: {}, master: {}, bcMsCombo: {}, doctor: {}, programmes: {} }
const newStats: Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>[] = []
const medians: MediansByCategory = { bachelor: [], master: [], bcMsCombo: [], doctor: [] }
const programmes: { medians: MediansByProgrammes } = {
medians: {
bachelor: {},
master: {},
bcMsCombo: {},
doctor: {},
},
}
for (const programme of facultyProgrammes) {
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
continue
}
const statsFromRedis = await getStudyTrackStats(programme.code, '', 'SPECIAL_EXCLUDED')
const statsFromRedis = await getStudyTrackStats(programme.code, null, 'SPECIAL_EXCLUDED')
if (statsFromRedis) {
newStats.push(statsFromRedis)
continue
@@ -155,9 +150,13 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
}
for (const { id: programmeCode, graduationTimes } of newStats) {
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
if (!(programmeCode in graduationTimes)) continue
if (!(programmeCode in graduationTimes)) {
continue
}
const statsForProgramme = graduationTimes[programmeCode]
const basicStats = statsForProgramme.medians.basic
const comboStats = statsForProgramme.medians.combo
@@ -165,88 +164,31 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
(acc, year) => ({ ...acc, [year.name]: year.classSize ?? 0 }),
{}
)
classSizes.programmes[code] = classSizesByYear
classSizes.programmes[programmeCode] = classSizesByYear
if (!medians[level]) {
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
}
classSizes[level] ??= {}
for (const statsForYear of basicStats) {
classSizes[level][statsForYear.name] ??= 0
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize ?? 0
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
mergeStats(medians, level, statsForYear, classSizes)
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], statsForYear)
}
if (level === 'master' && comboStats.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
}
classSizes.bcMsCombo ??= {}
for (const statsForYear of comboStats) {
classSizes.bcMsCombo[statsForYear.name] ??= 0
;(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 })
continue
}
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
}
}
if (!programmes.medians[level]) {
programmes.medians[level] = {}
}
for (const year of basicStats) {
if (year.y === 0) continue
if (!programmes.medians[level][year.name]) {
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'])
)
programmes.medians[level][year.name].programmes.push(code)
}
if (level === 'master' && comboStats.length) {
if (!programmes.medians.bcMsCombo) {
programmes.medians.bcMsCombo = {}
}
for (const year of comboStats) {
if (year.y === 0) continue
if (!programmes.medians.bcMsCombo[year.name]) {
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'])
)
programmes.medians.bcMsCombo[year.name].programmes.push(code)
mergeStats(medians, 'bcMsCombo', statsForYear, classSizes)
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, statsForYear)
}
}
}
return { medians, programmes, classSizes }
}
export const countGraduationTimes = async (faculty: string, programmesOfFaculty: ProgrammesOfOrganization) => {
export const countGraduationTimes = async (
facultyCode: string,
programmesOfFaculty: ProgrammesOfOrganization
): Promise<FacultyGraduationStatistics> => {
const { medians: mediansByGraduationYear, programmes: programmesByGraduationYear } =
await getStatsByGraduationYear(programmesOfFaculty)
const {
medians: mediansByStartYear,
programmes: programmesByStartYear,
@@ -255,7 +197,7 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
const goals = {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
bcMsCombo: facultyCode === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
@@ -279,7 +221,7 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
}, {})
return {
id: faculty,
id: facultyCode,
goals,
byGradYear: {
medians: mediansByGraduationYear,
@@ -1,4 +1,4 @@
import { Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
import { FacultyGraduationStatistics, Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
import { DegreeProgramme } from '../../routes/faculties'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -29,16 +29,16 @@ const createRedisKeyForFacultyStudents = (id: string, specialGroups: SpecialGrou
return `FACULTY_STUDENTS_STATS_${id}_${specialGroups}`
}
/*
/**
Faculty data objects have graduation times left in as arrays, so that
university-level evaluation overview can count median-times by itself.
university-level evaluation overview can count median-times by itself (in backend).
Faculties, however, don't need this, and it isn't needed in frontend
for universityview either. This removes them from the object.
for university view either. This clears the array.
*/
const removeGraduationTimes = (data: GraduationData) => {
const removeGraduationTimes = (data: FacultyGraduationStatistics) => {
Object.values(data.byGradYear.medians).forEach(array =>
array.forEach(yearStat => {
yearStat.times = null
yearStat.times = []
})
)
}
@@ -158,7 +158,7 @@ export type GraduationData = {
classSizes: Record<string, Record<string, number | Record<string, number>>>
}
export const setGraduationStats = async (data: GraduationData, programmeFilter: ProgrammeFilter) => {
export const setGraduationStats = async (data: FacultyGraduationStatistics, programmeFilter: ProgrammeFilter) => {
const { id } = data
const redisKey = createRedisKeyForGraduationTimeStats(id, programmeFilter)
await redisClient.set(redisKey, JSON.stringify(data))
@@ -170,7 +170,7 @@ export const getGraduationStats = async (id: string, programmeFilter: ProgrammeF
if (!dataFromRedis) {
return null
}
const data = JSON.parse(dataFromRedis) as GraduationData
const data = JSON.parse(dataFromRedis) as FacultyGraduationStatistics
if (!keepGraduationTimes) {
removeGraduationTimes(data)
}
@@ -8,7 +8,7 @@ export const GraduationTarget = {
FOUR_YEARS: 8,
FIVE_YEARS: 10,
SIX_YEARS: 12,
} as const
}
/** Uses semesters, not months */
export const countTimeCategories = (times: number[], target: number) => {
@@ -106,8 +106,8 @@ export const computeLanguageCenterData = async () => {
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'studyright_id', 'state'],
where: {
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
semestercode: { [Op.gte]: autumnSemester2017 },
state: { [Op.in]: ['ENROLLED', 'REJECTED'] },
// This doesn't need the `enrollmentTimeDateThreshold`, ColorizedDataTable doesn't use studyright_ids
},
raw: true,
})
+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({
@@ -1,33 +0,0 @@
import { CourseModel, CreditModel, EnrollmentModel, StudentModel } from '../../models'
export const formatCourseInfo = (course: CourseModel) => {
return {
label: course.code,
name: course.name,
}
}
export const formatOpenCredits = (credit: CreditModel) => {
return {
attainmentCourseCode: credit.course_code,
attainmentDate: credit.attainment_date,
attainmentGrade: credit.grade,
attainmentStudentNumber: credit.student_studentnumber,
}
}
export const formatOpenEnrollments = (enrollment: EnrollmentModel) => {
return {
enrollmentCourseCode: enrollment.course_code,
enrollmentDateTime: enrollment.enrollment_date_time,
enrollmentStudentNumber: enrollment.studentnumber,
}
}
export const formatStudentInfo = (student: StudentModel) => {
return {
studentNumber: student.studentnumber,
email: student.email,
secondaryEmail: student.secondary_email,
}
}
@@ -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,94 +1,4 @@
import { Op, WhereOptions } from 'sequelize'
import { SISStudyRight } from '@oodikone/shared/models'
import { EnrollmentState, ExtentCode } from '@oodikone/shared/types'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, SISStudyRightModel } from '../../models'
import { OpenUniPopulationSearchModel } from '../../models/kone'
import { formatCourseInfo, formatOpenCredits, formatOpenEnrollments, formatStudentInfo } from './format'
export const getCredits = async (courseCodes: string[], startdate: Date) =>
(
await CreditModel.findAll({
attributes: ['attainment_date', 'course_code', 'grade', 'student_studentnumber'],
where: {
course_code: {
[Op.in]: courseCodes,
},
attainment_date: {
[Op.gte]: startdate,
},
},
})
).map(formatOpenCredits)
export const getStudentInfo = async (studentNumbers: string[]) =>
(
await StudentModel.findAll({
attributes: ['studentnumber', 'email', 'secondary_email'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
},
})
).map(formatStudentInfo)
export const getEnrollments = async (courseCodes: string[], startDate: Date, endDate: Date) =>
(
await EnrollmentModel.findAll({
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
where: {
course_code: {
[Op.in]: courseCodes,
},
is_open: true,
enrollment_date_time: {
[Op.and]: {
[Op.lte]: endDate,
[Op.gte]: startDate,
},
},
state: EnrollmentState.ENROLLED,
},
})
).map(formatOpenEnrollments)
export const getCourseNames = async (courseCodes: string[]) =>
(
await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
code: {
[Op.in]: courseCodes,
},
},
})
).map(formatCourseInfo)
export const getStudyRights = async (studentNumbers: string[]) => {
const where: WhereOptions<SISStudyRight> = {
extentCode: {
[Op.in]: [
ExtentCode.BACHELOR,
ExtentCode.BACHELOR_AND_MASTER,
ExtentCode.MASTER,
ExtentCode.LICENTIATE,
ExtentCode.DOCTOR,
],
},
}
if (studentNumbers.length > 0) {
where.studentNumber = {
[Op.in]: studentNumbers,
}
}
return await SISStudyRightModel.findAll({
attributes: ['startDate', 'endDate', 'studentNumber'],
where,
})
}
export const getOpenUniSearchesByUser = async (userId: string) => {
return await OpenUniPopulationSearchModel.findAll({
@@ -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,75 +0,0 @@
const axios = require('axios')
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
const { ApplicationError } = require('../util/customErrors')
export 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
}
export const decodeJwtTokenPayloadToObject = token => {
const payload = token.split('.')[1]
const paddedPayload = `${payload}==`
const bufferObj = Buffer.from(paddedPayload, 'base64')
return JSON.parse(bufferObj.toString('utf-8'))
}
export const getSisuAuthData = async eppn => {
const accessToken = await getSisuAccessToken(eppn)
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
return { accessToken, tokenData }
}
export 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
}
}`
export 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
}
@@ -3,6 +3,8 @@ import { Op } from 'sequelize'
import { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
import { EnrollmentState } from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
import { dateMaxFromList } from '@oodikone/shared/util/datetime'
import {
CreditModel,
EnrollmentModel,
@@ -48,8 +50,8 @@ export type StudentEnrollment = Pick<
Enrollment,
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber' | 'studyright_id'
>
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> =>
EnrollmentModel.findAll({
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> => {
return EnrollmentModel.findAll({
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber', 'studyright_id'],
where: {
studentnumber: {
@@ -57,11 +59,12 @@ export const getEnrollments = (studentNumbers: string[], startDate: string): Pro
},
state: EnrollmentState.ENROLLED,
enrollment_date_time: {
[Op.gte]: startDate,
[Op.gte]: dateMaxFromList(new Date(startDate), enrollmentTimeDateThreshold),
},
},
raw: true,
})
}
type StudentPersonalData = Pick<
Student,
@@ -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()
+3 -3
View File
@@ -4,7 +4,7 @@ import { Name } from '@oodikone/shared/types'
import { dateMinFromList, dateMaxFromList } from '@oodikone/shared/util/datetime'
import { SemesterModel } from '../models'
type SemestersAndYears = {
export type SemestersAndYears = {
years: Record<
string,
{
@@ -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
}
+7 -6
View File
@@ -1,9 +1,10 @@
import dayjs from 'dayjs'
import { Op, QueryTypes } from 'sequelize'
import type { Credit, Student } from '@oodikone/shared/models'
import { EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
import { FormattedStudentForSearch, StudentPageStudent } from '@oodikone/shared/types/studentData'
import { splitByEmptySpace } from '@oodikone/shared/util'
import { enrollmentTimeDateThreshold, splitByEmptySpace } from '@oodikone/shared/util'
import { dbConnections } from '../database/connection'
import {
StudentModel,
@@ -118,7 +119,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 +170,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,15 +181,15 @@ 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.enrollment_date_time >= '${dayjs(enrollmentTimeDateThreshold).format('YYYY-MM-DD')}'
AND e.state = :enrollmentState
);
`,
{
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) => {
@@ -2,13 +2,15 @@ import { memoize } from 'lodash-es'
import { Op, QueryTypes } from 'sequelize'
import { Name, CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
import { dateIsBetween } from '@oodikone/shared/util/datetime'
import { dbConnections } from '../../database/connection'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, ProgrammeModuleModel } from '../../models'
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,
@@ -27,7 +29,7 @@ export const getAllProgrammeCourses = async (providerCode: string) => {
return courses
}
const getCourseCode = (code: string) => {
const removeOpenUniCodePrefix = (code: string) => {
if (code.startsWith('AY')) {
return code.replace('AY', '')
}
@@ -39,14 +41,23 @@ const getCourseCode = (code: string) => {
export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
try {
const enrollmentsCourses = await EnrollmentModel.findAll({
attributes: ['studentnumber', 'course_code'],
const enrollmentsCourses: Array<
Pick<EnrollmentModel, 'studentnumber' | 'course_code' | 'enrollment_date_time' | 'course'>
> = await EnrollmentModel.findAll({
raw: true,
nest: true,
attributes: ['studentnumber', 'course_code', 'enrollment_date_time'],
include: {
model: CourseModel,
attributes: ['is_study_module'],
},
where: {
course_code: {
[Op.in]: programmeCourses,
},
enrollment_date_time: {
[Op.between]: [from, to],
[Op.gte]: enrollmentTimeDateThreshold,
},
state: EnrollmentState.ENROLLED,
},
@@ -54,27 +65,36 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
const allEnrollments = new Map<string, Set<string>>()
for (const { studentnumber, course_code: courseCode } of enrollmentsCourses) {
const code = getCourseCode(courseCode)
const code = removeOpenUniCodePrefix(courseCode)
if (!allEnrollments.has(code)) {
allEnrollments.set(code, new Set())
}
allEnrollments.get(code)!.add(studentnumber)
}
const credits = await CreditModel.findAll({
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule'],
// TODO: This is being called for each year, could be optimized to call only once
const credits: Array<
Pick<
CreditModel,
'course_code' | 'student_studentnumber' | 'credittypecode' | 'isStudyModule' | 'attainment_date'
>
> = await CreditModel.findAll({
raw: true,
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule', 'attainment_date'],
where: {
course_code: {
[Op.in]: programmeCourses,
},
attainment_date: {
[Op.between]: [from, to],
},
},
order: [['credittypecode', 'ASC']], // Passed credits for a student have to be handled first
})
// Pick only this (between to, from) year's credits
const filteredCredits = credits.filter(credit => dateIsBetween(credit.attainment_date, from, to))
const courseCodeToName = (
await CourseModel.findAll({
raw: true,
attributes: ['code', 'name'],
where: {
code: programmeCourses,
@@ -85,8 +105,8 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
return acc
}, new Map())
const creditCourses = credits.map(credit => ({
code: getCourseCode(credit.course_code),
const creditCourses = filteredCredits.map(credit => ({
code: removeOpenUniCodePrefix(credit.course_code),
studentNumber: credit.student_studentnumber,
creditTypeCode: credit.credittypecode,
courseName: courseCodeToName.get(credit.course_code)!,
@@ -97,6 +117,15 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
const notCompletedByCourseCodes = new Map<string, Set<string>>()
const courses = new Map<string, { name: Name; isStudyModule: boolean }>()
const studentHasPassedCourse = (studentNumber: string, courseCode: string) =>
!!credits.find(
credit =>
credit.student_studentnumber === studentNumber &&
credit.course_code === courseCode &&
CreditModel.passed(credit)
) ||
(passedByCourseCodes.get(courseCode)?.has(studentNumber) ?? false)
for (const { code, courseName, creditTypeCode, isStudyModule, studentNumber } of creditCourses) {
if (!courses.has(code)) {
courses.set(code, {
@@ -106,29 +135,38 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
passedByCourseCodes.set(code, new Set())
notCompletedByCourseCodes.set(code, new Set())
}
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(creditTypeCode)) {
if (CreditModel.passed({ credittypecode: creditTypeCode })) {
passedByCourseCodes.get(code)!.add(studentNumber)
}
if (creditTypeCode === CreditTypeCode.FAILED && !passedByCourseCodes.get(code)!.has(studentNumber)) {
if (creditTypeCode === CreditTypeCode.FAILED && !studentHasPassedCourse(studentNumber, code)) {
notCompletedByCourseCodes.get(code)!.add(studentNumber)
}
}
// If student has enrollments, but no attainment for a particular course, they have no credit info.
programmeCourses.forEach(courseCode => {
allEnrollments.get(courseCode)?.forEach(studentnumber => {
if (passedByCourseCodes.get(courseCode)?.has(studentnumber)) {
notCompletedByCourseCodes.get(courseCode)?.add(studentnumber)
}
})
})
// Add course details to courseMap if there are only enrollments
for (const { studentnumber: studentNumber, course_code: courseCode, course } of enrollmentsCourses) {
if (!courses.has(courseCode)) {
courses.set(courseCode, {
isStudyModule: course.is_study_module,
name: courseCodeToName.get(courseCode)!,
})
passedByCourseCodes.set(courseCode, new Set())
notCompletedByCourseCodes.set(courseCode, new Set())
}
return [...courses.entries()].map(([code, { isStudyModule, name }]) => ({
code,
name,
isStudyModule,
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
}))
if (!studentHasPassedCourse(studentNumber, courseCode)) {
notCompletedByCourseCodes.get(courseCode)!.add(studentNumber)
}
}
return [...courses.entries()].map(([code, { isStudyModule, name }]) => {
return {
code,
name,
isStudyModule,
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
}
})
} catch (error) {
logger.error(`getNotCompletedForProgrammeCourses failed ${error}`)
return []
@@ -95,7 +95,7 @@ export const getProgrammeCourseAggregates = async (params: {
WHERE
cr.course_code IN (:courseCodes)
AND cr.attainment_date BETWEEN :from AND :to
AND cr.credittypecode = :creditType
AND cr.credittypecode IN (:creditTypes)
)
SELECT
fc."courseCode",
@@ -176,7 +176,7 @@ export const getProgrammeCourseAggregates = async (params: {
courseCodes,
from,
to,
creditType: CreditTypeCode.PASSED,
creditTypes: [CreditTypeCode.PASSED, CreditTypeCode.APPROVED],
degreeExtentCodes,
otherUniversityExtentCodes,
separateStudyExtentCodes,
@@ -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)
}
}
@@ -35,7 +35,7 @@ export const getStudyProgrammeStatsForColorizedCoursesTable = async (studyProgra
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'state'],
where: {
course_code: { [Op.in]: courseCodes },
semestercode: { [Op.gte]: autumnSemester2017 },
semestercode: { [Op.gte]: autumnSemester2017 }, // It is ok to use enrollments before OK sisu-migration, ColorizedTableData doesn't need it
state: { [Op.in]: [EnrollmentState.ENROLLED, EnrollmentState.REJECTED] },
},
raw: true,
@@ -3,7 +3,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { indexOf, orderBy } from 'lodash-es'
import {
GraduationStatistics,
GraduationTimeCategories,
Name,
SemesterEnrollment,
StudyTrack,
@@ -118,7 +118,7 @@ export type GraduationTimes = {
y: number
amount: number
name: string | number
statistics: GraduationStatistics
statistics: GraduationTimeCategories
times: number[]
}>
goal: number
@@ -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 (
@@ -65,7 +65,7 @@ export const getStudyRightsInProgramme = async (
},
},
})
).map(studyRight => studyRight.toJSON())
).map(studyRight => studyRight.toJSON<SISStudyRight>())
}
export const getStudyTracksForProgramme = async (studyProgramme: string) => {
@@ -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 requestField ? (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 -14
View File
@@ -1,6 +1,4 @@
import os from 'os'
import * as winston from 'winston'
import { WinstonGelfTransporter } from 'winston-gelf-transporter'
import LokiTransport from 'winston-loki'
import { isProduction } from '../config'
@@ -11,18 +9,7 @@ transports.push(
new LokiTransport({
host: 'http://loki-svc.toska-lokki.svc.cluster.local:3100',
labels: { app: 'oodikone', environment: process.env.NODE_ENV ?? 'production' },
}),
new WinstonGelfTransporter({
handleExceptions: true,
host: 'svm-116.cs.helsinki.fi',
port: 9503,
protocol: 'udp',
hostName: os.hostname(),
additional: {
app: 'oodikone',
environment: 'production',
},
}) as winston.transport
})
)
const { colorize, combine, timestamp, printf } = winston.format
+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
}

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