Compare commits

...
629 Commits
Author SHA1 Message Date
joonas-a 2a89f37984 [Docker][Dev] fix permissions with node_modules................... 2026-07-16 16:32:20 +03:00
joonas-a 05a9c1f0d1 [Docker] madness. ubi9->ubi10. 2026-07-16 14:39:55 +03:00
Santeri Rajamäki 31bb53b885 [META] Update cli scripts 2026-07-16 12:41:36 +03:00
joonas-a f1120cc6ce [Meta][Frontend] allow root level package(s) from remote rather than npm
We use xlsx which is distributed by their own cdn rather than npm. Npm
v12 by defaults blocks this installation.
2026-07-16 11:27:46 +03:00
joonas-a 4754feb6af [Meta][Frontend] Vite v8, react plugin swc->oxc 2026-07-16 11:27:46 +03:00
Santeri Rajamäki 2ebfb44571 [Frontend][CloseToGraduation] Remove function pointers from memo 2026-07-15 17:09:40 +03:00
Santeri Rajamäki df71cc955c [Frontend][CloseToGraduation] Remove early exit from semesterEnrollment map 2026-07-15 17:08:44 +03:00
Santeri Rajamäki 373f870854 [Frontend][Export] Remove unneccesary array spreads from faculty students by year 2026-07-15 17:07:19 +03:00
Santeri Rajamäki 2ac369daa4 [Frontend] Cleanup semestersPresent call sites 2026-07-15 16:54:24 +03:00
Santeri Rajamäki 018a73e760 [Frontend] Cleanup semestersPresent format util 2026-07-15 16:53:03 +03:00
Santeri Rajamäki f43a4e4397 [Frontend] Convert semestersPresent helper to use formtting utils like the rest of the views 2026-07-15 16:52:20 +03:00
Santeri Rajamäki 958258ce01 [Dockerfile] Use --include and --omit correctly 2026-07-15 16:03:03 +03:00
Santeri Rajamäki e039c78f9e [OT] Detach header mapping 2026-07-15 14:50:39 +03:00
Santeri Rajamäki 6784b6e682 [OT] Rework SortIcons 2026-07-15 14:48:24 +03:00
Santeri Rajamäki cc85b5994a [Frontend][Redux] Drop TODO as not needed 2026-07-15 14:21:40 +03:00
Santeri Rajamäki d5af6977c5 [META] Remove deprecated language center switch 2026-07-15 13:19:51 +03:00
joonas-a dc0b5eb890 [React] enable StrictMode 2026-07-15 12:30:27 +03:00
joonas-a e47db461bb [Infobox][Programme overview] fix text formatting on combined programme(s) 2026-07-15 11:45:42 +03:00
joonas-a 429980e883 [Infobox][By starting year] mention "has recent attainment" column 2026-07-15 11:44:24 +03:00
joonas-a a8f4600875 [Graduation times] reword helper text 2026-07-15 11:33:15 +03:00
joonas-a 6cb063d717 [Frontend] inconsistent layout fixes 2026-07-15 11:30:21 +03:00
joonas-a a79cf865e4 [Langcenterview] make great again
Big UI tweaks.
2026-07-14 16:15:35 +03:00
joonas-a d8342371eb [Section] enhance
do not render jank when no need
2026-07-14 15:51:56 +03:00
joonas-a a2f9450eb5 [Overview][Lang center] fix atrocious layouts 2026-07-14 15:35:05 +03:00
joonas-a d4253141e6 [Programme overview] layout / padding / overlap tweaks 2026-07-14 14:21:28 +03:00
joonas-a 07dc603460 [Faculties] more UI tweaks and consistency 2026-07-14 12:38:27 +03:00
joonas-a 1877be265e [StyledTable] add nowrap as a flag 2026-07-14 12:34:21 +03:00
joonas-a a47c3c809d [Faculties][By starting year] table layout tweaks 2026-07-14 12:03:03 +03:00
joonas-a 7ce91ca38d [Faculties][Basic info] increase rendering performance 1000x 2026-07-14 10:43:33 +03:00
joonas-a 26cc77e68e [Banners] cleanup 2026-07-14 10:09:56 +03:00
joonas-a 183bdbfa45 [Frontend][Navigation] make BanMan accessible to admins 2026-07-13 16:49:38 +03:00
joonas-a 4bdbf4ad4d [Frontend] new admin view to manage banners (BanMan) 2026-07-13 16:49:38 +03:00
joonas-a 826dd15fd3 [BannerView] load + show existing banners at the top 2026-07-13 16:49:38 +03:00
joonas-a faf8fb5d01 [RTKapi] inject endpoints for banner queries/mutations 2026-07-13 16:49:38 +03:00
joonas-a 975959f1a5 [Backend] use banner routes + include banners in /login response 2026-07-13 16:49:38 +03:00
joonas-a 249b5ff94e [Backend] create routes/services for banners 2026-07-13 16:49:38 +03:00
joonas-a fdc860ffd7 [Kone] add model/migration for upcoming banners 2026-07-13 16:49:38 +03:00
joonas-a 15cd86cc47 [Theme] add "dark" (black) color 2026-07-13 16:49:38 +03:00
Santeri Rajamäki 3c5d76bc43 [UPDATER][WORKER] Strip initStartYearToSemesters 2026-07-13 15:41:10 +03:00
Santeri Rajamäki 6dba913e0c [UPDATER][WORKER] Cleanup initStartYearToSemesters 2026-07-13 15:38:52 +03:00
Santeri Rajamäki 3ea337e43e [Frontend][DegreeCourses] Fix. 2026-07-13 15:20:08 +03:00
Santeri Rajamäki 513b15fed5 [CLI] Reword option 2 2026-07-13 14:12:35 +03:00
Santeri Rajamäki 52d672b99b [Frontend][StudyProgramme] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 855121b5af [Frontend][Students] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 27b812f8cf [Frontend][CourseStats] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 3225fc1006 [Backend][Teachers] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki ea40c2a56b [Backend][StudyProgramme] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 342048619a [Backend][University] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 8c7c130672 [Updater] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 681263aac4 [Frontend][Faculty] Collapse if init 2026-07-13 13:26:45 +03:00
Aaro Koivukangas 686438992f [E2E][Filters] Search study track by name, not by index 2026-07-13 13:00:42 +03:00
Aaro Koivukangas 4dcccdfab3 [E2E][University] Use the config variable name 2026-07-13 11:52:28 +03:00
Santeri Rajamäki ef5985d290 [E2E] Add access right checking to yaml 2026-07-13 11:50:05 +03:00
Santeri Rajamäki 0bfe4bace5 [E2E][Routes] Forbid "Something broke" when user has access 2026-07-13 11:36:41 +03:00
Santeri Rajamäki a7f285c5cb [E2E][Routes] Add server error catcher 2026-07-13 11:36:41 +03:00
Santeri Rajamäki 324db4c837 [E2E] Add access checking 2026-07-13 11:36:41 +03:00
Aaro Koivukangas 150b86145d [E2E][University] Add hella-long timeout to university e2e tests 2026-07-13 10:50:44 +03:00
Aaro Koivukangas 18f61cdb90 [E2E][University] Add same kind of check for Average graduation times breakdown chart 2026-07-12 17:04:12 +03:00
Aaro Koivukangas f99b882c4a [GithubActions] Still cannot enable updater update action 2026-07-12 14:17:34 +03:00
Aaro Koivukangas d525bc8673 [GithubActions] Add shorter retention dates to artifact uploads to use less storage 2026-07-11 09:54:39 +03:00
Aaro Koivukangas 1afbd6f2b4 [E2E][University] Add check that data has loaded 2026-07-11 09:50:21 +03:00
Aaro Koivukangas e3110219b6 [E2E] Fix degree programme overview test numbers 2026-07-11 09:48:39 +03:00
Aaro Koivukangas fb16335c81 [LanguageCenter] Fix sorting study rights with null end dates 2026-07-11 09:32:33 +03:00
Aaro Koivukangas f3fb9bc6da [Lint] Lint fix 2026-07-10 21:27:08 +03:00
Aaro Koivukangas a153b20fae [GithubActions] Re-enable updater update action acually updating sis-db 2026-07-10 21:11:14 +03:00
Aaro Koivukangas 159e904792 [E2E] Fix language center test numbers (for the last time... I hope) 2026-07-10 21:10:44 +03:00
Aaro Koivukangas 2b5b10b38b [LanguageCenter] Make language center view show correct faculty for students 2026-07-10 21:10:22 +03:00
Aaro Koivukangas 92fa9c3d2a [E2E] Fix faculty test numbers 2026-07-10 13:38:24 +03:00
Aaro Koivukangas 5d8b3e76ab [E2E] Fix course statistic test numbers 2026-07-10 13:38:07 +03:00
Aaro Koivukangas aacb2b2749 [Faculty] Fix test data studytrackstats calculations 2026-07-10 13:13:56 +03:00
Aaro Koivukangas fbe6fd4315 [Updater][Worker] Update all students by default locally 2026-07-10 12:52:43 +03:00
joonas-a 25e31c308a [Faculties] fix colspan to account for new column 2026-07-10 12:50:53 +03:00
Aaro Koivukangas 6034d885a0 [DockerCompose] Change ci-jami to use test NODE_ENV (even though it is not supported, just changing away from development) 2026-07-10 10:25:14 +03:00
Aaro Koivukangas bd9cfb9a7e [GithubActions][E2E] Run e2e tests in firefox 2026-07-10 10:17:54 +03:00
Aaro Koivukangas 24eb3137e9 [GithubActions] Add backend service-tests to gh actions 2026-07-10 10:12:07 +03:00
Aaro Koivukangas 5829f5c434 [Tests][Backend] Move unit tests that require db out of unit tests 2026-07-10 10:11:32 +03:00
joonas-a 9260a7d290 [Student page] Additionally highlight module and degree programme completions when a study right is selected 2026-07-09 16:11:20 +03:00
joonas-a 3adcb48e15 [Backend][Student details] Fetch modules in addition to courses for studyplans 2026-07-09 16:11:20 +03:00
Aaro Koivukangas b494af6344 [E2E] Add Course population tests to Numbers match 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 6357960541 [ApiTests] Add api tests for Course population student logic 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 73994181c8 [Backend][CourseStatistics] Change CoursePopulation student calculation logic to match other course calculations 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 3bcab31d8e [Filters] Make Course-filter to respect includeSubstitutions 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 951948e26d [Dockerfile] Ensure devDependencies are downloaded in updater dev environment 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 06dc27e4fb [Vitest] Improve api test performance 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 35872d6d26 [Updater][SubstitutionGroups] Remove possibly empty substitution groups 2026-07-09 15:41:48 +03:00
Aaro Koivukangas b142ed7339 [DockerCompose] Fix jami node_env to the correct one 2026-07-09 15:41:48 +03:00
joonas-a 2ffcfe7466 [StudyTrackStats] Scale colspans to account for new column 2026-07-09 15:19:41 +03:00
joonas-a 2a6dadf3dc [Backend][StudyTrackStats] add "Has recent attainment" to response 2026-07-09 15:19:04 +03:00
joonas-a 06c4b4cfae [Backend][StudyRightsFinders] include more data with studyrights when "include credits" mode enabled 2026-07-09 15:08:30 +03:00
Aaro Koivukangas 065922564c [GithubActions] Fix typo in frontend release action 2026-07-08 09:25:33 +03:00
Aaro Koivukangas c7d0e39eeb [E2E] Test numbers fix (pls be the last...) 2026-07-08 09:10:49 +03:00
Aaro Koivukangas 25418f700b [E2E] Fix e2e test numbers 2026-07-07 18:02:54 +03:00
Aaro Koivukangas 2439fc4475 [GithubActions] Fix hanging api test 2026-07-07 17:41:04 +03:00
Aaro Koivukangas df4601f0bf [Scripts] Improve oodikone:ci script + add comment about nginx in ci compose 2026-07-07 17:15:15 +03:00
Aaro Koivukangas 80516dd6a8 [E2E][LanguageCenter] Revert previous edits 2026-07-07 17:14:07 +03:00
Aaro Koivukangas 9d22511895 [GithubActions] Disable updater update pushing image to toska hub 2026-07-07 16:02:18 +03:00
Aaro Koivukangas 2bec5a0de1 [E2E][LanguageCenter] Fix broken e2e test 2026-07-07 15:38:10 +03:00
Aaro Koivukangas 734442c43c [GithubActions] Add missing failure + cancelled checks 2026-07-07 15:37:53 +03:00
Aaro Koivukangas 6ae1a05f27 [GithubActions] Remove log-flood from api tests 2026-07-07 15:19:57 +03:00
Aaro Koivukangas f11f2f240c [Scripts+DockerCompose] Update scripts used to run tests 2026-07-07 14:32:10 +03:00
Aaro Koivukangas 86d7f74cb2 [E2E][LanguageCenter] Fix broken e2e test 2026-07-07 13:57:23 +03:00
Aaro Koivukangas ceb7a733d7 [E2E][LanguageCenter] Fix broken e2e tests 2026-07-07 12:37:01 +03:00
Aaro Koivukangas c0246550bb [E2E][Teachers] Fix broken test 2026-07-07 12:14:26 +03:00
Aaro Koivukangas cb21c675c2 [E2E][DegreeProgrammeOverview] Fix tests broken by updater update 2026-07-07 12:13:44 +03:00
Aaro Koivukangas 49da59655e [E2E][Students] Fix tests broken by updater update 2026-07-07 10:27:11 +03:00
Aaro Koivukangas 39d9d6f664 [GithubActions] Make dependencies more clean 2026-07-06 16:51:15 +03:00
Aaro Koivukangas e3bd0d1226 [ApiTests] Update tests to match newly updated data 2026-07-06 16:50:27 +03:00
Aaro Koivukangas 795cb50a1f [GithubActions] Remove prepurge and purge actions, they never did anything 2026-07-06 14:44:07 +03:00
Aaro Koivukangas ba75b0828f [DockerCompose] Add env variables to scheduler to trigger updater updates locally easier 2026-07-06 13:58:16 +03:00
Aaro Koivukangas 0a0225bc77 [Scripts] Add a script to get all logs, including db 2026-07-06 13:58:15 +03:00
Aaro Koivukangas f14028145b [GithubActions] Restore previously deleted flag from healthchecks 2026-07-06 13:38:07 +03:00
Aaro Koivukangas 3cd7f05280 [GithubActions] Add log dump to updater update in case of failure 2026-07-06 13:06:25 +03:00
Aaro Koivukangas bc5769e89e [GithubActions] Last fixes to dependency structure 2026-07-06 12:59:00 +03:00
Aaro Koivukangas 85365e2636 [GithubActions] Fix permission error on updater update 2026-07-06 12:54:26 +03:00
Aaro Koivukangas b4deae4343 [DockerCompose] Fix healthchecks to all work the same way 2026-07-06 12:54:09 +03:00
Aaro Koivukangas 669e1b6ab9 [GithubActions] Fix actions dependency structure even more 2026-07-06 12:53:41 +03:00
Aaro Koivukangas 35c11c3e20 [GithubActions] Update dependency structure 2026-07-06 10:40:45 +03:00
Aaro Koivukangas fc61414f4b [GithubActions] Change to use more simple if block 2026-07-06 10:39:07 +03:00
Aaro Koivukangas 130f38c171 [Updater][Worker] Add throws to all errors + add a proper wait when running in EXIT_AFTER_IMMEDIATES 2026-07-06 10:27:01 +03:00
Aaro Koivukangas 98d32cfafc [DockerCompose] Fix database healthcheck logs No role for "root" 2026-07-06 10:25:36 +03:00
Aaro Koivukangas 8c85333cd6 [GithubActions] Add updater update to test workflow 2026-07-06 10:24:40 +03:00
Aaro Koivukangas febef1b7b8 [GithubActions] Remove unnecessary steps from build action 2026-07-05 11:15:52 +03:00
Aaro Koivukangas c9465da33d [GithubActions] Fix backend+frontend push to quay 2026-07-05 11:02:17 +03:00
Aaro Koivukangas 3a85da4573 [GithubActions] Add more missing quotes from build 2026-07-04 16:11:13 +03:00
Aaro Koivukangas 57d0aa6870 [GithubActions] Add missing quotes to backend+frontend build action 2026-07-04 15:48:39 +03:00
Aaro Koivukangas f00855f1bf [GithubActions] Export buildah image and tag name from action 2026-07-04 15:27:18 +03:00
Aaro Koivukangas 2c2393c422 [GithubActions] Update external action version 2026-07-04 15:03:23 +03:00
Aaro Koivukangas 677c0f2b59 [GithubActions] Fix simple typo in frontend and backend build action 2026-07-04 15:03:02 +03:00
Aaro Koivukangas 7ae1a84bd5 [UnitTesting] Add placeholder unit tests for backend to stop actions from failing 2026-07-04 14:48:33 +03:00
Aaro Koivukangas 2463ffa771 [GithubActions] Add missig checkout 2026-07-04 14:46:11 +03:00
Aaro Koivukangas 2fff045861 [GithubActions] Remove unnecesary logs from pulling images 2026-07-04 14:45:19 +03:00
Aaro Koivukangas 02aa86ae47 [GithubActions] Remove image pull caching so that the correct version for dbs are always used 2026-07-04 14:39:04 +03:00
Aaro Koivukangas 304da32284 [GithubActions] Fix multiple action errors 2026-07-04 14:37:56 +03:00
Aaro Koivukangas 76071c9f00 [GithubActions] Fix tests waiting for backend to start inside docker-compose.ci.yml 2026-07-04 14:35:16 +03:00
Aaro Koivukangas 6d4aeced89 [GithubActions] Fix e2e test action path change 2026-07-04 14:02:18 +03:00
Aaro Koivukangas ec32982ada [GithubActions] Add shell variable to all run: action steps 2026-07-04 14:00:34 +03:00
Aaro Koivukangas e68bffe8ec [GithubActions] Frontend build typo fix 2026-07-04 13:54:25 +03:00
Aaro Koivukangas 20875c6184 [GithubActions] Fix action error and use correct release_version var name 2026-07-04 13:51:05 +03:00
Aaro Koivukangas 67e67323de [GithubActions] Fix one more typo 2026-07-04 13:47:32 +03:00
Aaro Koivukangas 5d6b6b71ab [GithubActions] Fix typo and add names to workflow steps 2026-07-04 13:46:32 +03:00
Aaro Koivukangas 438267d552 [GithubActions] Handle checkouts correctly 2026-07-04 13:41:26 +03:00
Aaro Koivukangas dc02443986 [GithubActions] Fix multiple typos and errors 2026-07-04 13:35:36 +03:00
Aaro Koivukangas e3e7468f14 [GithubActions] Change backend action to use correct runner 2026-07-04 13:29:52 +03:00
Aaro Koivukangas fb0b4d01c4 [GithubActions] Fix typos 2026-07-04 13:22:34 +03:00
Aaro Koivukangas f0ebd6de93 [GithubActions] Rework github actions to be more consistent 2026-07-04 13:19:15 +03:00
Aaro Koivukangas f734c0db38 [DockerCompose] Add healthchecks for all applicable containers 2026-07-04 10:41:26 +03:00
joonas-a d8d34a58b1 [BullMQ][Backend] Fix logger not logging
There is no need to have a try/catch or logger instance in the
processor. The errors are already propagated back to where we had
existing event listeners.
2026-07-03 19:39:14 +03:00
Aaro Koivukangas 20433e72b7 [ApiTests] Skip few tests, will be fixed later 2026-07-03 18:27:49 +03:00
joonas-a 63988f7d6a [Updater][StudyRightElements] new logic to combat corrupted "most recent" snapshots 2026-07-03 18:13:14 +03:00
Aaro Koivukangas b1433ee4a2 [ApiTests] Update tests to match new test data 2026-07-03 17:38:12 +03:00
Aaro Koivukangas f0082e5dd0 [ApiTests] Refactor flaky api test 2026-07-03 16:58:18 +03:00
Aaro Koivukangas f8d2097f24 [ApiTests] Move api tests to api dir 2026-07-03 16:31:48 +03:00
Aaro Koivukangas 0bedc21a57 [Scripts] Add silent testig mode for backend api tests 2026-07-03 16:15:21 +03:00
Aaro Koivukangas 8a119485de [CI] Change ci to use vitest for backend api tests 2026-07-03 16:06:33 +03:00
Aaro Koivukangas 2f94bfd474 [CI] Unify normal and ci compose.yamls to use same ports 2026-07-03 16:01:41 +03:00
Aaro Koivukangas 5082ec9deb [ApiTests] Migrated changelog tests 2026-07-03 15:51:20 +03:00
Aaro Koivukangas 11c75181c3 [ApiTests] Migrated programme course stats tests 2026-07-03 15:51:20 +03:00
Aaro Koivukangas 02935f7da8 [ApiTests] Migrated populationStudyProgramme tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 9bb43caa57 [ApiTests] Migrated populationStatisticsByStudentNumbers tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 9995e61939 [ApiTests] Migrated populationStatisticsByCourse tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 7fbeb34e6d [ApiTests] Migrated populationstatsitics tests 2026-07-03 15:51:18 +03:00
Aaro Koivukangas eda0069e30 [ApiTests] Change to use vitest 2026-07-03 15:51:18 +03:00
joonas-a dcf7ef3e23 [Backend][Graduation times] No longer send grad time arrays to client as they are not needed and pollute the response 2026-07-03 15:42:17 +03:00
joonas-a 55474d5de9 Remove empty file 2026-07-03 15:27:46 +03:00
joonas-a eb843ff43d [E2E][Programme overview] fix tests 2026-07-03 15:02:14 +03:00
joonas-a de32410cc7 [E2E][University] fix tests 2026-07-03 14:18:53 +03:00
Aaro Koivukangas fd8c67384e [CI] Remove watch from logs:ci command, tests hang 2026-07-03 13:48:19 +03:00
Aaro Koivukangas 931b6632f2 [ApiTests] Remove unused db connection 2026-07-03 13:46:30 +03:00
Aaro Koivukangas 5769d7b996 [ApiTests] Add a possibility to execute tests locally 2026-07-03 13:46:30 +03:00
joonas-a f81e8e59da [Scheduler] remove duplicate log entry
The exact same thing is logged two lines in a row
2026-07-03 12:40:05 +03:00
Aaro Koivukangas 85830efce5 [CI] Remove bloat from logging 2026-07-03 12:20:21 +03:00
Aaro Koivukangas 55f48c4b77 [E2E] Add admin privileges to one test 2026-07-03 10:57:50 +03:00
Aaro Koivukangas 119073a87a [E2E] Add e2e test to check if numbers match between Programme courses and Course stats 2026-07-02 19:22:52 +03:00
Aaro Koivukangas 5863cc90be [ApiTests] Add Programme course tests and make numbers match to Course stats 2026-07-02 19:22:51 +03:00
Aaro Koivukangas b794d9d778 [CourseStats] Improve tests + Fix Enrolled no grade calculations 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 8d79a68a81 [ApiTests] By default silence logging inside tests 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 5ca04fea1a [Faculties] Fix type name typo 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 369ff207cb [Dockerignore] Change to toska-standard .dockerignore files 2026-07-02 19:22:50 +03:00
Aaro Koivukangas a863aa798c [CI] Trigger updater workflows on pushes, not only on releases 2026-07-02 19:20:08 +03:00
Aaro KoivukangasandAaro Koivukangas 7c15472c17 [ApiTests] Add more thorough testing for /courseyearlystats 2026-07-02 19:20:07 +03:00
joonas-a 2a9509ed90 [Charts][Graduations] Improve UI 2026-07-02 15:43:24 +03:00
joonas-a 237d47c22e [University][Graduations] add averages 2026-07-02 15:27:05 +03:00
joonas-a c1340daf3d [Faculties][Graduations] add averages 2026-07-02 15:25:15 +03:00
joonas-a f899572eaf [Programme overview][Graduations] add averages to charts 2026-07-02 15:18:02 +03:00
joonas-a c16d18b699 [Degree programme graduations] Add "average" to respose and make typings consistent with faculty/uni views 2026-07-02 13:22:59 +03:00
joonas-a 5f31e03ae1 [Updater][StudyRightElements] Consider phase graduations earlier in the mankeli
Now it does not grant phase 2 elements before phase 1 graduation, and
does not allow modifying elements if there is a graduation already
2026-07-02 12:44:54 +03:00
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
Santeri Rajamäki 1c75204854 [Filters] Map "Koepisteet" to "Valintakoe" as intended 2026-04-14 10:46:14 +03:00
Santeri Rajamäki 80bba6e0fc [Backend] Upgrade from express v4 to v5 2026-04-13 17:27:45 +03:00
Santeri Rajamäki 47b0dabe75 [OT] Remove pre-emptive null returns
OT pagination now supports changing data models which makes these early returns redundant
2026-04-13 15:52:50 +03:00
Santeri Rajamäki da45c8c150 [Backend] Cull user route types 2026-04-13 15:34:57 +03:00
joonas-a a57f2f01ca [Charts][Credit accmltn] hops filter functionality 2026-04-13 14:40:44 +03:00
Aaro Koivukangas 8fd7383217 [Staging] Remove unnecessary staging iamgroups 2026-04-13 12:00:04 +03:00
Santeri Rajamäki baa6d87c2c [Filters] Fix student number filter allowlist 2026-04-13 11:48:13 +03:00
Santeri Rajamäki 5ae4c8c7a0 [OT] Change rowsPerPage provider 2026-04-10 14:15:51 +03:00
Santeri Rajamäki cbb7e82d23 [OT] Apply ellipsis to cells table-wide 2026-04-10 14:09:07 +03:00
Aaro KoivukangasandSanteri Rajamäki 2768e95920 [Class stats][Courses of class] Show Master-level modules by fixing incorrect nullish coalesing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5b7d641ad1 [E2E] More page title e2e test fixing 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 37a71d7580 [E2E] Fix changed titles in tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki aaa7b5de27 [E2E] Fix tests 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 40bcbc59dc [Class stats][Courses of class] Change empty fields to be 0, not null 2026-04-10 13:39:10 +03:00
Aaro KoivukangasandSanteri Rajamäki 5e46ce0cb0 [Class stats] Improve page title 2026-04-10 13:39:10 +03:00
Santeri Rajamäki 517c08091e [META] Update @types/pg 2026-04-10 13:39:09 +03:00
Santeri Rajamäki e25dfb2a36 [META] Update lodash-es 2026-04-10 13:38:24 +03:00
Santeri Rajamäki aa4e0abb44 [META] Update cypress 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 3e5dc9a9f0 [META] Update globals 2026-04-10 13:37:13 +03:00
Santeri Rajamäki afd9541151 [META] Update eslint-plugin-import 2026-04-10 13:37:13 +03:00
Santeri Rajamäki a5ab3b972c [META] Update stylelint-config-standard 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 4f3350e4f9 [META] Update stylelint 2026-04-10 13:37:13 +03:00
Santeri Rajamäki 2b8e1ed98f [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-10 13:37:13 +03:00
joonas-a 1b4bdff37e [Charts][poc] credit accumulation 2026-04-02 16:14:04 +03:00
Santeri Rajamäki 3f311dee69 [Frontend] Remove legacy RTKApi middleware 2026-04-01 13:57:39 +03:00
Santeri Rajamäki eb6b683296 [Frontend] Convert apiConnections to typescript 2026-04-01 13:56:02 +03:00
Aaro Koivukangas b2153ec3dd [ClassStats][Courses] Add better handling for null substitution_groups (only in test-data) 2026-04-01 13:34:40 +03:00
Aaro Koivukangas 8949e7baf0 [Class stats][Courses] Better substitution group type handling 2026-03-31 15:18:47 +03:00
joonas-a 1fa10f7812 [Scripts] fix incorrect container name 2026-03-31 14:47:33 +03:00
Aaro Koivukangas fbe5db8a43 [E2E] Fix lint errors and e2e tests 2026-03-31 14:42:57 +03:00
Aaro Koivukangas d4c7a4014b [Class stats][Courses] Handle substitution groups correctly + fix
include substitutions button
2026-03-31 14:42:57 +03:00
Santeri Rajamäki 1177c2f848 [OT] Use 0th index in pagination when table data changes 2026-03-31 14:40:53 +03:00
Santeri Rajamäki 0f8d72fd1d [Frontend] Remove manual pagination from OT implementations 2026-03-31 14:36:45 +03:00
Santeri Rajamäki 47e3e2e26e [OT] Fix pagination 2026-03-31 14:36:05 +03:00
Santeri Rajamäki a312a14944 [Frontend] Disallow prop forwarding for all internal StyledTable attributes 2026-03-31 12:01:33 +03:00
joonas-a e3ec33f8dc [Course stat tables] try to add 0s 2026-03-30 16:10:22 +03:00
joonas-a 9c55d3b2ab [Highcharts] fix median time bar chart threshold lines 2026-03-30 16:07:51 +03:00
joonas-a 17ce83660c [e2e] programme overview graduation time fixes 2026-03-30 15:40:59 +03:00
joonas-a 22d3877b8e [Faculty][Graduation times] update graduation time targets in highcharts 2026-03-30 14:58:20 +03:00
joonas-a c60bee34fd [Backend][Faculty] update graduation time targets 2026-03-30 14:51:32 +03:00
joonas-a 15240050b7 [Faculty] graduation time infobox update 2026-03-30 14:31:18 +03:00
joonas-a 7da09c8b02 [Programme overview] update graduation time infoboxes 2026-03-30 14:31:18 +03:00
joonas-a cfb2d4d5c0 [Frontend][Graduation times] replace months with semesters 2026-03-30 14:31:18 +03:00
joonas-a 39b594d795 [Backend] Use semesters with graduation times 2026-03-30 14:31:18 +03:00
Santeri Rajamäki ff5bce1713 [E2E] Scroll coursestatistics data table into view 2026-03-30 10:26:56 +03:00
Santeri Rajamäki 8e288f3b26 [Frontend] Remove columnOrder from course statistics tables 2026-03-30 10:07:06 +03:00
Santeri Rajamäki 4134c8b64b [Frontend] Use manualPagination on course stats tables 2026-03-27 15:47:17 +02:00
Santeri Rajamäki ddc1a22492 [Frontend] Cleanup 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 8ff1cb54dc [Frontend] Convert AttemptsTable to OT 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 777de520f9 [Frontend] Convert StudentsTable to OT 2026-03-27 15:47:17 +02:00
joonas-a a9e325fe72 [Updater] Add transfer info to study rights 2026-03-26 17:41:17 +02:00
joonas-a befc3db3a8 [Filters][Studentnumber] Fix blacklist blacklisting all students always 2026-03-26 17:41:17 +02:00
Santeri Rajamäki e1b8ebd17f [Frontend] Readd EquivalenceGroupChip 2026-03-26 13:13:05 +02:00
Santeri Rajamäki 883129bee0 [Frontend] Unify course page secondary chip with course search table chips 2026-03-26 12:13:51 +02:00
Santeri Rajamäki 18eb04beeb [Frontend] Rework course search table and equivalence group chips 2026-03-26 12:12:19 +02:00
Santeri Rajamäki eb12d7e5ec [OT] Add cell width under size attribute 2026-03-26 12:08:56 +02:00
Santeri Rajamäki 26ca0a3bc1 [META] Remove redundant reset_jami_data script from setup script 2026-03-25 17:34:47 +02:00
Santeri Rajamäki b79c97780b [META] Update JAMI url in docker-compose 2026-03-25 17:29:04 +02:00
Santeri Rajamäki 0cd3bf28ff [Frontend] Replace MRT with StyledTable in StudyProgrammeTable 2026-03-25 16:20:55 +02:00
Santeri Rajamäki 20b0ba5a30 [Frontend] Enhance StyledTable 2026-03-25 16:19:12 +02:00
Aaro Koivukangas 67001a9db1 [Updater] Remove limit of 1000 entries from update-meta in dev environment 2026-03-23 14:15:51 +02:00
Aaro Koivukangas 8c268395b0 [E2E] Fix broken test 2026-03-20 16:32:41 +02:00
Aaro Koivukangas 235d2b03b2 [CourseStat][Search] Use substitution_groups when searching for courses 2026-03-20 15:53:11 +02:00
Aaro Koivukangas cbe20f1ad3 [Updater] Handle substitution_groups with null/undefined codes better 2026-03-20 15:53:11 +02:00
Matti Luukkainen 47211dcf89 [faculty/study programme] moar compact credits produced table (fits in screen) 2026-03-20 12:48:41 +02:00
joonas-a 26c6c653e5 [Custom pop] Enable hetu filter 2026-03-19 15:52:56 +02:00
Aaro Koivukangas 13bdf698cf [CI] Skip very flaky test 2026-03-19 15:29:25 +02:00
Aaro Koivukangas fd6a65925a [CI][DegreeProgrammes] Fix hetu-e2e tests once again 2026-03-19 15:03:14 +02:00
Aaro Koivukangas 435d2920fb [CI][DegreeProgramme] More e2e test fixes 2026-03-19 14:47:27 +02:00
Aaro Koivukangas 4ae93e0b7e [Updater] Add latest executed migration to starting logs 2026-03-19 14:45:18 +02:00
Aaro Koivukangas dc5200da2f [CI] Fix broken hetu-tests 2026-03-19 14:27:20 +02:00
joonas-a 373c8f9de8 [CI] remove debug print 2026-03-19 13:34:00 +02:00
joonas-a 34cc022830 [Custom pop][General tab] Fetch hops credits from acual hops
Also integrated CI test
2026-03-19 13:32:03 +02:00
Aaro Koivukangas ab311ddc66 [CI] Don't trigger all workflows if lint fails 2026-03-19 13:27:50 +02:00
Aaro Koivukangas 833fdad3bb [CI] Fix lint errors 2026-03-19 13:21:52 +02:00
joonas-a 4b634e032d [Eslint] Disable root imports
These cause silent backend crashes which are hard to debug
2026-03-19 13:18:41 +02:00
Aaro Koivukangas a16fd13413 [CI] Fix broken import in mailServices 2026-03-19 13:11:46 +02:00
Aaro Koivukangas 58913b4684 [Docker] Make .dockerignore work by moving it to context root 2026-03-19 12:58:55 +02:00
joonas-a 05c8993ba3 [Backend] Fix path import 2026-03-19 12:52:49 +02:00
joonas-a d3090ae725 [Ooditable] Highlight hovered row 2026-03-19 12:18:49 +02:00
joonas-a c870061fec [Excel exports] Show acual values in preview
No more empty strings
2026-03-19 12:00:49 +02:00
Aaro Koivukangas ec43699c3c [CI] Add backend and frontend logs to e2e tests 2026-03-19 11:57:56 +02:00
joonas-a 1b1e380a13 [Frontend] make lowercasing consistent 2026-03-17 17:21:14 +02:00
joonas-a 5c13b5c0ab [Programme overview][Faculties] hetu/ton credits produced 2026-03-17 17:18:02 +02:00
joonas-a f35fd41bfb [Backend] Include hetu/ton split in provider credits computation 2026-03-17 17:15:31 +02:00
joonas-a 6548cdd42b [Course population] Enable HETU filter 2026-03-17 15:16:30 +02:00
joonas-a 49b0da760d [Filters] New filter: Personal identity code 2026-03-17 15:16:30 +02:00
joonas-a 34e9cf5667 [Backend] Add "has hetu" to students 2026-03-17 15:16:30 +02:00
Aaro Koivukangas 288d32c591 [Backend][MailService] Backend doesn't crash if it tries to send emails in non-production environments 2026-03-17 13:16:04 +02:00
joonas-a 5001d55843 [Programme courses] Display hetuttomat/hetulliset 2026-03-17 12:25:41 +02:00
joonas-a eaedfe99db [Backend][Programme courses] Split open uni students (hetuton/hetu on) 2026-03-17 12:25:41 +02:00
Aaro Koivukangas 6cf375936a [Meta] Lint fixes 2026-03-17 12:02:12 +02:00
Aaro Koivukangas 8bc5c3ea65 [Updater][Substitutions] Add og. substitutions back and move the new substitutions to substitution_groups 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 3e4e749625 [CoursesSearch] Rebuild substitution combination 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 114af7522d [Backend] Try apply fix for courseFinder 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas c0b29d69c7 [Updater] Fix substitution .map() for modules 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas fb541ffa3a [Updater] Build course unit substitutions as substitution groups/arrays 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 0ed4ce7bb4 [Updater] Clarify updateCourses function 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 87e1e332a8 [Frontend] Convert sentry util to typescript 2026-03-17 11:49:20 +02:00
joonas-a 914440cef9 [Programme courses] Enable stripes in table 2026-03-17 10:52:06 +02:00
Aaro Koivukangas 901a4de21c [Updater] Allow updater workflow to trigger 2026-03-12 13:35:45 +02:00
Aaro Koivukangas 2bc28c7dd3 [Student] Convert BachelorHonors to ts 2026-03-12 13:00:57 +02:00
Aaro Koivukangas 2dd43b1ccd [StudyGuidanceGroups] Typo fix 2026-03-12 10:51:59 +02:00
Aaro Koivukangas 3fc1bc161b [Models] Add substitution_groups to course table in sis-db 2026-03-11 18:54:38 +02:00
Aaro Koivukangas 1edffd437e [ClassStats][CoursesOfPopulation] Remove module stats from Show all view 2026-03-11 17:14:49 +02:00
Aaro Koivukangas 4ed5e610bf [StudyGuidanceGroups] Change close-to-graduation link once more 2026-03-11 13:09:54 +02:00
Aaro Koivukangas 90ab1e80f7 [ClassStats][CoursesOfPopulation] Remove unecessary fields from module stats 2026-03-11 12:31:54 +02:00
Santeri Rajamäki 5b9f3c0184 [Meta] Update docker CI actions 2026-03-10 13:35:17 +02:00
Santeri Rajamäki 3f81fff6aa [Meta] Update swc plugin for vite 2026-03-10 13:32:17 +02:00
Santeri Rajamäki 5058a54099 [Meta] Update Vite for frontend 2026-03-10 13:27:10 +02:00
Santeri Rajamäki c5fb781f50 [Meta] Remove Vite from backend 2026-03-10 13:26:03 +02:00
Santeri Rajamäki 6c45c5cd76 [Meta] Upgrade Redux toolkit
This upgrade should bring noticeable improvments to frontend mankelointi times
2026-03-10 13:11:43 +02:00
Santeri Rajamäki b7e3df2e6b [Meta] Update sentry packages 2026-03-10 12:58:32 +02:00
Santeri Rajamäki 35ce0388eb [Meta] Update sequelize
Patch for CVE in JSON where parsing
2026-03-10 12:31:04 +02:00
Santeri Rajamäki 5252f3b816 [Meta] Update node-postgres and add type package for redundancy 2026-03-10 12:31:04 +02:00
Aaro Koivukangas 48f108c41d [StudyGuidanceGroup] Move filtering to the correct component 2026-03-10 12:16:07 +02:00
Santeri Rajamäki bade936f3c [Meta] Upgrade actions checkout, upload-artifact and download-artifact 2026-03-10 12:00:50 +02:00
Aaro Koivukangas c17721bd54 [StudyGuidanceGroup] Remove duplicate entries from Associated programme selector 2026-03-10 11:53:45 +02:00
Santeri Rajamäki a2e9064376 [Frontend] Move primitive functions to primitive file 2026-03-10 11:33:04 +02:00
Aaro Koivukangas 4b92ce4bd9 [StudyGuidanceGroup] Add better description to a link to close to graduation 2026-03-10 11:05:01 +02:00
Aaro Koivukangas f2348db941 [StudyGuidanceGroup] Fix empty table and add courses <-> modules switch to the sgg population view 2026-03-06 15:14:42 +02:00
Aaro Koivukangas b828904c3d [StudyGuidanceGroup] Migrate SGG population courses to ts 2026-03-06 14:49:14 +02:00
Aaro Koivukangas ed82a26e45 [Meta] Update remaining useGetProgrammesQuery users 2026-03-06 11:24:17 +02:00
Aaro Koivukangas b87ee36ebf [Students] Fix style 2026-03-05 17:10:15 +02:00
Aaro Koivukangas 7695315843 [Meta] keyBy is now used, letsgoo! 2026-03-05 16:31:09 +02:00
Aaro Koivukangas 0a292057cb [StudyGuidanceGroups] Add all degree programmes as options when selecting associated programmes for SGG 2026-03-05 16:24:12 +02:00
Aaro Koivukangas 0f5c672f5d [StudyGuidanceGroups] Change getCombinedProgrammeName to use switch case 2026-03-04 17:42:17 +02:00
joonas-a 8ec509aeed [General tab] before starting -> prior to x 2026-03-03 20:57:14 +02:00
joonas-a 7f4eab62a7 [General tab] Rephrase start date col 2026-03-03 20:10:35 +02:00
Aaro Koivukangas 6d82475696 [StudyGuidance] Correctly set initial year when adding starting year to group 2026-03-03 11:36:31 +02:00
Santeri RajamäkiandAaro Koivukangas bdcdd43230 [META] Remove toska sub- and prefixes from function names 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas 80e91d7bfb [Chore] Remove unused modules re-emerged after rebase 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas b15a56b177 [Frontend] Remove unused modules 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas c2684a4e8e [META] Remove apparent service provider specific code 2026-02-27 16:36:24 +02:00
Aaro Koivukangas 4ed8b3d2e9 [LanguageCenter] Add a course name/code filter 2026-02-27 14:34:59 +02:00
Santeri Rajamäki d6c55ed4f9 [Filters] Fix courses filter .courses being read-only 2026-02-27 13:58:13 +02:00
Aaro Koivukangas 97804b645e [ClassStats][CoursesOfClass] Fix filter buttons not working 2026-02-26 14:31:45 +02:00
Aaro Koivukangas 6b1c3e358a [CloseToGraduation] Add placeholders for text filters 2026-02-25 14:47:54 +02:00
Aaro Koivukangas 1935e36a3a [Meta] Fix graduated count in ProgressBars 2026-02-25 13:49:26 +02:00
Aaro Koivukangas 15d0acae13 [ClassStats][ProgressTab] Remove accidental text from link 2026-02-25 13:02:53 +02:00
Aaro Koivukangas ca31e0ed31 [ClassStats][ProgressTab] Add link to create progress criteria if it doesn't exist 2026-02-25 12:57:53 +02:00
joonas-a ab4c7c9ec3 [Cleanup] Replace HOPS -> studyplan and fix typo 2026-02-24 18:29:18 +02:00
joonas-a 6cd3e1bf4f [General tab] Enable study time months col for bsc programmes 2026-02-24 18:28:18 +02:00
joonas-a f836b3dacd [General tab] Better column grouping and ordering 2026-02-24 17:54:00 +02:00
joonas-a 066e1267f1 [Custom pop General tab] Add new cols if programme explicitly set 2026-02-24 17:11:17 +02:00
joonas-a eb29a4d261 [SGG General tab] Show new cols if programme set 2026-02-24 17:05:04 +02:00
joonas-a 85fc25eec7 [General tab] New cols: credits and courses attained before starting in programme
* that are in study plan for current programme
2026-02-24 16:57:39 +02:00
Santeri Rajamäki 27d8d73e3d [Frontend] Move color lists to theme palette 2026-02-24 11:08:31 +02:00
Aaro Koivukangas 4df339fa25 [PopulationStatistics][Courses of class] Rename 'Show all courses or modules' 2026-02-24 10:48:31 +02:00
Aaro Koivukangas 0b2349d1c1 [ClassStats][Progress] Fix missing course names/codes 2026-02-20 13:35:48 +02:00
Aaro Koivukangas 55f18a4cef [ClassStats][Progress] Table filtering works on all columns 2026-02-20 13:35:48 +02:00
Aaro Koivukangas 022efc22bd [ClassStats][Progress] Implement types to ProgressTable 2026-02-20 13:35:48 +02:00
Aaro Koivukangas a02e4adfa7 [ClassStats][Progress] Convert Progress tab to typescript 2026-02-20 13:35:48 +02:00
joonas-a 92db3af85f [General tab] remove excess Date casting 2026-02-19 17:07:21 +02:00
joonas-a e112bd3482 [General tab] New col: Study time in months for masters programmes 2026-02-19 16:44:16 +02:00
590 changed files with 22884 additions and 14441 deletions
+41
View File
@@ -0,0 +1,41 @@
name: 'Build backend'
description: 'Build backend in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build backend image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-backend
tags: ${{ inputs.environment }} ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
@@ -0,0 +1,50 @@
name: 'Build frontend'
description: 'Build frontend in staging or production versions'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
sentry_dns:
required: false
description: 'Sentry url to send error logs to (only production, enforced)'
default: ''
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- name: Get current time
id: get_current_time
shell: bash
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: ${{ inputs.environment }} ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_DSN=${{ case(inputs.environment == 'production', inputs.sentry_dns, '') }}
@@ -0,0 +1,40 @@
name: 'Build updater scheduler'
description: 'Build updater scheduler in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build scheduler image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: updater-scheduler
tags: ${{ inputs.environment }} ${{ github.sha }}
context: updater/sis-updater-scheduler
containerfiles: |
updater/sis-updater-scheduler/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
+40
View File
@@ -0,0 +1,40 @@
name: 'Build updater worker'
description: 'Build updater worker in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build worker image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: updater-worker
tags: ${{ inputs.environment }} ${{ github.sha }}
context: updater/sis-updater-worker
containerfiles: |
updater/sis-updater-worker/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
+37
View File
@@ -0,0 +1,37 @@
name: 'Run api tests'
description: 'Runs api tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run api-tests
shell: bash
run: npm run test:api --prefix ./services/backend
@@ -0,0 +1,31 @@
name: 'Run component tests'
description: 'Runs frontend component tests'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/frontend
- name: Install Playwright Browsers
shell: bash
run: npm exec -- playwright install --with-deps
working-directory: ./services/frontend
- name: Run Playwright tests
shell: bash
run: npm run test:component --prefix ./services/frontend
- uses: actions/upload-artifact@v4
if: |
!cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
@@ -19,7 +19,7 @@ runs:
using: composite
steps:
- name: 'Login to toska Docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
@@ -32,19 +32,20 @@ runs:
- name: 'Run cypress'
uses: cypress-io/github-action@v7
with:
browser: chrome
browser: firefox
spec: ${{ inputs.spec }}
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
path: ./cypress/videos/*
retention-days: 7
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml logs
run: npm run logs:ci && sleep 15
+37
View File
@@ -0,0 +1,37 @@
name: 'Run service tests'
description: 'Runs service tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run service-tests
shell: bash
run: npm run test:services --prefix ./services/backend
+22
View File
@@ -0,0 +1,22 @@
name: 'Run unit tests'
description: 'Runs unit tests in backend and frontend'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/backend && npm ci --prefix ./services/frontend
- name: Run frontend unit tests
shell: bash
run: npm run test:unit --prefix ./services/frontend
- name: Run backend unit tests
shell: bash
run: npm run test:unit --prefix ./services/backend
+19 -36
View File
@@ -5,35 +5,23 @@ on:
types: [published]
jobs:
release_frontend:
name: 'Build and release frontend'
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: 'Get current time'
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build frontend image
- name: 'Build backend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/backend
with:
image: oodikone-frontend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
SENTRY_DSN=${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }}
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -44,29 +32,24 @@ jobs:
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_backend:
name: 'Build and release backend'
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build backend image
- name: 'Build frontend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/frontend
with:
image: oodikone-backend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
sentry_dns: ${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }} # Used only in production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -85,7 +68,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -119,7 +102,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -157,7 +140,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -178,7 +161,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+35 -32
View File
@@ -5,57 +5,60 @@ on:
branches:
- master
paths:
- '.github/actions/**'
- '.github/workflows/pull_request.yaml'
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'docker-compose.ci.yml'
- 'package*.json'
jobs:
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_api:
test_unit:
name: 'Run unit tests'
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@v5
- uses: actions/checkout@v6
- uses: ./.github/actions/test/unit
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
test_api:
name: 'Test backend api'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: Start oodikone detached
run: npm run oodikone:ci:setup
- name: Run api-tests
run: npm run oodikone:ci:api
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/test/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,19 +81,19 @@ 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 }}
- name: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Run tests'
uses: ./.github/actions/run-tests
uses: ./.github/actions/test/e2e
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
+75 -43
View File
@@ -8,13 +8,13 @@ on:
- '.github/actions/**'
- '.github/workflows/staging.yaml'
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml'
- 'package*.json'
jobs:
test:
@@ -23,31 +23,20 @@ jobs:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
release_frontend:
name: 'Build and release frontend'
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
- name: 'Build backend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/backend
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
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -58,27 +47,68 @@ jobs:
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_backend:
name: 'Build and release backend'
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build backend image
- name: 'Build frontend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/frontend
with:
image: oodikone-backend
tags: staging ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
BASE_PATH=/oodikone
GIT_SHA=${{ github.sha }}
STAGING=true
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_worker:
name: 'Build and release updater worker'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: 'Build worker'
id: build-image
uses: ./.github/actions/build/worker
with:
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_scheduler:
name: 'Build and release updater scheduler'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: 'Build scheduler'
id: build-image
uses: ./.github/actions/build/scheduler
with:
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -92,14 +122,15 @@ 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()
- release_updater_worker
- release_updater_scheduler
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -111,14 +142,15 @@ 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()
- release_updater_worker
- release_updater_scheduler
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+201 -153
View File
@@ -16,7 +16,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -25,49 +25,25 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build image and export it'
uses: docker/build-push-action@v6
- uses: actions/checkout@v6
- name: Build backend
uses: ./.github/actions/build/backend
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=staging
context: services
file: services/backend/Dockerfile
push: false
tags: oodikone-backend:staging
outputs: type=docker,dest=/tmp/backend.tar
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
with:
name: backend-image
path: /tmp/backend.tar
environment: 'staging'
release_version: ${{ github.sha }}
build_frontend:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- 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: actions/checkout@v6
- name: Build frontend
uses: ./.github/actions/build/frontend
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
environment: 'staging'
release_version: ${{ github.sha }}
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
@@ -75,11 +51,12 @@ jobs:
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@v5
- uses: actions/checkout@v6
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
uses: dorny/paths-filter@v4
with:
filters: |
kone_db_migrations:
@@ -94,184 +71,231 @@ jobs:
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
needs: check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.sis_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
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
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: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.kone_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
- lint_packages
steps:
- uses: actions/checkout@v5
- name: 'Download backend image'
uses: actions/download-artifact@v7
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
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
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
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: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.user_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- check_migration_files
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
- lint_packages
steps:
- uses: actions/checkout@v5
- 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@v7
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
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
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
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'
# === Update sis-db with updater ===
check_updater_changed:
name: 'Check if updater has been updated'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
outputs:
has_updater_changed: ${{ steps.check_updater_changed.outputs.has_updater_changed }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: check_updater_changed
with:
filters: |
has_updater_changed:
- 'updater/**'
- '.github/workflows/test.yaml'
- '.github/workflows/staging.yaml'
- 'docker-compose.ci.yml'
run_updater_updates:
name: 'Run updater tests and updates'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure() &&
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
needs.check_migration_files.outputs.kone_db_migrations == 'true' ||
needs.check_migration_files.outputs.user_db_migrations == 'true' ||
needs.check_migration_files.outputs.sis_db_migrations == 'true')
needs:
- check_migration_files
- check_updater_changed
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, pull rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: Start worker containers
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --quiet-pull --detach --wait
- 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: Run hourly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: Run weekly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: weekly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v6
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
# - name: Push newly build db to toska hub
# if: success()
# run: ./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
- name: 'Print logs on failure'
- name: Dump logs on failure
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
run: npm run logs:ci
# === Backend api-tests ===
test_api:
name: 'Run backend api tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- test_service_exit_statuses
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
- run_updater_updates
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install node-24 for cypress
uses: actions/setup-node@v6
- uses: actions/checkout@v6
- name: Run api tests
uses: ./.github/actions/test/api
with:
node-version: 24
username: ${{ secrets.username }}
password: ${{ secrets.password }}
#
# === Backend service-tests ===
test_services:
name: 'Run backend service-tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
strategy:
fail-fast: false
- name: 'Download Docker images'
uses: actions/download-artifact@v7
steps:
- uses: actions/checkout@v6
- name: Run service-tests
uses: ./.github/actions/test/services
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: Start oodikone (without frontend and backend)
run: npm run oodikone:ci:setup
# === Unit testing ===
test_unit:
name: 'Run unit tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
- name: Run api-tests
run: npm run oodikone:ci:api
steps:
- uses: actions/checkout@v6
- name: Run unit tests (frontend and backend)
uses: ./.github/actions/test/unit
# === Frontend component testing ===
test_components:
name: 'Test frontend components'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
steps:
- uses: actions/checkout@v6
- name: Run frontend component tests
uses: ./.github/actions/test/component
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- test_service_exit_statuses
- test_api
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
- run_updater_updates
strategy:
fail-fast: false
matrix:
@@ -296,39 +320,63 @@ 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: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
- name: 'Test access rights'
spec: cypress/e2e/Routes.js
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v7
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Run tests'
uses: ./.github/actions/run-tests
uses: ./.github/actions/test/e2e
with:
username: ${{ secrets.username }}
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: |
!cancelled() && !failure()
needs:
- run_updater_updates
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, build rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
- 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: npm run logs:ci && sleep 15
+43 -43
View File
@@ -5,18 +5,18 @@ on:
types: [published]
paths:
- '.github/workflows/updater.yaml'
- 'updater/sis-updater-worker/**'
- 'updater/sis-updater-scheduler/**'
- 'updater/**'
jobs:
lint_scheduler_and_worker:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
@@ -25,11 +25,12 @@ jobs:
build_worker:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-worker/
push: false
@@ -37,20 +38,23 @@ jobs:
tags: toska/sis-updater-worker:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: updater-worker-image
path: /tmp/worker.tar
retention-days: 7
build_scheduler:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-scheduler/
push: false
@@ -58,11 +62,13 @@ jobs:
tags: toska/sis-updater-scheduler:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
retention-days: 7
test_updater:
name: 'Test Updater'
@@ -71,42 +77,50 @@ jobs:
- lint_scheduler_and_worker
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
- name: 'Load worker image'
run: docker load --input worker.tar
- name: 'Load scheduler image'
run: docker load --input scheduler.tar
- name: 'Start worker'
run: docker compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull --no-recreate --wait sis-updater-worker
- name: 'Run hourly jobs'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run weekly jobs'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: weekly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run prepurge'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: prepurge
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run purge'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: purge
EXIT_AFTER_IMMEDIATES: yes
@@ -115,22 +129,15 @@ jobs:
name: 'Build and release Updater worker'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build worker image
- name: 'Build updater worker'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/worker
with:
image: updater-worker
tags: staging production ${{ github.sha }}
context: updater/sis-updater-worker
containerfiles: |
updater/sis-updater-worker/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
environment: 'production'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -145,22 +152,15 @@ jobs:
name: 'Build and release Updater scheduler'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build scheduler image
- name: 'Build udpater scheduler'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/scheduler
with:
image: updater-scheduler
tags: staging production ${{ github.sha }}
context: updater/sis-updater-scheduler
containerfiles: |
updater/sis-updater-scheduler/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
environment: 'production'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
+3 -4
View File
@@ -56,7 +56,7 @@ cypress/videos/
coverage/
instrumented/
# Pycharm
# Pycharm
.idea
# VS Code
@@ -65,9 +65,8 @@ instrumented/
.vscode/launch.json
.vscode/terminals.json
# Funidata specific
environments/
# Target of shared symlink
services/**/src/shared
!services/backend/.env.test
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+20 -6
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/).
@@ -122,6 +123,8 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run oodikone`: starts Oodikone with anonymized data
- `npm run oodikone:real`: starts Oodikone with real data
- `npm run oodikone:test`: starts Oodikone with non-persistent database (wipes after every reset)
- `npm run oodikone:ci`: starts Oodikone in CI mode
- `npm run updater`: starts updater with anonymized data
- `npm run updater:real`: starts updater with real data
- `npm run both`: starts Oodikone and updater with anonymized data
@@ -129,7 +132,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 +151,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,13 +189,11 @@ 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
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container or `npm run install:docker` to install modules inside all packages (backend, frontend, sis-updater-scheduler and sis-updater-worker).
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Prefer `npm run ci:docker` or `npm run install:docker`, which also fix `node_modules` ownership for the non-root runtime user. If you run installs manually, use root inside the container and restore ownership afterwards (`chown -R 1001:0 node_modules`) to avoid permission issues.
### Study guidance groups don't work on my machine
@@ -240,6 +240,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"
+29 -12
View File
@@ -1,40 +1,57 @@
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
kone-db:
<<: *defaults
container_name: kone-db
container_name: oodikone-kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
container_name: sis-db
container_name: oodikone-sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
shm_size: 2gb
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
container_name: sis-importer-db
container_name: oodikone-sis-importer-db
environment:
PGDATA: /data
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
+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
+6 -6
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 ===
@@ -49,7 +49,7 @@ set_custom_select_prompt() {
options=(
"Set up Oodikone from scratch"
"Reset all real data"
"Reset all data"
"Reset single database"
"Restore data from dumps"
"Docker system prune"
@@ -68,8 +68,8 @@ while true; do
case $opt in
"Set up Oodikone from scratch")
set_up_oodikone_from_scratch;;
"Reset all real data")
reset_all_real_data;;
"Reset all data")
reset_all_data;;
"Reset single database")
reset_single_database
set_custom_select_prompt # Set prompt back to initial values
+305 -281
View File
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
}
const checkTableContents = contents => {
cy.get('table tbody').scrollIntoView()
cy.get('table tbody').within(() => {
contents.forEach((values, trIndex) => {
cy.get('tr')
@@ -91,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')
}
@@ -184,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 = [
@@ -213,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 = [
@@ -262,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')
@@ -308,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')
@@ -337,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
@@ -348,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', () => {
@@ -360,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.')
})
@@ -376,27 +382,26 @@ 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', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
@@ -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,13 +461,17 @@ 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('Students (33)').click()
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 32 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (32)').click()
cy.contains('td', '394776').siblings().eq(2).contains('3')
cy.contains('td', '497388').siblings().eq(2).contains('2')
})
@@ -466,13 +479,17 @@ 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('Students (33)').click()
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 32 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (32)').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'),
+1 -1
View File
@@ -12,7 +12,7 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.cs('history-search').click().type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
+144 -132
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() // NOTE: Tests are written for calendar years
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
@@ -79,11 +64,11 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 0, 0],
[2022, 25, 26, 48, 1, 4],
[2021, 29, 32, 47, 0, 2],
[2022, 25, 26, 47, 1, 3],
[2021, 29, 32, 48, 0, 2],
[2020, 26, 27, 12, 1, 3],
[2019, 28, 34, 1, 0, 1],
[2018, 40, 45, 0, 0, 0],
[2018, 40, 45, 0, 0, 1],
[2017, 41, 47, 0, 0, 0],
]
@@ -93,14 +78,14 @@ describe('Degree programme overview', () => {
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 189, 0],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
@@ -112,8 +97,8 @@ describe('Degree programme overview', () => {
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 48, 13],
[2021, 47, 76],
[2022, 47, 13],
[2021, 48, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
@@ -130,8 +115,8 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated]
...years.map(year => [year, 0, 0, 0]),
[2023, 8, 8, 24],
[2022, 25, 26, 44],
[2021, 29, 32, 46],
[2022, 25, 26, 43],
[2021, 29, 32, 47],
[2020, 26, 27, 11],
[2019, 28, 34, 1],
[2018, 40, 45, 0],
@@ -144,8 +129,8 @@ describe('Degree programme overview', () => {
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 44, 12],
[2021, 46, 69],
[2022, 43, 12],
[2021, 47, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
@@ -163,24 +148,24 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 0, 0],
['2022 - 2023', 25, 26, 36, 0, 0],
['2021 - 2022', 29, 32, 57, 1, 6],
['2022 - 2023', 25, 26, 35, 0, 0],
['2021 - 2022', 29, 32, 58, 1, 5],
['2020 - 2021', 26, 27, 30, 0, 3],
['2019 - 2020', 28, 34, 7, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0],
['2018 - 2019', 40, 45, 0, 0, 1],
['2017 - 2018', 41, 47, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 5, 0, 0, 26, 0],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
]
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
@@ -207,18 +192,20 @@ describe('Degree programme overview', () => {
.should('contain', 47)
.should('contain', 76)
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
cy.contains('Graduation year')
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 36 months')
cy.contains('27 graduated on time')
cy.contains('9 graduated max year overtime')
cy.contains('12 graduated over year late')
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
})
cy.cs('unset-median-bar-chart').within(() => {
cy.contains('47 graduated').should('be.visible')
cy.contains('47 graduated').hover()
})
cy.get('.grad-vals').contains('47 students graduated in year 2022').should('be.visible')
cy.contains('median study time: 6 semesters')
cy.contains('24 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('11 graduated over year late')
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
@@ -231,37 +218,43 @@ describe('Degree programme overview', () => {
})
describe('Graduation times of master programmes', () => {
it('are split into two graphs', () => {
it('are split into three 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('graduation-time-toggle').click()
cy.cs('master-median-time-bar-chart-section').within(() => {
cy.contains('Master study right')
cy.contains('Graduation year')
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
})
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: 67 months')
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('average 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('average study time: 11.18 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
@@ -269,7 +262,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()
@@ -302,15 +295,15 @@ describe('Degree programme overview', () => {
it('Students of the degree programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 0, 19, 7, 0, 25, 1],
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 29, 8, 0, 35, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 22, 13, 0, 35, 2],
['2018 - 2019', 46, 40, 0, 0, 6, 40, 0, 26, 20, 0, 45, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 0, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 0, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
@@ -355,14 +348,14 @@ describe('Degree programme overview', () => {
describe('Population link button works for', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
cy.contains('Class size 8 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2023 - 2024, 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 227 students')
cy.contains('Class of 2017 - 2026, 227 students')
})
it('Links to class statistics page with study track info included work', () => {
@@ -371,9 +364,9 @@ describe('Degree programme overview', () => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Class of 2022 - 2023, 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
@@ -385,10 +378,10 @@ describe('Degree programme overview', () => {
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
['2021 - 2022', 38, 8, 4, 9, 8, 4, 0, 0, 5],
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
@@ -405,31 +398,42 @@ 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, 23. 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, 14. On time."]').trigger('mouseover')
cy.get('text').contains('13').hover()
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('19 graduated').hover()
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -450,14 +454,14 @@ describe('Degree programme overview', () => {
it('Students of the study track are shown correctly', () => {
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 0, 2, 1, 0, 3, 0],
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 3, 1, 0, 4, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 10, 7, 0, 17, 1],
['2018 - 2019', 24, 21, 0, 0, 2, 22, 0, 11, 13, 0, 24, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 0, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 0, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
@@ -465,13 +469,14 @@ describe('Degree programme overview', () => {
it('Links to class statistics page with study track info included work', () => {
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
it.skip('Info message about missing progress stats is displayed', () => {
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the degree programme.')
})
@@ -481,7 +486,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 +494,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 +518,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 +534,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 +558,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 +572,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 +631,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(() => {
@@ -636,10 +645,11 @@ describe('Degree programme overview', () => {
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 6)
cy.get('th').should('have.length', 7)
const headers = [
'Degree students',
'Open university',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
@@ -660,7 +670,7 @@ describe('Degree programme overview', () => {
'Type',
'Code',
'Name',
'Total',
'Total students',
'Breakdown of total',
'Breakdown of passed',
'Not included in total or passed',
@@ -672,12 +682,13 @@ describe('Degree programme overview', () => {
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 9)
cy.get('th').should('have.length', 10)
const headers = [
'Passed',
'Not completed',
'Degree students',
'Open uni students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
@@ -696,7 +707,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('272')
})
})
})
@@ -708,7 +719,7 @@ describe('Degree programme overview', () => {
})
// FIXME: Flaky
it('test', () => {
it.skip('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
@@ -823,7 +834,8 @@ describe('Degree programme overview', () => {
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2022 - 2023')
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
+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', () => {
+4 -4
View File
@@ -19,7 +19,7 @@ describe("Population statistics with a master's programme", { testIsolation: fal
it('Study track filter works', () => {
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 0, true)
cy.selectFromDropdown('studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)', true)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
@@ -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, 34, 759, 15, 40, 6, 51, 1, 24, 1059, 42, 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([66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 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, 28, 499, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
})
})
+122
View File
@@ -0,0 +1,122 @@
/// <reference types="cypress" />
const selectCourseStatus = (courseCode, status) => {
cy.cs(`courseFilter-${courseCode}-selector`).click()
cy.get(`[role="listbox"][aria-labelledby=courseFilter-${courseCode}]`).within(() => {
cy.get('li').contains(status).click()
})
}
describe('Numbers should match between', () => {
describe('Programme courses, Course statistics and Course population (no substitutions)', () => {
const test = [
// Code, name, total students, passed, not completed, failed*, enrolled no grade* (not used by Programme courses)
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', '272', '253', (1 + 18).toString(), '1', '18'],
['Course', 'MAT11003', 'Raja-arvot', '270', '249', (1 + 20).toString(), '1', '20'],
['Course', 'MAT11004', 'Differentiaalilaskenta', '262', '248', (1 + 13).toString(), '1', '13'],
['Course', 'MAT21002', 'Sarjat', '256', '250', (0 + 6).toString(), '0', '6'],
['Course', 'MAT11005', 'Integraalilaskenta', '251', '234', (0 + 17).toString(), '0', '17'],
['Course', 'MAT11002', 'Lineaarialgebra ja matriisilaskenta I', '247', '242', (1 + 4).toString(), '1', '4'],
['Course', 'MAT12003', 'Todennäköisyyslaskenta I', '234', '227', (3 + 4).toString(), '3', '4'],
['Course', 'MAT21001', 'Lineaarialgebra ja matriisilaskenta II', '230', '216', (4 + 10).toString(), '4', '10'],
['Course', 'MAT21003', 'Vektorianalyysi I', '228', '202', (3 + 23).toString(), '3', '23'],
['Course', 'MAT20005', 'Akateemiset taidot', '201', '178', (0 + 23).toString(), '0', '23'],
['Module', 'MAT110', 'Matematiikka, perusopinnot', '195', '195', (0 + 0).toString(), '0', '0'],
['Course', 'MAT21014', 'Johdatus logiikkaan I', '188', '170', (0 + 18).toString(), '0', '18'],
]
it('in Programme courses', () => {
// Move to Math bachelor programme's programme courses
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('ProgrammeCoursesTab').click()
// Sort by Total students
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').eq(3).click()
})
})
cy.get('tbody').within(() => {
test.forEach((course, index) => {
cy.get('tr')
.eq(index)
.within(() => {
// Check a course's fields
course.forEach((field, fieldIndex) => {
if (fieldIndex <= 5) {
// Failed, Enrolled no grade values not used
cy.get('td').eq(fieldIndex).contains(field)
}
})
// Course link button, (name is second entry)
// cy.cs(`${course.at(1).toLocaleLowerCase()}-course-statistics-link-button`).click()
})
})
})
})
it('in Course statistics', () => {
test.forEach(course => {
// Move to Course statistics
cy.init(`/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D&combineSubstitutions=false`, 'basic')
cy.url().should('include', `/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D`)
cy.contains('Course statistics')
cy.contains(course.at(2)) // course name
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption2017-2018').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(1).contains(course.at(3))
cy.get('td').eq(2).contains(course.at(4))
cy.get('td').eq(3).contains(course.at(6)) // jump over combined Not completed
cy.get('td').eq(4).contains(course.at(7))
})
})
})
})
it('in Course population', () => {
test.forEach(course => {
// Academic years between 2017-2023
cy.init(
`/coursepopulation?from=68&to=74&coursecodes=%5B%22${course.at(1)}%22%5D&includeSubstitutions=false`,
'basic'
)
cy.contains(course.at(2)) // Course name
// Select course from Course-filter
cy.cs('courseFilter-filter-card').click()
cy.cs('courseFilter-selector').click()
cy.cs('courseFilter-popper').within(() => {
cy.contains('li', `${course.at(1)} - ${course.at(2)}`).click() // "code - name"
})
selectCourseStatus(course.at(1), 'All')
cy.contains(`Students (${course.at(3)})`)
selectCourseStatus(course.at(1), 'Passed')
cy.contains(`Students (${course.at(4)})`)
selectCourseStatus(course.at(1), 'Failed')
cy.contains(`Students (${course.at(6)})`)
selectCourseStatus(course.at(1), 'Enrolled, No Grade')
cy.contains(`Students (${course.at(7)})`)
})
})
})
describe('Course statistics and Course population', () => {
it.skip('when selecting one year')
it.skip('when selecting the all years')
})
})
-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')
})
})
})
+14 -14
View File
@@ -27,14 +27,14 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
it('Info box works', () => {
cy.cs('PopulationSearch-info-box-content').should('not.exist')
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
cy.cs('PopulationSearch-info-box-button').hover()
cy.cs('PopulationSearch-info-box-content').should('be.visible')
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
@@ -74,8 +74,8 @@ describe('Population statistics tests', () => {
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('See class').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
cy.contains('Class size 47 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2017 - 2018, 47 students')
})
// TODO: Delete MUI
@@ -95,7 +95,7 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class size 30 students')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
@@ -251,15 +251,15 @@ describe('Population statistics tests', () => {
it('Is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.intercept('/api/courseyearlystats**').as('coursePage')
cy.cs('toggle-group-module-MAT110').click()
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('MAT11001') // Johdatus yliopistomatematiikkaan
cy.contains('AYMAT11001') // Avoin yo: Johdatus yliopistomatematiikkaan
cy.contains('57033') // Johdatus yliopistomatematiikkaan
cy.contains('A57033') // Avoin yo: Johdatus yliopistomatematiikkaan
})
it('Curriculum selection works', () => {
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
@@ -401,8 +401,8 @@ describe('Population statistics tests', () => {
describe('When using IAM user', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Population statistics is visible', () => {
+85
View File
@@ -0,0 +1,85 @@
import { userHeaders } from '../support/commands.js'
const getBaseRoutes = [
['/', {}],
['/changelog', {}],
['/completedcoursessearch', {}],
['/university', {}],
['/feedback', {}],
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: ['KH50_001'] }],
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/users', { roles: ['admin'] }],
['/users/334', { roles: ['admin'] }],
['/updater', { roles: ['admin'] }],
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/teachers', { roles: ['teachers'] }],
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
]
for (const user of userHeaders) {
if (user.uid === 'admin') continue
describe(`${user.displayname} has access only to views that were intended`, () => {
let userInfo
before(() => {
cy.request({ method: 'GET', url: '/api/login', headers: user }).then(({ body }) => (userInfo = body?.user))
})
beforeEach(() => cy.wrap(userInfo).should('not.be.undefined'))
beforeEach(() => {
cy.intercept('**/api/**', req => {
req.on('response', res => {
expect(res.statusCode, `${req.method} ${req.url}`).to.be.lessThan(500)
})
})
})
for (const [route, requirements] of getBaseRoutes) {
it(`Checking access ${route}`, () => {
cy.init(route, user.uid)
const requiredUserId = !requirements.userId || requirements.userId === userInfo.userId
const requiredRoles = !requirements.roles || requirements.roles?.some(role => userInfo.roles?.includes(role))
const requiredRights =
!requirements.requireProgrammeRights ||
userInfo.roles?.includes('fullSisuAccess') ||
requirements.programmeRights.every(role => userInfo.programmeRights?.includes(role))
// This is defined in frontend ProtectedRoute component.
const specialNeedsRoutes = [
'populations',
'study-programme',
'coursepopulation',
'coursestatistics',
'students',
'custompopulation',
]
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
? requiredRoles || requiredRights
: requiredRoles && requiredRights
const shouldHaveAccess = mankeli && requiredUserId
if (shouldHaveAccess) {
cy.contains('Something broke').should('not.exist')
cy.contains('Access denied').should('not.exist')
} else {
cy.contains('Access denied')
}
})
}
})
}
+17 -28
View File
@@ -114,9 +114,9 @@ describe('Students tests', () => {
cy.get('a').click()
})
cy.url().should('include', '/coursestatistics')
cy.contains('MAT12004 • Tilastollinen päättely I')
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
cy.contains('MAT12004') // Tilastollinen päättely I
cy.contains('AYMAT12004') // Avoin yo: Tilastollinen päättely I
cy.contains('57046') // Johdatus tilastolliseen päättelyyn
})
it('Has correct Sisu link', () => {
@@ -162,8 +162,8 @@ describe('Students tests', () => {
.parent()
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('Class size 30 students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Grade graph works in all three different modes', () => {
@@ -174,18 +174,13 @@ describe('Students tests', () => {
cy.contains('label', 'Group size')
cy.get('input').should('have.value', '5')
})
cy.contains('.highcharts-container text', "Nov '22")
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
cy.get('input').should('have.value', '10')
})
cy.contains('.highcharts-container text', "Jul '22")
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
cy.contains('button', 'Show semester mean').click()
cy.contains('.highcharts-container text', "Jan '22")
})
describe('Bachelor Honours section', () => {
@@ -264,8 +259,10 @@ describe('Students tests', () => {
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
cy.visit('/students/550789')
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.get('td').eq(1).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
@@ -274,31 +271,23 @@ 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", () => {
cy.visit('/students/550789')
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.get('td')
.eq(-1)
.eq(4)
.contains(/^185 cr$/)
})
})
it("If there's a study plan corresponding to the degree programme and the student hasn't graduated, percentage of completion is also displayed", () => {
cy.visit('/students/458723')
cy.contains('table tbody tr', 'Oikeusnotaarin koulutusohjelma (01.08.2020–31.07.2027)').within(() => {
cy.get('td').eq(-1).contains('69% (125 cr)')
cy.contains('Study rights')
cy.get('[data-cy=KH20_001-0]').contains('Oikeusnotaarin koulutusohjelma')
cy.get('[data-cy=KH20_001-0]').within(() => {
cy.get('td').eq(4).contains('69% (125 cr)')
})
})
})
+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(2).contains('td', teacher1)
})
it('Can check teacher page', () => {
+29 -18
View File
@@ -10,21 +10,18 @@ const checkProgressTables = () => {
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
const checkAverageGraduationTimesBreakdownBarCharts = () => {
const checkGraduationCharts = mode => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-breakdown-bar-chart`)
cy.cs(`${level}-${mode}-bar-chart`).within(() => {
// Check that bar has loaded
cy.contains('Loading content').should('not.exist')
})
})
}
const checkAverageGraduationTimesMedianBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-median-bar-chart`)
})
}
describe('University view', () => {
describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
beforeEach(() => {
cy.init('/university')
cy.contains('University')
@@ -49,7 +46,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')
@@ -73,17 +70,31 @@ describe('University view', () => {
cy.cs('faculty-graduations-tab').click()
})
it('has all the correct median time bar charts', () => {
checkAverageGraduationTimesBreakdownBarCharts()
})
describe('Different modes work', () => {
it('Breakdown', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-breakdown').click()
})
checkGraduationCharts('breakdown')
})
it("'Breakdown/Median times' toggle works", () => {
cy.cs('graduation-time-toggle').click()
checkAverageGraduationTimesMedianBarCharts()
it('Median', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
})
checkGraduationCharts('median')
})
it('Average', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
})
checkGraduationCharts('average')
})
})
it('info boxes contain correct information', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-button').hover()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
+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])
+18 -12
View File
@@ -58,18 +58,7 @@ 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 = [
export const userHeaders = [
adminUserHeaders,
basicUserHeaders,
onlycoursestatisticsUserHeaders,
@@ -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,
})
})
+127 -62
View File
@@ -1,15 +1,30 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
backend:
<<: *defaults
container_name: backend
container_name: oodikone-backend
image: oodikone-backend:staging
build:
context: services
dockerfile: backend/Dockerfile
depends_on:
- kone-db
- redis
- sis-db
- user-db
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
environment:
CI: 'true'
DB_URL_USER: postgres://postgres@user-db:5432/user-db
@@ -22,130 +37,152 @@ services:
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
JAMI_URL: 'http://jami:3003/'
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
build:
context: services
dockerfile: backend/Dockerfile
ports:
- 8080:8080
profiles:
- oodikone
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health'] # In CI/prod the routing happens at a different level, hence no /api/ needed
<<: *healthcheck-defaults
frontend:
<<: *defaults
container_name: frontend
container_name: oodikone-frontend
image: oodikone-frontend:staging
build:
context: services
dockerfile: frontend/Dockerfile
depends_on:
backend:
condition: service_healthy
ports:
- 5000:5000
profiles:
- oodikone
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
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
ports:
- 4321:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 4320:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
redis:
<<: *defaults
container_name: oodikone-redis
image: redis:7.2.6
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:7.2.6
container_name: redis
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
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
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
sis-importer-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
depends_on:
jami-db:
condition: service_healthy
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- NODE_ENV=test # previously was development which borks CI
- 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
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
nginx:
container_name: oodikone-nginx
image: nginx:latest
container_name: nginx
depends_on:
- frontend
- backend
frontend:
condition: service_started
backend:
condition: service_healthy
ports:
- 1337:80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
profiles:
- oodikone
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
# 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
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
sis-updater-worker:
<<: *defaults
build:
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
- sis-importer-db
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
# Following services + sis-importer-db 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-updater-scheduler:
<<: *defaults
restart: no
container_name: oodikone-updater-scheduler
build:
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
dockerfile: ./Dockerfile
depends_on:
- redis
- sis-importer-db
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
CI: 'true'
REDIS_HOST: redis
@@ -160,3 +197,31 @@ services:
- 8082:8082
profiles:
- updater
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
sis-updater-worker:
<<: *defaults
container_name: oodikone-updater-worker
build:
context: updater/sis-updater-worker
dockerfile: ./Dockerfile
depends_on:
redis:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
-47
View File
@@ -1,47 +0,0 @@
services:
oodikone-backend:
build:
context: ${OODIKONE_REPO:-.}/services
dockerfile: backend/Dockerfile
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-backend:${DOCKER_IMAGE_TAG:-dev}
oodikone-frontend:
build:
context: ${OODIKONE_REPO:-.}/services
dockerfile: frontend/Dockerfile
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
SIS_URL: 'https://sis-qa.funidata.fi'
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
updater-scheduler:
build:
context: ${OODIKONE_REPO:-.}/updater/sis-updater-scheduler
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-scheduler:${DOCKER_IMAGE_TAG:-dev}
updater-worker:
build:
context: ${OODIKONE_REPO:-.}/updater/sis-updater-worker
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-worker:${DOCKER_IMAGE_TAG:-dev}
-190
View File
@@ -1,190 +0,0 @@
x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
dockerfile: dev.Dockerfile
services:
adminer:
<<: *defaults
container_name: adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
ports:
- 5050:8080
volumes:
- ./adminer-plugins:/var/www/html/plugins-enabled
backend:
<<: *defaults
build:
<<: *build_defaults
context: services/backend
depends_on:
- kone-db
- redis
- sis-db
- user-db
env_file:
- .env
environment:
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
DB_URL_USER: postgres://postgres@user-db:5432/user-db
FRONT_URL: http://localhost:3000
REDIS: redis
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://localhost:3005'
JAMI_URL: 'http://jami:3003/'
SERVICE_PROVIDER: fd
SIS_HOST: ${SIS_HOST}
SIS_GRAPHQL_APPLICATION_ACCOUNT: ${SIS_GRAPHQL_APPLICATION_ACCOUNT}
SIS_GRAPHQL_APPLICATION_KEY: ${SIS_GRAPHQL_APPLICATION_KEY}
IMPORTER_DB_API_USER: dev_user
IMPORTER_DB_API_PASSWORD: dev_password
ROOT_ORG_ID: ${ROOT_ORG_ID}
LANGUAGE_CENTER_VIEW_ENABLED: 'false'
ports:
- 8080:8080
volumes:
- backend-nodemod:/opt/app-root/backend/node_modules
- ./environments/testu:/opt/app-root/backend/src/environment
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
build:
<<: *build_defaults
context: services/frontend
container_name: frontend
ports:
- 3000:3000
environment:
REACT_APP_SERVICE_PROVIDER: fd
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
volumes:
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
ports:
- 12349:5432
volumes:
- kone-db-data:/data
redis:
<<: *defaults
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:6.2.16
ports:
- '6379:6379'
volumes:
- redis-data:/data
sis-db:
<<: *defaults
container_name: sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
volumes:
- sis-db-data:/data
shm_size: 2gb
ports:
- 12345:5432
sis-updater-scheduler:
<<: *defaults
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- redis
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SERVICE_PROVIDER: fd
ports:
- 8082:8082
volumes:
- ./updater/sis-updater-scheduler/:/opt/app-root/src
sis-updater-worker:
<<: *defaults
deploy:
replicas: 2
mode: replicated
build:
<<: *build_defaults
context: updater/sis-updater-worker
depends_on:
- redis
- sis-db
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
REDIS_PORT: 6379
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_PASSWORD: postgres
ROOT_ORG_ID: ${ROOT_ORG_ID}
SERVICE_PROVIDER: fd
volumes:
- ./updater/sis-updater-worker/:/opt/app-root/src
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
volumes:
- user-db-data:/data
ports:
- 12347:5432
volumes:
backend-nodemod:
frontend-nodemod:
kone-db-data:
redis-data:
sis-db-data:
user-db-data:
networks:
default:
name: oodikone
importer:
name: importer_network
external: true
+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
+26 -25
View File
@@ -1,26 +1,27 @@
services:
api-tests:
volumes:
- backend-tests-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
environment:
CI: 'true'
NODE_ENV: 'test'
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
DB_URL_USER: postgres://postgres@user-db:5432/user-db
FRONT_URL: http://localhost:3000
REDIS: redis
SECRET_TOKEN: test
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
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
name: oodikone
volumes:
backend-tests-nodemod:
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
sis-db:
<<: *defaults
container_name: oodikone-sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
pull_policy: always
volumes: !reset [] # Remove previous volume definitions
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
+138 -72
View File
@@ -1,13 +1,20 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
dockerfile: dev.Dockerfile
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
adminer:
<<: *defaults
container_name: adminer
container_name: oodikone-adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
@@ -18,15 +25,19 @@ 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
- sis-db
- user-db
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
env_file:
- .env
tty: true
@@ -45,57 +56,90 @@ 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
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health']
<<: *healthcheck-defaults
frontend:
<<: *defaults
container_name: oodikone-frontend
build:
<<: *build_defaults
context: services/frontend
container_name: frontend
dockerfile: frontend/dev.Dockerfile
context: services
depends_on:
backend:
condition: service_healthy
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
ports:
- 4321:5432
volumes:
- kone-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
profiles:
- oodikone
ports:
- 4320:5432
volumes:
- user-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
redis:
<<: *defaults
container_name: oodikone-redis
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:7.2.6
ports:
- '6379:6379'
- 6379:6379
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
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
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
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
@@ -103,41 +147,55 @@ services:
- sis-importer-db-data:/data
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-updater-scheduler:
jami:
<<: *defaults
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
container_name: oodikone-jami
image: quay.io/toska/jami:production
depends_on:
- redis
- sis-importer-db
jami-db:
condition: service_healthy
env_file:
- .env
environment:
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 8082:8082
profiles:
- updater
- 3003:3003
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
ports:
- 5433:5432
volumes:
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-scheduler/:/opt/app-root/src
- jami-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
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
- sis-importer-db
redis:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
@@ -149,52 +207,60 @@ services:
profiles:
- updater
volumes:
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-worker/:/opt/app-root/src
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
user-db:
sis-updater-scheduler:
<<: *defaults
container_name: user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
depends_on:
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
ports:
- 8082:8082
profiles:
- oodikone
- updater
volumes:
- user-db-data:/data
jami:
<<: *defaults
image: quay.io/toska/jami:production
env_file:
- .env
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
ports:
- 3003:3003
container_name: jami
jami-db:
<<: *defaults
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
container_name: jami-db
ports:
- 5433:5432
volumes:
- jami-data:/data
- ./updater/sis-updater-scheduler/:/opt/app-root/src
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
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:**
+78 -57
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,24 +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', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
'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 }],
'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': [
@@ -105,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',
@@ -127,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'],
})
}),
],
},
},
@@ -140,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,
@@ -166,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',
@@ -181,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
@@ -191,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
@@ -221,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',
@@ -232,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',
@@ -263,5 +263,26 @@ export default tseslint.config(
'no-promise-executor-return': 'off',
},
},
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
// Backend tests
{
files: ["services/backend/tests/**/*.ts", "services/backend/vitest.config.ts"],
rules: {
'import-x/no-extraneous-dependencies': 'off',
}
},
// 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
)
+497 -263
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -6,32 +6,32 @@
},
"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",
"logs:all": "docker compose --file docker-compose.yml --profile oodikone --profile updater logs -f",
"logs:ci": "docker compose --file docker-compose.ci.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami",
"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",
"oodikone:test": "npm run docker:down && docker compose --profile oodikone up --detach && docker compose --file docker-compose.test.yml up --detach",
"oodikone:ci": "npm run docker:down && docker compose --file docker-compose.ci.yml --profile oodikone up",
"updater": "./run.sh updater anon up --detach",
"updater:real": "./run.sh updater real up --detach",
"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 --wait sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker compose --file docker-compose.ci.yml up sis-updater-scheduler --exit-code-from sis-updater-scheduler",
"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",
"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 oodikone-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 oodikone-sis-updater-worker npm ci",
"install:docker": "docker exec --user 0 oodikone-frontend sh -lc \"npm i --prefix /opt/app-root/shared && npm i --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm i --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
"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 --user 0 oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm ci --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
@@ -45,21 +45,21 @@
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@eslint/js": "^9.28.0",
"cypress": "^15.9.0",
"cypress": "^15.13.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.1",
"eslint-plugin-cypress": "^6.2.3",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.2.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^40.0.0",
"typescript-eslint": "^8.34.0"
}
}
+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
}
+6 -29
View File
@@ -11,9 +11,8 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
## 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"
@@ -113,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"
@@ -139,30 +138,7 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_jami_data() {
local database="jami-db"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Removing database and related volume"
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
infomsg "Starting postgres in the background"
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
infomsg "Populating Jami"
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
docker-compose stop "$database"
msg ""
}
reset_all_real_data() {
reset_all_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
@@ -239,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"
)
@@ -247,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"
+116
View File
@@ -0,0 +1,116 @@
# disallow directory named "private", it can be used to store things that should not end up in version control or images
# NOTE: needs to be at the root of the project
private/
gitignored/
# disallow version control, package managment, IDEs, tools etc
**/node_modules
**/.venv
**/.git
**/.github
**/.vscode
**/.idea
**/.zed
**/coverage/
**/.eslintcache
**/.DS_Store
# do not include any data by default (except json)
**/*.csv
**/*.tsv
**/*.db
**/*.sqlite
**/*.sql
**/*.psql
**/*.sqlz
**/*.sqz
**/*.sql.gz
**/*.bson
**/*.jsonl
**/*.bin
**/*.dat
**/*.data
**/*.swp
**/*.tmp
**/.~lock*
**/*.pid
**/*.seed
**/*.lock
**/*.gz
**/*.tgz
**/*.xz
**/*.zip
**/*.gzip
**/*.tar
**/*.xz
**/*.7z
**/*.rar
**/*.docx
**/*.doc
**/*.xlsx
**/*.xls
**/*.pptx
**/*.ppt
**/*.odt
**/*.odp
**/*.ods
**/*.pdf
# disallow logs
**/*.log
**/*.log*
**/logs/
**/log/
**/*.cpuprofile
**/*.heapprofile
**/*.heapsnapshot
# disallow configuration
**/*.yml
**/*.yaml
**/*.toml
**/*.conf
**/*.ini
**/toska_login/
**/toska_login/*
**/cypress.env.json
cypress/
# disallow secrects
**/.env
**/.env*
**/*.env.*
**/.secrects
**/*.s3cfg
# disallow backups
**/backups/*
**/backups
**/backup/*
**/backup
**/dump/
**/dump
**/dumps/
**/dumps
**/databasedumps/
**/privateData
**/temp
**/minio-data/*
# additional inclusions here
# exclusions here
**/.dockerignore
**/.gitignore
**/.eslintrc.json
**/*Dockerfile
**/build
**/dist
-3
View File
@@ -1,3 +0,0 @@
.dockerignore
*Dockerfile
node_modules/
+12
View File
@@ -0,0 +1,12 @@
CI='true'
NODE_ENV='test'
DB_URL_KONE=postgres://postgres@localhost:4321/kone-db
DB_URL_USER=postgres://postgres@localhost:4320/user-db
FRONT_URL=http://localhost:3000
REDIS=localhost
SECRET_TOKEN=test
SIS_DB_URL=postgres://postgres@localhost:12345/sis-db
SIS_UPDATER_URL=http://localhost:8082
JAMI_URL='http://localhost:3003/'
IMPORTER_DB_API_URL='https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
CRYPT_KEY='e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
+3
View File
@@ -0,0 +1,3 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+7 -3
View File
@@ -1,4 +1,4 @@
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal AS build
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal AS build
ENV TZ="Europe/Helsinki"
@@ -29,7 +29,7 @@ RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backen
RUN npm run build --prefix shared && npm run build --prefix backend
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
@@ -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
+11 -3
View File
@@ -1,13 +1,21 @@
FROM node:24-alpine
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
USER 0
WORKDIR /opt/app-root/shared
COPY --chown=1001:0 ./shared .
RUN npm ci && chown -R 1001:0 /opt/app-root/shared
WORKDIR /opt/app-root/backend
COPY . .
RUN npm ci
COPY --chown=1001:0 ./backend .
RUN npm ci && chown -R 1001:0 /opt/app-root/backend
EXPOSE 8080
USER 1001
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
+1759 -1016
View File
File diff suppressed because it is too large Load Diff
+16 -19
View File
@@ -3,52 +3,49 @@
"private": true,
"scripts": {
"build": "tsc",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
"test:all": "vitest",
"test:api": "vitest ./tests/api",
"test:services": "vitest ./tests/services",
"test:unit": "vitest ./tests/unit",
"test:silent": "SILENT_TESTING=true vitest"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"@types/lodash-es": "^4.17.12",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash-es": "^4.17.23",
"express": "^5.2.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"pg": "^8.20.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.7",
"sequelize": "^6.37.8",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"vite": "^7.1.12",
"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",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.4.0"
}
"typescript": "^5.9.2",
"vitest": "4.1.9"
}
}
-1
View File
@@ -1,4 +1,3 @@
import 'express-async-errors'
import express from 'express'
import { baseUrl, backendPort } from './config'
+3 -12
View File
@@ -6,10 +6,10 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
export const silentTesting = process.env.SILENT_TESTING === 'true'
// IAM group
export const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
@@ -33,20 +33,11 @@ export const baseUrl = isDev ? '/api' : ''
export const frontUrl = process.env.FRONT_URL
export const backendPort = 8080
// System run in whose environment
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
// Optional logout-url configuration
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
-1
View File
@@ -5,7 +5,6 @@ export const roles = [
'courseStatistics',
'facultyStatistics',
'fullSisuAccess',
'openUniSearch',
'studyGuidanceGroups',
'teachers',
] as const
@@ -24,6 +24,7 @@ import {
TeacherModel,
} from '../models'
import {
BannerModel,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -107,6 +108,7 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
logging: false,
password: conf.KONE_PASSWORD,
models: [
BannerModel,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -0,0 +1,46 @@
const { INTEGER, STRING, DATE } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.createTable('banners', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
text: {
type: STRING,
allowNull: false,
},
color: {
type: STRING,
allowNull: false,
},
lightness: {
type: STRING,
allowNull: false,
},
start_date: {
type: DATE,
allowNull: false,
},
end_date: {
type: DATE,
allowNull: false,
},
last_modified_by: {
type: STRING,
allowNull: false,
},
created_at: {
type: DATE,
},
updated_at: {
type: DATE,
},
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.dropTable('banners')
},
}
+11 -9
View File
@@ -1,6 +1,6 @@
import { CronJob } from 'cron'
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
import { isProduction, runningInCI, CRON_SCHEDULE } from './config'
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
import { getFaculties } from './services/faculty/facultyHelpers'
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
@@ -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,9 @@ const dailyJobs = async () => {
try {
await refreshTeacherLeaderboard()
await refreshProgrammesAndFaculties()
if (languageCenterViewEnabled) jobMaker.languagecenter()
jobMaker.closeToGraduation()
jobQueue.refreshLanguagecenter()
jobQueue.refreshCloseToGraduation()
} catch (error) {
logger.error('Daily jobs failed', error)
}
@@ -118,11 +120,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
})
+9 -36
View File
@@ -2,9 +2,8 @@ import * as Sentry from '@sentry/node'
import { NextFunction, Request, Response } from 'express'
import { intersection } from 'lodash-es'
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
import { FormattedUser } from '../types'
import { requiredGroup, baseUrl } from '../config'
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -25,7 +24,7 @@ type Headers = {
'x-show-as-user'?: string
}
const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction) => {
export default async (req: Request, _: Response, next: NextFunction) => {
const {
'shib-session-id': sessionId,
'x-show-as-user': showAsUser,
@@ -37,6 +36,11 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
uid: username,
} = req.headers as Headers
if (req.path === `${baseUrl}/health`) {
next()
return
}
if (!username || !sessionId)
throw new ApplicationError(
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
@@ -61,7 +65,7 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
const user =
showAsUser && specialGroup?.superAdmin
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
: await getUserToska({
: await getUser({
username,
name: name!,
email: email!,
@@ -80,34 +84,3 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
next()
}
const fdUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
const { remote_user: remoteUser, 'x-show-as-user': showAsUser } = req.headers
if (!remoteUser) {
throw new ApplicationError('Not enough data in request headers, remote_user was missing', 403, { configLogoutUrl })
}
let user: FormattedUser | null
// getMockedUser in production requires the superAdmin role, which is only available
// via iamGroups, so it's now only implemented for the dev environment
if (showAsUser && isDev) {
user = await getMockedUserFd({ userToMock: showAsUser as string, mockedBy: remoteUser as string })
} else {
user = await getUserFd({ username: remoteUser as string })
}
if (!user) {
throw new ApplicationError(`Could not grant access with the eppn ${remoteUser}.`, 403, { configLogoutUrl })
}
req.user = user
req.logoutUrl = configLogoutUrl
next()
}
const currentUserMiddleware = serviceProvider === 'toska' ? toskaUserMiddleware : fdUserMiddleware
export default currentUserMiddleware
+1 -7
View File
@@ -1,10 +1,4 @@
import headersMiddleware from 'unfuck-utf8-headers-middleware'
import { serviceProvider } from '../config'
const headersToska = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
const headersOther = ['remote_user']
const headers = serviceProvider === 'toska' ? headersToska : headersOther
const headers = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
export default headersMiddleware(headers)
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
@Column(DataType.JSONB)
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@Column(DataType.JSONB)
declare transferInfo: SISStudyRight['transferInfo']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
declare credits: SISStudyRight['credits']
+3
View File
@@ -73,6 +73,9 @@ export class CourseModel extends Model implements Course {
@Column(DataType.JSONB)
declare substitutions: Course['substitutions']
@Column(DataType.JSONB)
declare substitution_groups: Course['substitution_groups']
@Column(DataType.STRING)
declare course_unit_type: Course['course_unit_type']
@@ -0,0 +1,33 @@
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement } from 'sequelize-typescript'
import type { Banner } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'banner',
tableName: 'banners',
})
export class BannerModel extends Model implements Banner {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
declare id: number
@Column(DataType.DATE)
declare startDate: Date
@Column(DataType.DATE)
declare endDate: Date
@Column(DataType.STRING)
declare text: string
@Column(DataType.STRING)
declare color: string
@Column(DataType.STRING)
declare lightness: 'light' | 'main' | 'dark'
@Column(DataType.STRING)
declare lastModifiedBy: string
}
@@ -1,3 +1,4 @@
export { BannerModel } from './banner'
export { CustomPopulationSearchModel } from './customPopulationSearch'
export { ExcludedCourseModel } from './excludedCourse'
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
-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))
+12 -18
View File
@@ -3,22 +3,23 @@ import compression from 'compression'
import cors from 'cors'
import { Express, json as jsonExpress } from 'express'
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
import { frontUrl } from './config'
import accessLogger from './middleware/accessLogger'
import * as auth from './middleware/auth'
import currentUserMiddleware from './middleware/currentUser'
import errorHandler from './middleware/errorHandler'
import headersMiddleware from './middleware/headers'
import banners from './routes/banners'
import changelog from './routes/changelog'
import closeToGraduation from './routes/closeToGraduation'
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'
import health from './routes/health'
import languageCenterData from './routes/languageCenterData'
import login from './routes/login'
import population from './routes/population'
@@ -34,8 +35,7 @@ import tags from './routes/tags'
import teachers from './routes/teachers'
import university from './routes/university'
import updater from './routes/updater'
import usersToska from './routes/users'
import usersFd from './routes/usersFd'
import users from './routes/users'
const routes = (app: Express, url: string) => {
app.use(cors({ credentials: true, origin: frontUrl }))
@@ -43,9 +43,10 @@ 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}/banners`, banners)
app.use(url, courses)
app.use(`${url}/students`, students)
app.use(url, population)
@@ -53,32 +54,25 @@ 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) {
app.use(`${url}/languagecenterdata`, languageCenterData)
}
app.use(`${url}/languagecenterdata`, languageCenterData)
app.use(`${url}/faculties`, faculties)
app.use(`${url}/university`, university)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, teachers)
if (serviceProvider === 'toska') {
app.use(`${url}/users`, usersToska)
} else {
app.use(`${url}/users`, usersToska)
app.use(`${url}/users`, usersFd)
}
app.use(`${url}/users`, users)
app.use(`${url}/feedback`, feedback)
app.use(`${url}/custom-population-search`, customPopulationSearch)
app.use(`${url}/studyguidancegroups`, auth.roles(['studyGuidanceGroups']), studyGuidanceGroups)
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
app.use(`${url}/study-programme-pins`, studyProgrammePins)
app.use(`${url}/curriculum-periods`, curriculumPeriods)
app.get('*', (_, res) => {
app.use(`${url}/health`, health)
app.get('', (_, res) => {
res.status(404).json({ error: 'unknown endpoint' })
})
Sentry.setupExpressErrorHandler(app)
+53
View File
@@ -0,0 +1,53 @@
import { Request, Response, Router } from 'express'
import { Banner } from '@oodikone/shared/models/kone'
import { saveBanner, getActiveBanners, getAllBanners } from '../services/banners'
const router = Router()
router.get<never, Banner[]>('/', async (_req: Request, res: Response) => {
const banners = await getActiveBanners()
res.json(banners).end()
})
router.get<never, Banner[]>('/all', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const banners = await getAllBanners()
res.json(banners)
})
router.post('/new', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const payload = {
...req.body,
lastModifiedBy: user.username,
}
await saveBanner(payload)
res.status(201).end()
})
router.put('/update', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const payload = {
...req.body,
lastModifiedBy: user.username,
}
await saveBanner(payload)
res.status(201).end()
})
export default router
@@ -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' })
}
+37 -40
View File
@@ -9,71 +9,58 @@ import {
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { uniq } from '@oodikone/shared/util'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
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, combineSubstitutions, includeSpecial } = req.query
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = uniq(
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
)
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
const mergedCourses = new Map<string, CourseWithSubsDetails>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
if (!groupId) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Object.values(mergedCourses) })
res.json({ courses: Array.from(mergedCourses.values()) })
}
)
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' })
}
@@ -95,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
+10 -8
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,12 +30,13 @@ import {
setFacultyProgressStats,
type BasicData,
type ThesisWriterData,
GraduationData,
getGraduationStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress } from '../services/faculty/facultyStudentProgress'
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { objectKeyAnnihilator } from '../util'
import { ApplicationError } from '../util/customErrors'
// Faculty uses a lot of tools designed for Degree programme.
@@ -135,14 +136,13 @@ 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) => {
@@ -153,7 +153,9 @@ 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)
// Times were needed to compute medians/averages serverside, but are not needed in frontend.
// They are still kept in redis as university view needs them for the same purpose.
if (data) return res.json(objectKeyAnnihilator(data, 'times', []))
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
@@ -161,7 +163,7 @@ router.get<
const updatedStats = await countGraduationTimes(faculty.code, programmes)
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
return res.json(updatedStats)
return res.json(objectKeyAnnihilator(updatedStats, 'times', []))
})
type GetProgressStatsResBody = {
@@ -212,7 +214,7 @@ export type DegreeProgramme = Pick<
'code' | 'name' | 'degreeProgrammeType' | 'progId'
>
type GetStudetStatsResBody = {
type GetStudentStatsResBody = {
id: string
years: string[]
facultyTableStats: Record<string, (number | string)[]>
@@ -229,7 +231,7 @@ type GetStudetStatsResBody = {
router.get<
GetStatsParams,
CanError<GetStudetStatsResBody, GenericApplicationError>,
CanError<GetStudentStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetProgressStatsQuery
>('/:id/studentstats', auth.roles(['facultyStatistics']), async (req, res) => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Request, Response, Router } from 'express'
import { sendFeedbackToToska } from '../services/mailService'
import { sendFeedback } from '../services/mailService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -20,7 +20,7 @@ router.post('/email', async (req: EmailRequest, res: Response) => {
throw new ApplicationError('User not found', 404)
}
await sendFeedbackToToska({
await sendFeedback({
feedbackContent: content,
user,
})
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express'
const router = Router()
/** Used for checking if backend is up eg. Docker healthchecks */
router.get<never, { status: string }>('/', (_, res) => {
return res.status(200).json({ status: 'healthy' })
})
export default router
+1 -1
View File
@@ -1,8 +1,8 @@
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { FormattedUser } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types/user'
import { ApplicationError } from '../util/customErrors'
const router = Router()
+40 -32
View File
@@ -15,18 +15,22 @@ import {
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
} from '@oodikone/shared/routes/populations'
import { Unification, Unarray } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
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, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
import { getStudentTagMap } from '../services/populations/getStudentData'
import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
import { findByCourseAndSemesters } from '../services/students'
import { 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)
@@ -260,14 +269,13 @@ router.get<never, PopulationstatisticsStudyprogrammesResBody>(
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
const formattedFilteredProgrammes = keyBy(filteredProgrammes, 'code')
const formattedAllProgrammes = keyBy(programmes, 'code')
res.json({
allProgrammes: formattedAllProgrammes,
filteredProgrammes: formattedFilteredProgrammes,
})
}
)
@@ -276,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
+5 -6
View File
@@ -1,19 +1,17 @@
import { Request, Response, Router } from 'express'
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
import { getFullStudyProgrammeRights, handleQueryArrays, validateParamLength } from '../util'
const router = Router()
const iamGroupsGivingFullAccess = ['hy-dekaanit', 'hy-varadekaanit-opetus']
const fullAccessAuth = () =>
serviceProvider === 'toska' ? auth.roles([], iamGroupsGivingFullAccess) : auth.roles(['teachers'])
const fullAccessAuth = () => auth.roles([], iamGroupsGivingFullAccess)
interface GetTeachersRequest extends Request {
query: {
@@ -93,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 (
!(
+83 -76
View File
@@ -1,12 +1,12 @@
import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash-es'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
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,
@@ -15,20 +15,21 @@ 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 { objectKeyAnnihilator } from '../util'
import logger from '../util/logger'
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: {
@@ -120,16 +121,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 = serviceProvider === 'toska' ? 'NEW_DEGREE_PROGRAMMES' : 'ALL_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)
@@ -137,43 +142,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)
}
@@ -182,29 +194,28 @@ 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: [] }
}
const uniYearStats = mediansForUniversity[degree][yearData.name]
/** 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]) {
mediansForUniversity[degreeType][facultyYearStats.name] ??= { programmes: [], data: [] }
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
}
}
}
@@ -213,27 +224,23 @@ 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)
// Individual times are no longer needed as medians/averages were already computed.
objectKeyAnnihilator(byGradYear, 'times', [])
res.status(200).json({
goals,
programmeNames,
byGradYear,
classSizes,
})
})
export default router
+20 -17
View File
@@ -1,9 +1,9 @@
import { Request, Response, Router } from 'express'
import { languageCenterViewEnabled } from '../config'
import { refreshFaculties, refreshProgrammes } from '../events'
import {
abortUpdate,
nukeRedis,
updateSISCoursesByCourseCode,
updateSISMetadata,
updateSISProgrammes,
@@ -12,28 +12,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 +47,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 +65,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 +80,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')
})
@@ -102,15 +108,13 @@ router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
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 +124,12 @@ router.get('/abort', async (_req: Request, res: Response) => {
})
router.get('/jobs', async (_req: Request, res: Response) => {
const waiting = await getJobs('waiting')
const active = await getJobs('active')
res.status(200).json({ waiting, active })
const jobs = await jobQueue.getJobs()
res.status(200).json(jobs)
})
router.delete('/jobs', async (_req: Request, res: Response) => {
await removeWaitingJobs()
await jobQueue.clearWaitingJobs()
res.status(200).end()
})
+27 -57
View File
@@ -1,33 +1,34 @@
import { Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { LANGUAGE_CODES } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import {
ChangeLanguageReqBody,
GetUserByIdParams,
GetUserByIdResBody,
GetUserRolesResBody,
GetUsersResBody,
ModifyUserRolesReqBody,
PostUserEmailReqBody,
UserEmailPreviewResBody,
UserIDElementsParams,
UserIDElementsReqBody,
} from '@oodikone/shared/routes/users'
import { tryCatch } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const router = Router()
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
const results = await userService.findAll()
res.json(results)
})
type GetUserRolesResBody = readonly string[]
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
res.json(roles)
})
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
@@ -41,61 +42,35 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
async (req, res) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
if (error) return res.status(400).json(error)
return res.status(204).end()
}
)
type UserEmailPreviewResBody = {
subject: string
html: string
}
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
res.json({ subject: accessMessageSubject, html: accessMessageText })
})
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['admin']), async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
'/email',
auth.roles(['admin']),
async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
}
)
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
type PostUserUIDElementsResBody = void
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -106,9 +81,7 @@ router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, Elemen
}
)
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -119,10 +92,7 @@ router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, El
}
)
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
-68
View File
@@ -1,68 +0,0 @@
import { Request, Response, Router } from 'express'
import { isDev } from '../config'
import * as auth from '../middleware/auth'
import * as userService from '../services/userService'
const router = Router()
interface NewUserEppnRequest extends Request {
params: {
newUserEppn: string
}
}
router.get('/from-sisu-by-eppn/:newUserEppn', auth.roles(['admin']), async (req: NewUserEppnRequest, res: Response) => {
let { username: requesterEppn } = req.user
const { newUserEppn } = req.params
// In order to test this feature in the dev environment we need to set an eppn that demo sisu will recognize
if (isDev && requesterEppn === 'mluukkai') {
requesterEppn = newUserEppn
}
const person = await userService.getUserFromSisuByEppn(requesterEppn, newUserEppn)
res.json(person)
})
interface User {
id: string
first_name: string
last_name: string
call_name: string
eppn: string
email_address: string
student_number: string
phone_number: string
document_state: string
}
interface AddUserRequest extends Request {
body: {
user: User
}
}
interface DeleteUserRequest extends Request {
body: {
userId: string
}
}
router.post('/add', auth.roles(['admin']), async (req: AddUserRequest, res: Response) => {
const { user } = req.body
const oldUser = await userService.findByUsername(user.eppn)
if (oldUser) return res.status(400).json({ error: 'Trying to add a user already present in the user database.' })
const success = await userService.addNewUser(user)
res.json(success)
})
router.delete('/delete', auth.roles(['admin']), async (req: DeleteUserRequest, res: Response) => {
const { userId } = req.body
try {
await userService.deleteUserById(userId)
res.json({ deleted: userId })
} catch (error) {
return res.status(400).json({ error: 'Deletion was unsuccessful with the user id included in the request' })
}
})
export default router
+28
View File
@@ -0,0 +1,28 @@
import { Op } from 'sequelize'
import { Banner } from '@oodikone/shared/models/kone'
import { BannerModel } from '../models/kone/banner'
import { SetOptional } from '../types'
export const getActiveBanners = async () =>
await BannerModel.findAll({
attributes: ['id', 'text', 'lightness', 'color', 'startDate', 'endDate'],
where: {
endDate: {
[Op.gt]: new Date(),
},
startDate: {
[Op.lt]: new Date(),
},
},
raw: true,
})
export const getAllBanners = async () => await BannerModel.findAll({ raw: true })
export const saveBanner = async (payload: SetOptional<Banner, 'id'>) => {
if (!payload.id) {
await BannerModel.create(payload)
} else {
await BannerModel.upsert(payload)
}
}
@@ -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,51 +38,33 @@ 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),
...codeLikeTerm(code),
},
raw: true,
})
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
const rawCourses = await getRawCourses(name, code, includeSpecial)
return rawCourses
.map(course => ({
...course.toJSON(),
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) 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[]) => {
@@ -124,6 +124,7 @@ export class CourseYearlyStatsCounter {
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
}
/** NOTE: Duplicates are counted by year eg. you can be both in 2022 and 2023 enrollments */
public markEnrollmentToGroup(
studentNumber: string,
enrollmentDateTime: Date,
@@ -1,7 +1,10 @@
import { Op } from 'sequelize'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { CreditTypeCode, EnrollmentState, Unification } from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
import {
StudentModel,
CreditModel,
SemesterModel,
OrganizationModel,
@@ -11,37 +14,85 @@ 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),
credittypecode: { [Op.not]: CreditTypeCode.IMPROVED }, // We do not care about improved grades
},
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 +134,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
}

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