Compare commits

...
688 Commits
Author SHA1 Message Date
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
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
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
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
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
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
joonas-a 9a9f723b46 [Charts][Distribution development] Migrate to echarts 2026-04-21 12:41:39 +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
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
joonas-a afa6804d09 [Charts] Credit acc done for non bscmsc classes 2026-04-14 16:19:46 +03:00
joonas-a a57f2f01ca [Charts][Credit accmltn] hops filter functionality 2026-04-13 14:40:44 +03:00
joonas-a 1b4bdff37e [Charts][poc] credit accumulation 2026-04-02 16:14:04 +03:00
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
joonas-a 205a3079ad [Courses of class] quick and dirty too narrow column fix 2026-02-18 15:10:21 +02:00
Aaro Koivukangas 29a0447b3f [ClassStats][CoursesOfX] Change default Link visibility 2026-02-18 15:00:32 +02:00
Aaro Koivukangas ee65aac42a [ClassStats][CoursesOfPopulation] Add stats to modules 2026-02-18 14:52:02 +02:00
joonas-a 56c59cb86b [Frontend / Ooditable] Fix too large boxshadow in multiline headers 2026-02-18 13:42:59 +02:00
Aaro Koivukangas e404fe09e3 [E2E][ProgrammeCourses] Change more header names in tests 2026-02-18 10:27:23 +02:00
Aaro Koivukangas 613164b3da [E2E][ProgrammeCourses] Fix header name in e2e test 2026-02-18 10:01:53 +02:00
joonas-a 7446760798 [Frontend][Programme courses] Add final info texts 2026-02-17 20:01:56 +02:00
joonas-a a86a99ef96 [Programme courses] Update infobox 2026-02-17 19:46:57 +02:00
joonas-a df7f2b75da [Programme courses] Add docstring and sanity check 2026-02-17 18:32:54 +02:00
joonas-a 3f40896f3b [E2E] adjust numbers 2026-02-17 16:29:59 +02:00
joonas-a 975d37142a [DX][Nodemon] Fix backend hotreload on helix/nvim 2026-02-17 14:55:49 +02:00
Matti Luukkainen 6026f04fcf try optimize workflow 2026-02-16 00:50:41 +02:00
Matti Luukkainen 12182193e1 feat: if course population covers only one semester, indicate it in the name 2026-02-16 00:20:03 +02:00
Aaro Koivukangas ca8ad3ea04 [PopulationStatistics][Courses of class] Show all courses/modules changes text based on Course <-> Module toggle 2026-02-14 12:57:42 +02:00
Aaro Koivukangas b21693ba1d [PopulationStatistics][Courses of class] Fix table dissapearing if Course <-> Module tab returns 0 rows 2026-02-14 12:57:42 +02:00
Aaro Koivukangas b99d3ff649 [PopulationStatistics][Courses of class] Add a toggle to show only courses or modules in "Show all courses" view 2026-02-14 12:57:42 +02:00
joonas-a 2ca49bbe32 [Faculty][Degree programme overview] Remove special category switch
Instead show everything by default, also add "other" category.

Commit powered by AI btw
2026-02-13 18:31:47 +02:00
joonas-a 9015be8458 [Programme courses] Remove merging of AY courses 2026-02-13 17:26:20 +02:00
joonas-a 8dc95c7a89 [Programme courses] Rework credit categories 2026-02-13 16:56:39 +02:00
Santeri Rajamäki 4537bd792b [Frontend] Hide progress tab (once again) from multi year populations 2026-02-13 15:09:27 +02:00
Santeri Rajamäki fcea050b9d [FilterView] Cleanup 2026-02-06 16:35:42 +02:00
Santeri Rajamäki 75049cc641 [Frontend] Fix progress criteria calculation source 2026-02-06 15:59:40 +02:00
Santeri Rajamäki 58a8c38c53 [Frontend] FilterDegreeCoursesModal "Save & Close" is actually "Close" 2026-02-06 13:48:19 +02:00
Aaro Koivukangas 29723b15aa [Population][ClassStats] Handle class stat query-arrays better and add more tests 2026-02-06 12:39:22 +02:00
Aaro Koivukangas 77c3271c25 [ApiTests] Add tests for ClassStats with years and programme 2026-02-04 17:48:36 +02:00
Aaro Koivukangas eaee2bac5a [ApiTests] Add tests PopulationStatistics and a skipped test semester bug #4959 2026-02-04 17:05:02 +02:00
Santeri Rajamäki 5a2e8ed4b1 [Frontend] Cull icons from @/theme 2026-02-03 14:07:09 +02:00
Santeri Rajamäki 128303cc70 [Backend] Import logger to worker correctly 2026-02-03 13:32:30 +02:00
Santeri Rajamäki 92dea1c19d [Frontend] Rename SellIcon to TagIcon to reflect use case 2026-02-03 13:01:23 +02:00
Santeri Rajamäki 94669ef98a [Frontend] Cull icons from @/theme 2026-02-03 13:00:24 +02:00
Santeri Rajamäki 7036d1be28 [Frontend] Consolidate icons under @/theme folder 2026-02-03 12:30:55 +02:00
Aaro Koivukangas 0aff1afcd6 [Api-tests] Add basic permission tests for populations 2026-02-02 17:14:38 +02:00
Aaro KoivukangasandAaro Koivukangas 6db47e0568 [CI] Update toska registry login envs in pull_request action 2026-02-02 14:30:46 +02:00
Aaro KoivukangasandAaro Koivukangas 2b855654a6 [CI] Login to toksa docker registry in pull_request action 2026-02-02 14:30:46 +02:00
Aaro KoivukangasandAaro Koivukangas 5acf0d3e2c [CI] Remove incorrect path from pull_request action 2026-02-02 14:30:46 +02:00
Aaro KoivukangasandAaro Koivukangas 76f80c7828 [CI] Rename npm script to the correct one in pull_request action 2026-02-02 14:30:46 +02:00
Aaro KoivukangasandAaro Koivukangas c0f4acf681 [Meta] Update docker compose container_names to use '-' as separator 2026-02-02 14:30:46 +02:00
Santeri Rajamäki 31348527a0 [META] Use correct entry for dev tsx --watch 2026-02-02 12:35:29 +02:00
joonas-aandSanteri Rajamäki 773c768805 [E2E][Deprecation] Remove coverage plugin requires 2026-02-02 11:40:52 +02:00
joonas-aandSanteri Rajamäki f335d38d27 [Meta][Deprecation] Remove instrumentation stuff 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 892f64f492 [CI] Action typo fix 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki a12a86ad0e [CI] Run api+e2e tests even when previous jobs are skipped 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 3ed549cc75 [CI] Run frontend and backend while e2e testing 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 809a42d947 [CI] Login to toska docker registry while setting up api tests 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki ceabbd0628 [CI] Fixing the same error 3rd time 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki d4485ee3dd [CI] Fix the same error again 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 214df46c4d [CI] Typo fix 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 39709d41c7 [CI] Api test and e2e tests now run even when previous steps are skipped 2026-02-02 11:40:52 +02:00
joonas-aandSanteri Rajamäki 54464f12da [Lint] fix import in wrong order 2026-02-02 11:40:52 +02:00
joonas-aandSanteri Rajamäki 3a752a1f83 [TS/lint] betterment and fix 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki 12003a9c0b [CI] Multiple ci fixes 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki d5f3f86b2b [CI] Change api tests to use docker-compose.ci.yml 2026-02-02 11:40:52 +02:00
Aaro KoivukangasandSanteri Rajamäki f37617754d [CI] Add check services run correctly as a dependency in actions to api+e2e 2026-02-02 11:40:52 +02:00
joonas-aandSanteri Rajamäki 1a41d3d8fc [Backend][Programme courses] Refactor
Preparing for upcoming change in logic
2026-02-02 11:40:32 +02:00
Aaro Koivukangas b83dae4ead [Meta] Change run.sh to use docker compose, not docker-compose 2026-01-28 18:13:44 +02:00
Aaro Koivukangas b1d75d53d1 [CI] Remove working-directory, not run from test action 2026-01-28 18:07:42 +02:00
Aaro Koivukangas 52569cb402 [CI] Fix incorrect path in test action 2026-01-28 18:06:15 +02:00
Aaro Koivukangas cd88b6c41c [CI, Updater] Trigger updater workflow only on release 2026-01-28 18:00:06 +02:00
Aaro Koivukangas ea5543f712 [CI] Typo fix 2026-01-28 17:58:47 +02:00
Aaro Koivukangas e0fb0ab788 [CI] Add node/npm to api-tests action 2026-01-28 17:57:56 +02:00
Aaro Koivukangas adda1c5020 [CI] E2E acually checks if needed jobs succeed or are skipped 2026-01-28 17:45:45 +02:00
Aaro Koivukangas c15a113bbd [CI] Typo fix 2026-01-28 17:36:50 +02:00
Aaro Koivukangas d52be3e425 [CI] Add api tests to pull requests 2026-01-28 17:33:37 +02:00
Aaro Koivukangas 5643fe0c07 [CI] Remove duplicate setup-node from test actions 2026-01-28 17:18:39 +02:00
Aaro Koivukangas fb673950b7 [CI] Add api-tests to ci 2026-01-28 17:18:39 +02:00
Aaro Koivukangas 4b052a1b13 [Dev] Add a script to run api-tests in docker 2026-01-28 17:18:39 +02:00
Aaro Koivukangas 34d0509572 [api-tests] Add @types/supertest 2026-01-28 17:18:39 +02:00
Aaro Koivukangas 5e8a5022cd [api-test] Add basic api test for changelog 2026-01-28 17:18:39 +02:00
Aaro Koivukangas a989f98524 [Changelog] Change to fake changelogs in test env 2026-01-28 17:18:39 +02:00
Santeri Rajamäki 6076b3d45a [Faculties] Relax programme and yearIndex requirements for ExpandableRow data 2026-01-27 16:40:22 +02:00
Santeri Rajamäki 3d4a09f2ef [OT] Add basic documentation for features 2026-01-27 15:02:41 +02:00
Santeri Rajamäki c6e40d27a4 [Frontend] Commit the rest of the changes 2026-01-27 15:00:59 +02:00
Santeri Rajamäki 016d2b6ec8 [Frontend] Remove lodash chain from project 2026-01-27 12:51:30 +02:00
joonas-a 2754874e67 [Eslint] Unban all 1 char long identifiers
Harmful and inconsistent rule making some reduces/maps/filters bloated
for absolutely no reason
2026-01-27 00:23:47 +02:00
joonas-a 64352a6e24 [Chore] remove few lodash chains
lodash-es chain method does not work well with vite in prod/e2e
2026-01-27 00:19:43 +02:00
Santeri Rajamäki 4f290d45eb [CI] Use node 24 before docker-compose in run-tests/action.yml 2026-01-26 20:31:17 +02:00
Santeri Rajamäki 27a75877bd [Backend] Use imports instead of require in languageCenterData 2026-01-26 20:31:17 +02:00
santeri0200andGitHub 3f9af3b5f6 Merge pull request #4930 from UniversityOfHelsinkiCS/renovate/major-github-artifact-actions
Update GitHub Artifact Actions (major)
2026-01-26 20:20:30 +02:00
Santeri Rajamäki 359a18be92 [Frontend] Remove already replaced package immer 2026-01-26 19:58:14 +02:00
Santeri Rajamäki b6a1e07fb4 [Frontend] Unify data passing in Courses of Class tabs 2026-01-26 19:49:29 +02:00
Santeri Rajamäki 63d8b12273 [Backend] Convert logger to typescript 2026-01-26 17:56:43 +02:00
Aaro Koivukangas 124b203629 [META] Update remaining package-lock.jsons 2026-01-26 16:13:52 +02:00
Aaro Koivukangas ada19dc3a8 [META] Update package-lock.json to node 24 2026-01-26 16:07:32 +02:00
Aaro Koivukangas eba2a775ae [CI] Acual change cypress node version to 24 2026-01-26 15:47:25 +02:00
Aaro Koivukangas f74342d25d [CI] Update cypress node version to 24 2026-01-26 15:31:24 +02:00
Santeri Rajamäki 8b133fae64 [Backend] Use correct import style for local files 2026-01-26 12:22:31 +02:00
Santeri Rajamäki 6100aaaa0b [META] Update node env from 22 to 24 2026-01-23 17:20:23 +02:00
joonas-a 5a2f7196a2 [Programme overview] Fix sorting by type col flaky
sometimes there could be undefined which messes things up
2026-01-23 17:19:53 +02:00
Santeri Rajamäki 53684200ca [E2E] Remove deprecated option experimentalStudio
The option was deprecated in 15.4.0, where the studio was made available for everyone.
2026-01-23 16:50:41 +02:00
joonas-a c3b24d3d5d [E2E] Fix degree programme overview tests 2026-01-23 16:45:02 +02:00
joonas-a 20b40ec5c8 [RTK api] remove unnecessary ampersand from route 2026-01-23 16:45:02 +02:00
Santeri Rajamäki 4244e70d68 [META] Update imports from lodash to lodash-es 2026-01-23 16:43:25 +02:00
Santeri Rajamäki 7480a063c3 [META] Change type packages from to 2026-01-23 16:42:23 +02:00
Santeri Rajamäki 37b4b31a9a [META] Upgrade lodash and lodash-es to the latest lodash-es variant 2026-01-23 16:33:39 +02:00
Santeri Rajamäki 159ccc3655 [META] Remove lodash from global dependencies 2026-01-23 16:22:00 +02:00
Santeri Rajamäki 380168b425 [Courses of class] Simplify module/course parse logic 2026-01-23 16:13:43 +02:00
Santeri Rajamäki 14ed9fa99a [Filters] Reword studyRightType filter logic 2026-01-23 15:58:50 +02:00
Santeri Rajamäki adcac058a6 [Filters] Reword graduatedFromProgramme filter logic 2026-01-23 15:58:50 +02:00
Santeri Rajamäki 03efb92936 [Filters] Simplify studentNumber filter actions 2026-01-23 15:58:50 +02:00
Santeri Rajamäki a0f3a1d4ab [Frontend] Simplify getStudentTotalCredits 2026-01-23 15:58:50 +02:00
joonas-a 27c3291049 [Meta] Delete unused file 2026-01-23 15:58:21 +02:00
joonas-a 706ada2b6f [Programme courses] Remove default sort as unneeded 2026-01-23 15:58:21 +02:00
joonas-a 41ff70cd45 [Programme overview] Add excel export 2026-01-23 15:58:21 +02:00
Aaro Koivukangas c065cfd2ed [E2E] Remove deprecated test 2026-01-23 15:36:40 +02:00
Aaro Koivukangas 9ac6b3db84 [Meta][ProgressCharts] Remove Include graduated toggle from all views 2026-01-23 15:27:51 +02:00
Santeri Rajamäki b3d7722d79 [Filters] Use precompute in admissionType filter instead of recalculating the same value each render 2026-01-23 15:00:50 +02:00
Santeri Rajamäki 197fee44ad [Docs] Partial update to resources.md 2026-01-23 15:00:50 +02:00
joonas-a 5c6f6ec689 [Programme courses] Default sort by total credits 2026-01-23 14:31:52 +02:00
Santeri Rajamäki 6fbd93b97d [META] Update Cypress minor version to 15.9 2026-01-23 14:20:22 +02:00
joonas-a 95b3695fc5 [Programme overview][Programme courses] Hide text overflow in name col 2026-01-23 14:15:44 +02:00
joonas-a bb547e2f51 [Programme overview][Programme courses] ditch MRT 2026-01-23 14:09:23 +02:00
Santeri Rajamäki 858f82d93e [Filters] Use mutate attribute instead of mutating in filter attribute 2026-01-23 13:54:24 +02:00
Aaro KoivukangasandGitHub 33cea662b9 Merge pull request #4948 from UniversityOfHelsinkiCS/progress-chart-graduated-category
Progress chart graduated category
2026-01-23 12:46:20 +02:00
Aaro Koivukangas fc75dfd2dd [StudyTrackStats+E2E] Include graduated back in all students in charts 2026-01-23 12:37:23 +02:00
Santeri Rajamäki f0fc96f07f [Faculties] Fix bcMs credit intervals to take in count the SRE based timings 2026-01-22 18:05:54 +02:00
joonas-a df08a30f2d [Programme overview] Programme courses show credits correctly
remove default sort for now until ooditable migration
2026-01-22 17:49:57 +02:00
Aaro Koivukangas 97d332d50e [E2E] maybe soon 2026-01-22 17:45:14 +02:00
Aaro Koivukangas 860488977f [E2E] 4th e2e fix 2026-01-22 17:26:46 +02:00
Aaro Koivukangas 6b1ca59559 [E2E] Fix e2e 3rd time 2026-01-22 17:15:29 +02:00
Aaro Koivukangas 071038a1fa [E2E] More e2e fixes 2026-01-22 17:01:17 +02:00
Aaro Koivukangas 392e28010d [E2E] Fix e2e test 2026-01-22 16:38:58 +02:00
Aaro Koivukangas c1c7d3d254 [StudyTrackStats] Exclude graduated from credit counts 2026-01-22 16:34:02 +02:00
Aaro Koivukangas c35701bb5d [University+Faculty] Add graduated as category to ProgressCharts 2026-01-22 12:00:14 +02:00
Aaro Koivukangas b7e8b6fad1 [CI] manual lint fixes 2026-01-19 14:13:25 +02:00
Aaro Koivukangas f355577c8a [StudyProgramme] Add graduated as category to ProgressCharts 2026-01-19 13:22:29 +02:00
joonas-a 2bd78a587a [E2E] Fix graduation time calculation 2026-01-12 17:01:54 +02:00
joonas-a 294b624e5a [Graduations] Allow 4 absences if study right start before 1-8-2015 2026-01-12 16:20:20 +02:00
joonas-a 2eacb56ce2 [Graduations] Add 2 free absences
First two non-statutory absences will no longer drain study time
2026-01-12 16:20:20 +02:00
Santeri Rajamäki e384d734a6 [Filters][Grade] Use fallback if selectedGrade fails to show up 2026-01-12 14:29:17 +02:00
joonas-a a9ab7621bf [Programme overview] Fix labels in studies after programme chart 2026-01-12 13:19:47 +02:00
joonas-a 98ea010d64 [Graduations] Route typings 2026-01-12 13:11:45 +02:00
Aaro Koivukangas 304b16c73c [CourseStats] Fix incorrect precomputed use 2026-01-12 12:40:30 +02:00
Aaro Koivukangas 82a726190e [CourseStats] Get course name for title from FilterView 2026-01-12 12:15:02 +02:00
Santeri Rajamäki af21972e88 [Faculty] Progrss tab credit accumulation table for bachelor+master now shows credit limits correctly 2026-01-09 18:39:52 +02:00
Santeri Rajamäki cf7e34f4ce [Faculty] Add programme level color coding 2026-01-09 17:30:57 +02:00
Santeri Rajamäki 57cff95ba3 [Frontend] Swap AssignmentCheckedIcon with SwapHorizIcon 2026-01-09 16:43:20 +02:00
Santeri Rajamäki b7c7a24a3f [PopulationStats][Courses] Add transferred icon to APPROVED credits 2026-01-09 16:43:20 +02:00
joonas-a 0769b38b98 [Routes/programme overview] Type creditstats 2026-01-09 15:18:44 +02:00
Santeri Rajamäki 6557f7db55 [Populations] Add enrollments to course stats before credits 2026-01-09 14:42:39 +02:00
joonas-a 9e6b9be0e2 [Backend] Type creditstats 2026-01-09 14:32:43 +02:00
joonas-a 52b92e4cb6 [Routes] Type basicstats 2026-01-09 12:35:16 +02:00
Santeri Rajamäki e23cb37121 [Backend] Rewrite faculty route types 2026-01-07 18:31:29 +02:00
Aaro Koivukangas 1c6b9a60ed [StudyTrack] Add programme code and study track name to programme {sub,}title 2026-01-07 18:04:08 +02:00
Santeri Rajamäki 30e7f832f7 [Filters][StudyTrack] Add ability to read student study tracks 2026-01-07 15:48:18 +02:00
Aaro Koivukangas 62b5009391 [CI] Fix slackbot link url 2026-01-07 13:06:43 +02:00
joonas-a 6dd7dbc803 [Faculties/University] Remove legacy datafields
lastUpdated and Status have not been in use for a long time
2026-01-07 12:10:07 +02:00
Aaro Koivukangas e8f4ed8ae6 replace studytrack status 'inactive' to 'passive' 2026-01-07 11:26:44 +02:00
Santeri Rajamäki f4678a8714 [E2E][CourseStats] Use correct logic in summary checks 2026-01-03 17:33:39 +02:00
Santeri Rajamäki 25730450af [E2E][CourseStats] Use correct course name 2026-01-03 17:29:09 +02:00
Santeri Rajamäki e329f6e311 [E2E][CourseStats] Use BSCS instead of TKT to fetch course name from test data 2026-01-03 17:27:13 +02:00
Santeri Rajamäki abd48cb8c1 [Frontend] Remove unused redux endpoint query types 2026-01-03 17:07:06 +02:00
Santeri Rajamäki 033ab8f328 [CourseStats] Remove unnecessary checks from "passed" condition 2026-01-03 17:02:40 +02:00
Santeri Rajamäki 8fdac9827b [CourseStats] Fix grade parsing in yearly stats counter 2026-01-03 17:02:10 +02:00
Santeri Rajamäki 8baada888b [CourseStats] Readd credittypecode to credits 2026-01-03 17:01:09 +02:00
Santeri Rajamäki 01e6317386 [Frontend] Remove unused RTKApi endpoint 2026-01-03 16:42:40 +02:00
Santeri Rajamäki 17cff4dc5f [CourseStats] Show population button uses the same logic as links 2026-01-03 16:16:14 +02:00
Santeri Rajamäki 182a35079b [CourseStats] Remove unecessary yearly statistics call 2026-01-03 15:47:29 +02:00
Santeri Rajamäki 422e2776e3 [CourseStats] Set primary course as the first alternative 2026-01-03 15:46:21 +02:00
Santeri Rajamäki 2e6132e09c [CourseStats] CourseYearlyStats clean up 2026-01-03 14:57:55 +02:00
Santeri Rajamäki 885de8b64c [Backend] Fix auto added path 2026-01-02 16:40:14 +02:00
Santeri Rajamäki c632173a86 [CourseStats] Avoid data refresh on tab change 2026-01-02 16:31:48 +02:00
Santeri Rajamäki 2dc1797bc4 [Shared] Add courseExclusions route 2026-01-02 16:30:55 +02:00
Santeri Rajamäki bba918812c [CourseExclusion] Add rights check to exclusion post and delete 2026-01-02 12:53:09 +02:00
Santeri Rajamäki b8f77275ff [Importer] Add timeout to importer client and remove Promise.races 2026-01-02 12:23:03 +02:00
Santeri Rajamäki 39fdeb5321 [CloseToGraduation] Show loading section instead of empty table on fetch 2026-01-02 12:22:59 +02:00
Santeri Rajamäki edbd41afc7 [CloseToGraduation] Share backend types with frontend 2026-01-02 12:22:56 +02:00
Santeri Rajamäki 16c9580ed4 [Changelog] Redo types and first fetch logic 2026-01-02 12:22:51 +02:00
joonas-a 82b4e99f7b [Frontend] Fix atmospheric sciences
In faculties and in class overview
2025-12-19 14:31:36 +02:00
Santeri Rajamäki 1eb1527260 [Updater] Use selectFromByIds instead of hardcoded version to find missing course codes 2025-12-18 17:18:07 +02:00
Santeri Rajamäki 82a4d2a08d [Updater] Add JSDocstrings to worker db index 2025-12-18 17:15:59 +02:00
Santeri Rajamäki 91fa0106f5 [ExcelExport] Fix semester enrollments in all FilterViews 2025-12-17 19:33:32 +02:00
Santeri Rajamäki ad8c51fce6 [ProgressTab] Add fallback to header attribute to avoid crashing 2025-12-17 18:31:12 +02:00
Santeri Rajamäki 4d012699e2 [Filters][Date] Reword infotext 2025-12-17 17:38:34 +02:00
Santeri Rajamäki a5af4ffcae [CourseStats] Partially rewrite controlflow logic 2025-12-17 17:06:19 +02:00
Santeri Rajamäki cab573eebb [Backend] Fix invalid import
It seems that types are never actually resolved
2025-12-17 15:40:56 +02:00
Santeri Rajamäki 5d3c5c99c5 [CourseStats] Simplify alternatives type 2025-12-17 15:27:24 +02:00
Santeri Rajamäki 577de97741 [Filters][Date] Change filter title 2025-12-16 10:21:01 +02:00
Santeri Rajamäki 60d95e45d2 [Filters][Date] Change course credits to course participation 2025-12-16 10:08:19 +02:00
renovate[bot]andGitHub 67ab0db6d1 Update GitHub Artifact Actions 2025-12-12 22:57:34 +00:00
Santeri Rajamäki 34ae6f279b [DegreeProgramme] Fix visibility chip for module courses 2025-12-12 17:08:04 +02:00
joonas-a 1c91554a3b [Frontend] Theme refresh
Increased contrast and consistency

Also moved unihow export to ooditable toolbar in custom population
2025-12-12 16:54:18 +02:00
Santeri Rajamäki e53c0c2de4 [CourseStats] Move Redux hook to useState 2025-12-12 14:25:41 +02:00
Santeri Rajamäki 25deee6016 [CourseStats] Use full data in summary tab 2025-12-12 14:02:15 +02:00
Santeri Rajamäki f8cc05afda [Frontend][Highchart] Remove unused (due to data drawn) start date from CreditAccumulation 2025-12-12 14:02:15 +02:00
joonas-a 9b46c9f43f [Courses of population] Use new number selector
Fixes debounce etc
2025-12-12 13:38:47 +02:00
Santeri Rajamäki 654a2a6604 [E2E][CourseStats] Use correct column for grade matching 2025-12-12 10:00:27 +02:00
Santeri Rajamäki 27a9ac8225 [Frontend] Add retry on error card to Population statistics 2025-12-12 09:57:54 +02:00
Santeri Rajamäki b8d93af641 [Filters] Reword date filter mutate function 2025-12-12 09:23:48 +02:00
joonas-a 33ee9d142d [Course pop] New col to general tab: associated programme
Same programme that is used in computing the programme distribution tab
2025-12-11 18:07:14 +02:00
joonas-a 25ece19e36 [Course pop] lang dist: rephrase error text 2025-12-11 18:07:14 +02:00
Santeri Rajamäki 39ac7a2665 [Frontend] Introduce mutation as a standalone method in filters 2025-12-11 16:20:35 +02:00
joonas-a fb35bc0c2b [Course search] Show no results if no results 2025-12-11 15:32:35 +02:00
joonas-a ce08567bb4 [Course search] make multisearch notice fit in with rest of the app 2025-12-11 11:52:23 +02:00
joonas-a 89f08dabc5 [Course search] remove arbitrary limit in multisearch
limit of 99999 was nothing but clutter
2025-12-11 11:31:51 +02:00
Santeri Rajamäki d6f414dc71 Change comment to override GH issue 2025-12-10 21:46:02 +02:00
Santeri Rajamäki d9a8a87c2f [Frontend] Make Class statistics Students ellipsed spans respect set maxWidth 2025-12-10 20:06:46 +02:00
Santeri Rajamäki 2bab5c6ef0 [DegreeCourses] Use course.code instead of random id 2025-12-10 19:52:31 +02:00
Santeri Rajamäki 8cf9eae693 [Frontend] Make Class statistics Students study track respect set maxWidth 2025-12-10 19:39:59 +02:00
Santeri Rajamäki 535cd08593 [Frontend] Force courses of class percentages toFixed(2) 2025-12-10 19:32:57 +02:00
Santeri Rajamäki f3fd84598a [Frontend] Fix course filter toggle texts 2025-12-10 19:30:14 +02:00
Santeri Rajamäki ceb813636a [Backend] Simplify getCoursesByNameAndOrCode function 2025-12-10 19:13:26 +02:00
joonas-a c51f90ab1a Hotfix LSP powered file path 2025-12-10 19:12:58 +02:00
Santeri Rajamäki 12a0a38ce0 Hotfix 2025-12-10 19:05:01 +02:00
Santeri Rajamäki ef25eeae40 [E2E] Course statistics search form should use main course code for selection 2025-12-10 19:05:01 +02:00
joonas-a 70b10480a4 [Course search] fix substitutions fetched by not-main-course sometimes 2025-12-10 19:03:02 +02:00
joonas-a 11bfd0b8c5 [Course search] Add switch to include special courses
e.g. courses created from credit transfer
2025-12-10 18:25:14 +02:00
Santeri Rajamäki a45ec5537e [Backend] Fix population array to set conversion 2025-12-10 18:03:00 +02:00
Santeri Rajamäki 165fff0163 [Backend] Rework population obfuscation function 2025-12-10 18:02:59 +02:00
Santeri Rajamäki a6df2e8d50 [Filters] Retype course filter cards 2025-12-10 18:02:59 +02:00
Santeri Rajamäki bfeb3ef9b1 [Frontend] Extend FilteredCourse type 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 4fdbba9fe1 [Filters] Remove course filter card population check 2025-12-10 18:02:59 +02:00
Santeri Rajamäki fe52b06696 [Backend] Remove obfuscation from course population data 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 54e60279f7 [Shared] Remove unused module courseData 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 2ef35ced6b [Meta] Rework class population data 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 386290288f [Backend] Remove obfuscation from class population query 2025-12-10 18:02:59 +02:00
joonas-a b1d33dc64c [Course search] Fix sort order 2025-12-10 17:38:38 +02:00
joonas-a 7d4c8c99bf [Course search] Dont show special courses by default
Courses e.g. from result of hyväksilukeminen with random a-z - 0-9
codes, and other courses with no "main_course_code" in db
2025-12-10 17:00:36 +02:00
joonas-a ed7e03169a [Course stats] add/correct typing 2025-12-05 15:18:37 +02:00
joonas-a eaa9a17a49 [Lint] silence warning in case component still needed 2025-12-05 13:55:43 +02:00
joonas-a 6c463b6b3b [Cleanup] Delete unused tooltips 2025-12-05 13:41:36 +02:00
joonas-a 79c04ba2a6 [Course stats] style + tsx credit gains 2025-12-05 13:41:04 +02:00
joonas-a b0c4d8bb8c [Frontend] Add dummy facultyCode to NO_PROGRAMME 2025-12-05 13:40:18 +02:00
joonas-a cf0796bb88 [Course stats] Finish courses of population styling/debounce 2025-12-04 20:24:25 +02:00
joonas-a 9a23799972 [Frontend] finish StudentAmountLimiter component 2025-12-04 20:15:18 +02:00
joonas-a 010829b96c [Frontend] New component for numerical TextField
Has internal state and works with debounce
2025-12-04 20:05:15 +02:00
Santeri Rajamäki b921bc0891 [Populations] Add secondary email to other filterview student > general tab excels 2025-12-02 10:14:01 +02:00
Joonas AhovalliandGitHub aa6ce4ab28 Remove SortableTable documentation link
Removed link to SortableTable documentation from README.
2025-12-02 09:55:35 +02:00
joonas-a b5b211e750 [Frontend] Use same old/new programme filtering logic everywhere 2025-12-01 20:18:45 +02:00
joonas-a c114121a7a [General tab] filter out inactive study rights from primary programme column
This is to match the behaviour of previously changed programme
distribution panel
2025-12-01 20:00:31 +02:00
joonas-a 31eb9b2101 [Backend] Make student obfuscation fn return dummy studyplans array 2025-12-01 17:15:16 +02:00
Santeri Rajamäki 55c1a5f93b [Backend] Rename isOpenUniCourseCode to getOpenUniCourseCode to reflect what the function is actually used for 2025-12-01 17:12:06 +02:00
Santeri Rajamäki 2311a9c027 [E2E][SGG] Replace mirrored state with same the label text 2025-12-01 16:56:01 +02:00
Santeri Rajamäki 62b7864ffe [E2E][SGG] Fix contains dated field 2025-12-01 16:45:36 +02:00
Santeri Rajamäki f1000a8f25 [E2E] Readd nav-bar cypress tag to ToolBar 2025-12-01 16:41:58 +02:00
Santeri Rajamäki 4e566827f7 [Students] Fix backend crash when return value is null instead of valid value 2025-12-01 15:36:32 +02:00
Santeri Rajamäki 56f5f730c4 [CustomPopulation] Add secondary email to custom population excel 2025-12-01 15:29:20 +02:00
Santeri Rajamäki 556999614c [PopulationStatistics] Add secondary email column index 2025-12-01 15:29:20 +02:00
joonas-a 1c4e3721c6 [Custom population] Update distribution infobox accordingly 2025-11-28 16:36:51 +02:00
joonas-a 7032902c75 [Lint] Alphabetical ordering of props (???) 2025-11-28 16:21:08 +02:00
joonas-a e3928ebd5f [Programme distribution] remake component in custom/course population views 2025-11-28 16:15:14 +02:00
joonas-a 917931378a [Meta] Delete unused css file 2025-11-26 13:19:24 +02:00
joonas-a ff93d54470 [Course pop] Add missing margin 2025-11-25 17:47:41 +02:00
Santeri Rajamäki 402ee672a1 [StatisticsOf] Readd studyright_id to enrollments 2025-11-25 17:46:22 +02:00
joonas-a d5fc584d93 [Frontend] New component StyledCell to use with StyledTable 2025-11-25 16:46:31 +02:00
joonas-a 11a390128f [Course stats] Revisit language distribution 2025-11-25 16:08:19 +02:00
joonas-a f80ef6fb40 [Course stats] Reword tooltip 2025-11-25 16:08:19 +02:00
joonas-a 78e31f0a3e [Course stats] Revisit grade distribution 2025-11-25 16:08:19 +02:00
Santeri Rajamäki 651b3b5c0d [Filters] Clarify studyright status filtering 2025-11-25 16:07:24 +02:00
joonas-a 66e666c99a [Courses of class] UI tweaks 2025-11-25 15:29:37 +02:00
Santeri Rajamäki 464cdff246 [META] Fix lint errors 2025-11-25 15:26:43 +02:00
Santeri Rajamäki 58c35f21b0 [Students] Fix falsy query skip logic 2025-11-25 15:26:43 +02:00
Santeri Rajamäki ae1c7765a1 [StatisticOf] Fix mock date 2025-11-25 15:26:43 +02:00
joonas-a 59e2258da3 [SGG] Make credit cutoff button a switch 2025-11-25 14:34:06 +02:00
joonas-a 5f8dc44958 [Frontend] Remake toggleWithTooltip component 2025-11-25 14:25:12 +02:00
joonas-a c8b4586ea1 [Class stats] Credit accumulation panel betterments 2025-11-25 13:49:21 +02:00
joonas-a b04859639f [Panelview] Make more consistent 2025-11-25 13:25:26 +02:00
joonas-a 8fba294dd8 [Progress tab] Add student name toggle 2025-11-21 18:41:05 +02:00
joonas-a 714f3bc953 [Tags tab] center spinner + add name toggle 2025-11-21 18:39:31 +02:00
joonas-a 7247049564 [Class stats] Move table actions to OT toolbar 2025-11-21 18:21:15 +02:00
Santeri Rajamäki ff6646ebb4 [Frontend] Remove more wrapping divs 2025-11-21 17:03:10 +02:00
joonas-a d77c735030 [Ooditable] Integrate all excel buttons 2025-11-21 16:24:24 +02:00
joonas-a aaefbfa9fe [Language center view] revisit styles 2025-11-21 16:24:24 +02:00
joonas-a 7ec487412f [Ooditable] Integrate excel export 2025-11-21 16:24:24 +02:00
joonas-a f7c84ca06f [Ooditable] Delete unused css 2025-11-21 16:24:24 +02:00
joonas-a bc46a18151 [Ooditable] Add missing docstring 2025-11-21 16:24:24 +02:00
Santeri Rajamäki fcd8eb9284 [Frontend] Use CSS variables in themes 2025-11-21 16:19:40 +02:00
Santeri Rajamäki d0c52f301f [Frontend] Convert Container components to PageLayout and fix erroneous trees 2025-11-21 16:18:45 +02:00
Santeri Rajamäki a606c21cd3 [Frontend] Separate Layout component from App state 2025-11-21 16:10:50 +02:00
Santeri Rajamäki dc496618f3 [Backend] Update util types 2025-11-21 14:48:02 +02:00
Santeri Rajamäki 099e60b617 [Backend] Update student routing types 2025-11-21 14:37:49 +02:00
Santeri Rajamäki c39fdcd77a [Backend] Update course routing types 2025-11-21 14:36:50 +02:00
Santeri Rajamäki 907e24e26e [Shared] Add uniq util function 2025-11-21 13:38:43 +02:00
Santeri Rajamäki 664d8fb83a [Footer] Make DOM shallower and adapt design to NavBar 2025-11-21 11:01:38 +02:00
Santeri Rajamäki 3618d318eb [NavBar] Make DOM shallower 2025-11-21 10:38:52 +02:00
joonas-a cc09af092f [Backend] Remove benchmarking console.logs 2025-11-20 14:47:28 +02:00
Santeri Rajamäki 48a77d2b12 [META] Run install:local 2025-11-18 13:30:21 +02:00
Santeri Rajamäki 99a598e773 [META] Update node requirement to version 24 and update package-locks 2025-11-18 13:27:31 +02:00
Santeri Rajamäki c9455d2fde [Frontend] Make population course amount limiter consistent with debounced state 2025-11-18 13:04:09 +02:00
Santeri Rajamäki a343b2460a [Frontend] Fix :first-child rendering error 2025-11-18 12:37:45 +02:00
Santeri Rajamäki 7ca612364b [Backend] Remove unused type import
Validator types are used with sequelize-typescript, but they are imported at least by sequelize.
No explicit import is needed.
2025-11-18 12:17:24 +02:00
Santeri Rajamäki 6bed41dd9e [Frontend] Convert population roots to typescript 2025-11-18 12:06:27 +02:00
joonas-a 392d5ea3b0 [Frontpage] Remove mention of service break 2025-11-18 09:04:43 +02:00
Santeri Rajamäki 537ab19fb6 [CoursePopulation] Use PageTitle title correctly 2025-11-14 15:56:40 +02:00
Santeri Rajamäki 74b566f574 [Backend] Rework population shared maps 2025-11-14 15:31:30 +02:00
Santeri Rajamäki 509fdeadfa [Backend] Rewrite parts of uncached user login 2025-11-14 14:57:57 +02:00
Santeri Rajamäki 1f13389954 [Frontend] Remove unused SegmentContainer 2025-11-13 17:50:00 +02:00
Santeri Rajamäki e699139f4b [Frontend] Adapt FilterViews to use PageTitle and PageLayout 2025-11-13 17:22:50 +02:00
Santeri Rajamäki 9f9786a3bb [E2E] Fix Course of Class expansion tests 2025-11-13 14:55:31 +02:00
Santeri Rajamäki 7c79d23a47 [Frontend] Replace currently invalid font-weights 2025-11-13 14:36:23 +02:00
Santeri Rajamäki 85eb3c9634 [Frontend] Add Roboto 500 to the project
Font-weight 500 is used by MUI for weight "Medium"
2025-11-13 14:30:20 +02:00
Santeri Rajamäki 25f7f46a36 [Frontend] Replace default Roboto import with the correct weighted ones 2025-11-13 14:10:11 +02:00
Santeri Rajamäki 569e411f7c [DOCS] Remove references to Semantic UI 2025-11-13 04:21:33 +02:00
Santeri Rajamäki d7f8024d27 [Frontend] Add explicit Roboto font import
The font was loaded from google servers before.
2025-11-13 04:18:54 +02:00
Santeri Rajamäki ca429a7b68 [Frontend] Remove "New user interface" notification 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 01bc1f11f4 [Frontend] Move FacultyProgress and FacultyProgressTable components from components/material to components/Faculties 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 50aad14a39 [Frontend] Move ProgrammeDropdown component from components/material to components/CourseStatistics 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 76624da6c6 [Frontend] Merge HelpInfoCard component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f7eba6b2d7 [Frontend] Move TableHeaderWithTooltip component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 6aa7762acb [Frontend] Merge CheckIconWithTitle component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 1f61ab7b81 [Frontend] Move DataTable component from components/material to components/StudyProgramme 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5681ef9f97 [Frontend] Move CurriculumPicker component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 7e903456ae [Frontend] Move MRTExcelExport component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ece089b5b6 [Frontend] Move FilterOldProgrammesToggle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 177b2c39a5 [Frontend] Merge RightsNotification component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f399041163 [Frontend] Move StudentNameVisibilityToggle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 7f238c5ccb [Frontend] Move StudentInfoItem component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ce41a9a3e3 [Frontend] Move MedianTimeBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki a117ce0fd4 [Frontend] Move ProgressBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki fe5418575e [Frontend] Move StackedBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 08adaed6d7 [Frontend] Move LineGraph component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki fc8d59af72 [Frontend] Move BreakdownBarChart component from components/material to components/GraduationTimes
BreakdownBarChart is moved and renamed as BreakdownBarChartV2 as the file contains intent to merge both BreakdownBarChart files.
2025-11-13 04:18:54 +02:00
Santeri Rajamäki 9e7c84d053 [Frontend] Move TotalsDisclaimer component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 3e69cfae20 [Frontend] Move MockButton component from components/material to components/Users 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 69b9301c17 [Frontend] Move PageTitle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 4cae8eaac2 [Frontend] Move ProgressBar component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 6ea3f9a922 [Frontend] Move UpdateButton component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ea7d4b1554 [Frontend] Move SearchHistory component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 426658f8b0 [Frontend] Move ExpandableRow component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 72a6778ae5 [Frontend] Move RoleChip component from components/material to components/Users 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 8c2a2d113e [Frontend] Move StyledAccordion component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 27dbf0b575 [Frontend] Move StyledTable component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 2ff9ae7843 [Frontend] Move loading components from components/material to components/Loading 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ee8a7b016a [Frontend] Move infobox component from components/material to components/InfoBox 2025-11-13 04:18:54 +02:00
Santeri Rajamäki c7831b7f66 [Frontend] Move navigation components from components/material to components/NavigationBar 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5895021a48 [Frontend] Move graduation time components from components/material to components/GraduationTimes 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ced54e7a9b [Frontend] Move section components from components/material to components/Section 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 25cd18e025 [Frontend] Move footer components from components/material to components/Footer 2025-11-13 04:18:54 +02:00
Santeri Rajamäki c08b2cf37c [Frontend] Move status notification components from components/material to components/StatusNotifications 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 2da42ed93e [Frontend] Move error components from components/material to components/ 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f2e7893c60 [Frontend] Move toggle components from components/material to components/common/toggle 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5b348c37db [Frontend] Move link components from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki bb6ca028c7 [Frontend] Remove class items from custom.css 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 340d46c739 [Frontend] Remove Semantic UI 2025-11-13 04:18:53 +02:00
Santeri Rajamäki 52f77935fc [Frontend] Remove Semantic UI icon from Highcharts charts 2025-11-13 04:17:50 +02:00
Santeri Rajamäki d35cbc1085 [Frontend] Remove SortableTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 6f17bb002a [CoursesOfPopulation] Context cleanup 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 692e704108 [Frontend] Make filter toggle button small 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 31c45581e0 [Teachers] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki cb3ed86231 [OpenUni] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki de85a38abe [ProgressTab] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki e28a1e5e6f [CoursesOfClass] Style "when passed" OodiTable export row 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 66bd4be4ed [CoursesOfClass] Convert "when passed" SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 37fbb5cbb3 [CoursesOfClass] Remove on-demand rendering from tabs 2025-11-13 04:17:50 +02:00
Santeri Rajamäki c31fac4419 [CoursesOfClass] Convert pass/fail SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 7760f60ff5 [CoursesOfClass] Convert grades SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 7b65539873 [Frontend] Remove unnecessary imported package
Scheduler is a react internal package, never used in the project.
2025-11-12 23:23:31 +02:00
joonas-a fe4bda7ed8 [Backend] Add temp logging to statisticsOf 2025-11-11 18:12:12 +02:00
joonas-a 6ccaec1643 [Backend] Add missing function 2025-11-11 18:03:32 +02:00
joonas-a df01c3b693 [Backend] make class stat req mankeling faster 2025-11-11 17:37:23 +02:00
joonas-a b7235ff4b4 HOTFIX: capital O in OODIkone 2025-11-10 18:10:30 +02:00
joonas-a 84d8a6bfec [Frontpage] Add announcement on service break 2025-11-10 18:10:29 +02:00
joonas-a 24ccb9de58 [E2E / lang center] Fix tests + remove useless test 2025-11-10 17:50:44 +02:00
joonas-a faac3e968e [E2E] Add missing cy selectors 2025-11-10 17:42:22 +02:00
Santeri Rajamäki 3407af8215 [ColorizedCourseTable] Add semester length check to "By semesters" table column memoization 2025-11-10 17:24:50 +02:00
Santeri Rajamäki 94d289194a [ColorizedCourseTable] Move total column in "By semesters" table 2025-11-10 17:17:24 +02:00
Santeri Rajamäki 39c21b261a [ColorizedCourseTable] Fix column pinning in "By faculties" table 2025-11-10 17:01:09 +02:00
Santeri Rajamäki 74ae6f3d46 [ColorizedCourseTable] Move total column in "By faculties" table 2025-11-10 17:00:32 +02:00
Santeri Rajamäki 3f9ce90834 [ColorizedCourseTable] Add totla sorting to "By faculties" table 2025-11-10 16:57:46 +02:00
Santeri Rajamäki b235fa7883 [SortableTable] Remove unused features 2025-11-10 15:40:46 +02:00
joonas-a e2070248be [E2E / completed courses] flakiness reduce 🪄 2025-11-10 15:28:44 +02:00
Santeri Rajamäki 97068be78e [ColorizedCourseTable] Convert "By faculty" table to OodiTable 2025-11-10 15:26:56 +02:00
joonas-a 53dd55f874 [E2E / teachers] Fix tests 2025-11-10 15:13:04 +02:00
joonas-a 3c99a30a8c [E2E / open uni] skip all tests 2025-11-10 15:05:39 +02:00
joonas-a 5fd94fd0d0 [E2E / SGG] Fix tests 2025-11-10 15:02:04 +02:00
joonas-a c36ce33f7f [E2E / class stats] fix tests 2025-11-10 14:55:06 +02:00
joonas-a ff6bcd6658 [E2E / custom population] Fix tests 2025-11-10 14:00:30 +02:00
joonas-a 53e75b6164 [E2E filters] fix tests 2025-11-10 12:43:01 +02:00
joonas-a a65c4d448b [Custom population] facelift + refactor + TS + MUI 2025-11-10 12:18:11 +02:00
joonas-a f9a4d85ce2 [Frontend] Type betterments to class stats views*
*with a twist
2025-11-10 12:18:11 +02:00
Santeri Rajamäki c610a53168 [ColorizedCourseTable] Convert "By semester" table to OodiTable 2025-11-07 13:37:44 +02:00
Santeri Rajamäki ad40e02442 [OodiTable] Add theming for zebra stripes 2025-11-07 13:26:01 +02:00
Santeri Rajamäki 6b6e070deb [ColorizedCourseTable] Update getColor to use simpler logic 2025-11-07 13:13:03 +02:00
Santeri Rajamäki 253f31d7d0 [OodiTable] Add Zebra stripe feature 2025-11-07 13:06:11 +02:00
joonas-a 551775bcec [Course population] Fix broken layout 2025-11-04 15:49:54 +02:00
joonas-a 70e8d6f033 [Class stats] style unification vol 1 2025-11-04 15:44:44 +02:00
Santeri Rajamäki ea505b5d84 [Frontend] Hide ColorizedCoursesTable tabs when only one option is available 2025-11-03 12:17:35 +02:00
Santeri Rajamäki 344a3c00ef [SortableTable] Remove actions entry from README 2025-11-03 12:09:32 +02:00
Santeri Rajamäki b29984f589 [SortableTable] Remove unnecessary features 2025-11-03 12:08:19 +02:00
Santeri Rajamäki 0ad6d6f9a2 [Frontend] Fix validation to spec and move util to shared 2025-11-03 11:47:21 +02:00
Santeri Rajamäki 6bf1d9a590 [Frontend] Remove unused util 2025-11-03 11:36:17 +02:00
Santeri Rajamäki 430fc97917 [Teachers] Convert Sortabletable to MUI table 2025-11-03 11:35:17 +02:00
joonas-a 7ba2d3489e [StyledMessage] Correct prop naming 2025-10-31 17:55:47 +02:00
joonas-a 1e68c959b9 [SGG] Fix redundant loading spinner logic 2025-10-31 17:54:02 +02:00
joonas-a 55b900d1f6 [SGG overview] Migrate to MUI+TS 2025-10-31 17:52:46 +02:00
joonas-a 1ff637ab9d [Types] fix GroupsWithTags type 2025-10-31 13:53:04 +02:00
joonas-a cff045fd3b [SGG] Fix content incorrectly centered in panels 2025-10-30 18:11:43 +02:00
joonas-a 7fce9fd5be [SGG] Delete unused css import 2025-10-30 17:58:09 +02:00
joonas-a a2569d1525 [SGG] Delete unused css file 2025-10-30 17:54:08 +02:00
joonas-a 70e220ba58 [SGG] Delete unused file 2025-10-30 17:52:21 +02:00
joonas-a 9482f58930 [SGG] Move all utils to same file (utils) 2025-10-30 17:51:37 +02:00
joonas-a fbc92b6949 [SGG] Split large file 2025-10-30 17:16:35 +02:00
joonas-a 2b452429c9 [SGG] Refactoring 2025-10-30 17:09:43 +02:00
joonas-a 2eb542a61a [Lint] Fix errors 2025-10-29 11:08:43 +02:00
joonas-a 10ba9153ed [Frontpage] Remove alert on updating data tables 2025-10-28 17:48:53 +02:00
Santeri Rajamäki 4eb3946d4f [CoursePopulation] Remove extra collapsing attribute from grade dist. 2025-10-25 14:01:11 +03:00
santeri0200andGitHub 691233a91e Merge pull request #4905 from UniversityOfHelsinkiCS/renovate/major-github-artifact-actions
Update GitHub Artifact Actions (major)
- Adds support for Node 24
2025-10-25 13:56:15 +03:00
renovate[bot]andGitHub cda44b321c Update GitHub Artifact Actions 2025-10-25 00:34:16 +00:00
joonas-a bdbed16047 [Programme overview/courses] Make pagination choices meaningful 2025-10-24 18:13:41 +03:00
joonas-a ec6dd9d672 [SGG] minimal tsx conversion for DX 2025-10-24 17:43:14 +03:00
joonas-a 95ff21a09b [Types] Add re-export for filterview course type 2025-10-24 17:43:14 +03:00
joonas-a a464d8cd49 [SGG] Index cleanup 2025-10-24 17:43:14 +03:00
joonas-a d97e1757b1 [Credit stats] Remove sgg special treatment 2025-10-24 17:43:14 +03:00
joonas-a e91239c4e4 [AgeStats] Add missing typing 2025-10-24 17:43:14 +03:00
Santeri Rajamäki b0d358db78 [CustomPopulation] Upgrade custom population search to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 22643fa590 [CoursePopulation] Upgraded population courses wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 9c5665c216 [OpenUni] Upgrade custom open uni population result wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 5549202299 [OpenUni] Upgrade custom open uni population wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 31004bed07 [Frontend] Upgrade credit accumulation graph wrapper to MUI 2025-10-24 17:02:41 +03:00
Santeri Rajamäki 5cdfea1bc2 [CoursePopulation] Upgrade credit gain table and tabs to MUI 2025-10-24 17:02:41 +03:00
joonas-a cbb17ef00f [Frontend] Add general purpose styled message component 2025-10-24 16:45:56 +03:00
joonas-a a75b3b1d5a [SGG/types] correctly make some fields optional 2025-10-24 16:31:38 +03:00
joonas-a b34f42efd9 [Routes] Type custom population/sgg rtk endpoint 2025-10-24 16:26:15 +03:00
joonas-a 34e2293bc8 [SGG] jsx->tsx preparation 2025-10-24 15:23:48 +03:00
Santeri Rajamäki 71ae263e40 [Frontend] Remove react-datetime 2025-10-24 15:20:45 +03:00
Santeri Rajamäki b87b129766 [OpenUni] Upgrade custom open uni search to MUI 2025-10-24 15:20:45 +03:00
Santeri Rajamäki 76c707c322 [Frontend] Convert DateRangeSelector to use Stack 2025-10-24 15:20:45 +03:00
joonas-a 1d0d9f91bf [Frontend] Move full screen loading spinner to reusable component 2025-10-24 15:01:32 +03:00
Santeri Rajamäki da22043197 [PopulationStudents] Upgrade check student list admin component to MUI 2025-10-24 14:35:15 +03:00
joonas-a db567235a9 [Modules tab] consistent row naming 2025-10-24 14:00:31 +03:00
Santeri Rajamäki 08d42c8efd [Frontend] Upgraded tag population component to MUI 2025-10-24 13:16:08 +03:00
Santeri Rajamäki 53003b2c93 [Frontend] Remove unused useTabChangeAnalytics hook 2025-10-24 12:05:04 +03:00
Santeri Rajamäki 9049160b02 [SGG] Upgrade population students tabs to MUI 2025-10-24 12:04:14 +03:00
Santeri Rajamäki b40ba0846a [SGG] Upgrade SGG overview to MUI 2025-10-24 11:51:22 +03:00
Santeri Rajamäki cae1fe1550 [Frontend] Enable enrollment date selector to work in other views 2025-10-24 11:43:04 +03:00
Santeri Rajamäki 180bea21c1 [Frontend] Upgrade ColorizedCoursesTable selector components to MUI 2025-10-24 09:21:00 +03:00
Santeri Rajamäki f861d55aab [Frontend] Upgrade ColorizedCoursesTable component to MUI 2025-10-24 09:02:55 +03:00
Santeri Rajamäki 9f689d5ec7 [SGG] Upgrade single SGG view to MUI 2025-10-24 08:53:51 +03:00
Santeri Rajamäki 43d67713a6 [CustomPopulation] Upgrade Unihow data export button to MUI 2025-10-23 23:46:38 +03:00
Santeri Rajamäki ff15862aaf [Frontend] Remove unused Semantic UI segment from population courses container 2025-10-23 22:36:28 +03:00
Santeri Rajamäki 2d00381b0d [CustomPopulation] Replace search history component with the MUI version 2025-10-23 22:31:43 +03:00
Santeri Rajamäki 9186ca5b40 [SGG] Remove loading attribute wrapping paper 2025-10-23 22:21:35 +03:00
Santeri Rajamäki 7b43b7bb06 [CustomPopulation] Upgrade index to MUI 2025-10-23 22:15:17 +03:00
Santeri Rajamäki 533b6b17bd [Frontend] Upgrade rights notification to MUI 2025-10-23 16:53:36 +03:00
Santeri Rajamäki 1ffa846243 [SGG] Upgrade population course selector to MUI 2025-10-23 16:47:39 +03:00
Santeri Rajamäki 9f7f037e90 [SGG] Upgrade common to MUI 2025-10-23 16:26:13 +03:00
Santeri Rajamäki 795bae945d [SGG] Upgrade index to Typescript 2025-10-23 16:20:03 +03:00
Santeri Rajamäki c2b0dc46ca [SGG] Upgrade index to MUI 2025-10-23 16:18:52 +03:00
renovate[bot]andGitHub f22fe44f5a Update dependency react-markdown to v10 2025-10-23 12:41:04 +00:00
santeri0200andGitHub 2139286a49 Merge pull request #4901 from UniversityOfHelsinkiCS/renovate/actions-setup-node-6.x
Update actions/setup-node action to v6
2025-10-23 15:36:25 +03:00
Santeri Rajamäki 4e1ad9441a [META] Upgrade vite to 7.1.12
Closes #4903
2025-10-23 15:35:13 +03:00
Santeri Rajamäki 3a021e07a7 [E2E][Teachers] Get the row, not the index of MAT12004 2025-10-23 14:59:52 +03:00
Santeri Rajamäki 8b14c21f3c [Frontend] Add curriculum guard to population details 2025-10-23 14:04:34 +03:00
Santeri Rajamäki 6bbd53ade9 [E2E][Teachers] Update tests to work with MUI components 2025-10-23 13:55:23 +03:00
Santeri Rajamäki e4e20c8e3c [OodiTable] Ignore unused export on feature interface 2025-10-23 13:13:02 +03:00
Santeri Rajamäki 3337192b8a [CoursePopulation] Upgrade course grade distribution table to MUI 2025-10-23 13:10:17 +03:00
Santeri Rajamäki 61ee4475e7 [Frontend] Add loading section wrapper 2025-10-23 12:57:34 +03:00
Santeri Rajamäki 43c3dd2998 [Teachers] Upgraded teacher index to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki de5e23fdc6 [Teachers] Upgraded teacher statistics view to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 355b034a4a [Teachers] Upgraded teacher statistics table to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 2321ab7242 [Teachers] Upgraded teacher details to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki ecc610bf6d [Teachers] Upgraded teacher search tab to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki bef5e39d22 [Teachers] Upgraded teacher leaderboard form to Typescript 2025-10-23 12:54:33 +03:00
Santeri Rajamäki b7d3fbab09 [Teachers] Upgraded teacher leaderboard to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki c0d7538cab [Teachers] Upgrade other wrappers' info boxes to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 7858845b60 [Teachers] Upgrade leaderboard wrapper to MUI 2025-10-23 12:54:33 +03:00
joonas-a 4c87250206 [Ooditable] use same copy button with notification in all tables 2025-10-21 17:34:59 +03:00
joonas-a 8c52ff707a [General tab] Use better notification when copying student numbers 2025-10-21 17:11:23 +03:00
joonas-a 92d9040260 [General tab] Fix few column alignments 2025-10-21 16:53:47 +03:00
joonas-a 874e75670e [Stylelint] Modify css selector regex 2025-10-21 16:30:50 +03:00
joonas-a 9f60d026ad [Ooditable] Dom optimizations and cleanup 2025-10-21 16:29:54 +03:00
Santeri Rajamäki 0bb2da9137 [Frontend] Fix filter toggle forwardRef error and commit to icon design 2025-10-21 12:33:36 +03:00
Santeri Rajamäki 0977eefbd3 [Frontend] Convert filter toggle to use common filter toggle icon 2025-10-20 22:24:05 +03:00
Santeri Rajamäki 99d2e803b6 [Frontend] Upgrade common filter toggle icon to MUI 2025-10-20 22:23:16 +03:00
Santeri Rajamäki 5a4998ce84 [PopulationStatistics] Upgrade course filter toggle to MUI 2025-10-20 22:22:07 +03:00
Santeri Rajamäki 316c17745d [CustomPopulation] Changed language of search result table to typescript 2025-10-20 21:49:44 +03:00
Santeri Rajamäki bfad75d03e [CustomPopulation] Upgraded search result table to MUI 2025-10-20 21:48:29 +03:00
Santeri Rajamäki c802e3827f [PopulationStatistics] Upgraded Courses of Class to use MUI tabs 2025-10-20 21:26:12 +03:00
Santeri Rajamäki 78f1b2a3e4 [CoursePopulation] Upgrade Language distribution table to MUI 2025-10-20 20:50:52 +03:00
Santeri Rajamäki 6b7b9da7c8 [PopulationStatistics][Tags] Rework Semantic UI Dropdown to MUI 2025-10-20 17:10:37 +03:00
Santeri Rajamäki dfadf975d0 [DegreeProgramme] Make study track selection lazy to remove warnings 2025-10-20 14:04:05 +03:00
Santeri Rajamäki 3809d0e284 [DegreeProgramme] Reorder items to create more dependable render order 2025-10-20 13:59:36 +03:00
Santeri Rajamäki b2338981a9 [Frontend] Remove SisuLinkItem 2025-10-20 13:58:20 +03:00
Santeri Rajamäki be56f8b388 [Frontend] Remove unused modules 2025-10-20 13:26:32 +03:00
Santeri Rajamäki bf4c8fa2fb [Frontend] Apply ForwardRef to Link component
Use https://mui.com/material-ui/integrations/routing/ as guidance.
2025-10-20 13:18:56 +03:00
Santeri Rajamäki d9b8bad314 [PopulationStatistics] Convert SortableTable button to MUI 2025-10-20 12:18:01 +03:00
Santeri Rajamäki 2149cad79c [Teachers] Upgrade frontpage tab feature to MUI 2025-10-20 12:18:00 +03:00
joonas-a 718393cb25 [Material UI] Courses tab 2025-10-16 14:48:59 +03:00
joonas-a bb56f3e801 [Ooditable] make export able to parse objects 2025-10-15 13:32:43 +03:00
joonas-a 08a3f50a5a [Ooditable] streamline exports accessorkey handling 2025-10-15 12:59:09 +03:00
joonas-a e04315e72b [Ooditable] consistent coloring from theme 2025-10-15 12:29:05 +03:00
joonas-a 56a8463871 [Ooditable] better copy button 2025-10-15 12:22:18 +03:00
joonas-a 3895ca5143 [Ooditable] Hide horizontal overflow in export view 2025-10-15 12:17:02 +03:00
Santeri Rajamäki dbb89ad2d8 [E2E][PopulationStatistics] Update searched icon 2025-10-14 16:52:35 +03:00
Santeri Rajamäki 422f6464fc [E2E][LanguageCenter] Remove "hasLanded" inline helper 2025-10-14 16:47:55 +03:00
Santeri Rajamäki 84cd9956ff [CustomPopulation] Use existing MUI percentage bar 2025-10-14 16:41:12 +03:00
Santeri Rajamäki 3473d8e546 [SGG] Center SGG list link icons 2025-10-14 16:33:33 +03:00
Santeri Rajamäki d5e44d4df4 [Frontend] Abstract react-router Link behind wrapper component 2025-10-14 16:26:17 +03:00
Santeri Rajamäki 843f9d9716 [Frontend] Add Link wrapper for MUI/react-router implementation 2025-10-14 16:26:17 +03:00
joonas-a 521ec7e302 [Class stats] Center no students found info box 2025-10-14 16:03:43 +03:00
Santeri Rajamäki 7217f9666d [LanguageCenterView] Convert info message to common MUI info button 2025-10-14 15:20:26 +03:00
Santeri Rajamäki a40cd6a38c [CoursePopulation] Upgrade population course tab feature to MUI 2025-10-14 14:51:04 +03:00
Santeri Rajamäki 8258fa3cf8 [PopulationStatistics] Remove CoursesTab container to Semantic UI contents 2025-10-14 14:18:54 +03:00
Santeri Rajamäki 65b0c83778 [PopulationStatistics] Upgraded progress tab skeleton to MUI 2025-10-14 14:04:22 +03:00
Santeri Rajamäki 7eb5511a77 [PopulationStatistics] Upgraded progress tab links to MUI version 2025-10-14 13:53:36 +03:00
Santeri Rajamäki 378df9cfcd [Frontend] Upgraded SISU link component to MUI 2025-10-14 13:36:36 +03:00
Santeri Rajamäki 0ba959b99e [Frontend] Upgrade dimmer to MUI 2025-10-14 13:16:17 +03:00
Santeri Rajamäki 609fd2e3bc [Frontend] Update StyledTable styling 2025-10-14 12:43:27 +03:00
Santeri Rajamäki 7a43f3f522 [PopulationStatistics] Upgraded student tags table to MUI 2025-10-14 12:41:09 +03:00
renovate[bot]andGitHub 61959c25b9 Update actions/setup-node action to v6 2025-10-14 04:59:32 +00:00
Santeri Rajamäki 5146ff9c48 [Charts] Upgrade highcharts tooltips to MUI
This does not include icons as MUI for some reason does not want to behave with react element and string parsing.
2025-10-13 19:19:07 +03:00
Santeri Rajamäki 481acc3f27 [Frontend] Upgrade progress bar to MUI 2025-10-13 17:56:26 +03:00
Santeri Rajamäki df7761e6e7 [Frontend] Upgrade infobox to MUI 2025-10-13 17:11:40 +03:00
Santeri Rajamäki 7c2c6478b5 [PopulationStatistics] Upgraded student progress icons to MUI 2025-10-13 16:00:37 +03:00
Santeri Rajamäki 284a24c937 [PopulationStatistics] Upgraded student courses icons to MUI 2025-10-13 15:20:15 +03:00
Santeri Rajamäki eedfe5a971 [PopulationStatistics] Upgrade courses of class course management modal to MUI 2025-10-13 15:06:39 +03:00
Santeri Rajamäki e958ba7a5e [PopulationStatistics] Upgrade courses of class course management buttons to MUI 2025-10-13 15:06:33 +03:00
Santeri Rajamäki f5ce505a34 [StudyProgramme] Make CI happy again 2025-10-13 13:13:30 +03:00
Santeri Rajamäki d596db6f15 [StudyProgramme] Reorder hooks for more reliable execute order 2025-10-10 17:47:22 +03:00
Santeri Rajamäki a8da08bd82 [StudyProgramme] Reformat programmeId split 2025-10-10 17:43:27 +03:00
Santeri Rajamäki f4cf3f2c71 [StudyProgramme] Replace excess cypress tag with key 2025-10-10 17:30:47 +03:00
Santeri Rajamäki d2190579fe [SGG] Credit statistics clean up left from query rework 2025-10-10 17:18:59 +03:00
Santeri Rajamäki 3805ed1ffe [Redux] Replace actionHistory reducer with actionHistory middleware 2025-10-10 16:33:12 +03:00
Santeri Rajamäki 14d24dd320 [Redux] skip focus/unfocus state changes in action history item 2025-10-10 15:01:27 +03:00
santeri0200andGitHub da54f92261 Merge pull request #4898 from UniversityOfHelsinkiCS/OOD-97-hide-graduation-title-in-fd
[Programmes] Hide graduation title when not default service provider
2025-10-09 13:52:33 +03:00
Matti Räsänen 248627d809 Moved service provider check to top 2025-10-07 12:51:18 +03:00
Matti Räsänen bc177a124c [Programmes] Hide graduation title when not default service provider 2025-10-07 11:45:16 +03:00
Santeri Rajamäki b5592f4a6c Resolve #4896 2025-10-03 15:36:07 +03:00
607 changed files with 18342 additions and 22642 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ description: 'Runs ESLint and Stylelint for frontend and backend'
runs:
using: composite
steps:
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: 'Install dependencies'
shell: bash
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
+5 -5
View File
@@ -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 }}
@@ -27,10 +27,10 @@ runs:
- name: 'Run services, pull rest of the needed images'
shell: bash
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Run cypress'
uses: cypress-io/github-action@v6
uses: cypress-io/github-action@v7
with:
browser: chrome
spec: ${{ inputs.spec }}
@@ -38,7 +38,7 @@ runs:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
@@ -47,4 +47,4 @@ runs:
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml logs
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
+7 -7
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Get current time'
id: get_current_time
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -85,7 +85,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -119,7 +119,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -157,13 +157,13 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment
softa-url: https://oodikone.cs.helsinki.fi/
softa-url: https://oodikone.helsinki.fi/
deployment-target: production
report_failure:
@@ -178,7 +178,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:
+31 -2
View File
@@ -17,14 +17,43 @@ 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
test_api:
needs:
- lint_packages
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24 for cypress
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: ${{ 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
test_end_to_end:
runs-on: ubuntu-latest
needs:
- lint_packages
- test_api
strategy:
fail-fast: false
matrix:
@@ -59,7 +88,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get current time
id: get_current_time
@@ -64,7 +64,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build backend image
id: build-image
@@ -99,7 +99,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:
@@ -118,7 +118,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:
+124 -53
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,10 +25,10 @@ jobs:
name: 'Build backend'
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 image and export it'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -41,7 +41,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: backend-image
path: /tmp/backend.tar
@@ -50,7 +50,7 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
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
@@ -76,7 +76,7 @@ jobs:
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
@@ -98,9 +98,9 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
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 }}
@@ -120,15 +120,15 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Download backend image'
uses: actions/download-artifact@v5
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: check_migrations
with:
@@ -157,13 +157,13 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v5
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -174,14 +174,104 @@ jobs:
sleep 30 # wait for backend to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
# === End-to-end tests ===
test_end_to_end:
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
# Always run the tests, even if some of the needed jobs failed or were skipped
if: always()
- lint_packages
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, pull rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Check that exit statuses are not errors'
run: |
docker ps -a
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
echo 'Some process had nonzero exit code';
exit 1;
fi
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
# === Backend api-tests ===
test_api:
needs:
- test_service_exit_statuses
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- name: Checkout
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@v8
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@v4
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
- name: Run api-tests
run: npm run oodikone:ci:api
# === End-to-end tests ===
test_end_to_end:
needs:
- test_service_exit_statuses
- test_api
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
strategy:
fail-fast: false
matrix:
@@ -219,7 +309,21 @@ jobs:
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@v8
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
@@ -228,36 +332,3 @@ jobs:
password: ${{ secrets.password }}
spec: ${{ matrix.spec }}
test_name: ${{ matrix.name }}
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- 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: 'Run services, pull rest of the needed images'
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull
- name: 'Check that exit statuses are not errors'
run: |
docker ps -a
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
echo 'Some process had nonzero exit code';
exit 1;
fi
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml logs
+18 -27
View File
@@ -1,29 +1,22 @@
name: 'Test (and release) Updater'
on:
push:
branches:
- master
release:
types: [published]
paths:
- '.github/workflows/updater.yaml'
- 'updater/sis-updater-worker/**'
- 'updater/sis-updater-scheduler/**'
pull_request:
branches:
- master
paths:
- 'updater/sis-updater-worker/**'
- 'updater/sis-updater-scheduler/**'
jobs:
lint_scheduler_and_worker:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
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'
@@ -33,10 +26,10 @@ jobs:
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
@@ -45,7 +38,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: updater-worker-image
path: /tmp/worker.tar
@@ -54,10 +47,10 @@ jobs:
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
@@ -66,7 +59,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
@@ -79,15 +72,15 @@ jobs:
- 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@v5
uses: actions/download-artifact@v8
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
@@ -122,9 +115,8 @@ 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
id: build-image
@@ -152,9 +144,8 @@ 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
id: build-image
-3
View File
@@ -65,9 +65,6 @@ instrumented/
.vscode/launch.json
.vscode/terminals.json
# Funidata specific
environments/
# Target of shared symlink
services/**/src/shared
-5
View File
@@ -1,5 +0,0 @@
{
"all": true,
"include": ["services/frontend/src/**/*.js", "services/frontend/src/**/*.jsx"],
"extends": "./nyc-config.js"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": ["stylelint-config-standard"],
"rules": {
"selector-class-pattern": "^[a-z][a-zA-Z-]*$"
"selector-class-pattern": "^(?:[a-z][a-zA-Z-]*|Mui[A-Za-z-]+)$"
}
}
-2
View File
@@ -112,8 +112,6 @@ The `documentation` directory contains some notes on the sis-db schema and is me
For internal meeting notes and sensitive information, see Toska GitLab.
[SortableTable documentation](./services/frontend/src/components/SortableTable/README.md)
### Basics
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for Oodikone's two main components: **oodikone** and **updater**.
+3 -3
View File
@@ -6,14 +6,14 @@ The quick guide should be enough for most purposes: updating schema, data, or th
## Quick guide to updating a test database
1. Ensure you have the latest test-db. Just pulling images might not replace your existing volume, so you may need to remove it manually. This command might work: `docker volume rm oodikone_sis-db-data` (replace `oodikone_sis-db-data` with the correct volume name). The new image, including data, should be pulled the next time you launch Oodikone normally.
1. Ensure you have the latest test-db. Just pulling images might not replace your existing volume, so you may need to remove it manually. This command might work: `docker volume rm oodikone-sis-db-data` (replace `oodikone-sis-db-data` with the correct volume name). The new image, including data, should be pulled the next time you launch Oodikone normally.
2. Do your dev work as usual with the test database, for example, using `npm run both`. Edit data as needed, (for example, run the updater via the frontend), and then run tests to see if they pass (follow the instructions in the [README](../README.md) to run the tests more quickly).
3. If you're updating `sis-db`:
- Modify the scheduler limit in [config.js](../updater/sis-updater-scheduler/src/config.js) by setting `module.exports.DEV_SCHEDULE_COUNT = null`.
- Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone_sis-updater-worker_1`).
- Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone-sis-updater-worker`).
4. When you're satisfied with the database, create a dump:
@@ -67,6 +67,6 @@ The quick guide should be enough for most purposes: updating schema, data, or th
5. Navigate to <http://localhost:3000/updater> and click the three buttons (Update meta, Update students, Update curriculums) one by one. This will populate `sis-db` with data from the new `sis-importer-db`. You might want to do each update a couple of times, as the database tables affect each other, and not all necessary data might be generated on the first try.
6. Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone_sis-updater-worker_1`). If you see any errors, try clicking the buttons again.
6. Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone-sis-updater-worker`). If you see any errors, try clicking the buttons again.
7. Run all the tests to ensure everything is working as expected (follow the instructions in the [README](../README.md) to run the tests more quickly). If any tests fail, update the test files accordingly.
-2
View File
@@ -3,7 +3,6 @@
const { defineConfig } = require('cypress')
module.exports = defineConfig({
experimentalStudio: process.env.NODE_ENV === 'development',
projectId: 'c3jsph',
defaultCommandTimeout: 30000,
requestTimeout: 30000,
@@ -14,7 +13,6 @@ module.exports = defineConfig({
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
require('@cypress/code-coverage/task')(on, config)
require('./cypress/plugins/index.js')(on, config)
return config
},
+30 -30
View File
@@ -17,32 +17,32 @@ const openSearch = () => {
}
const selectSavedCourselist = name => {
cy.get('[data-cy=history-search]').click()
cy.get('[data-cy=history-search]').type(name)
cy.get('[data-cy=history-search]').type('{downarrow}')
cy.get('[data-cy=history-search]').type('{enter}')
cy.cs('history-search').click()
cy.cs('history-search').type(name)
cy.cs('history-search').type('{downarrow}')
cy.cs('history-search').type('{enter}')
}
const createCourseList = (courseCodes, courseListName) => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
cy.get('[data-cy=search-name]').type(courseListName)
cy.get('[data-cy=save-courselist]').click()
cy.cs('course-list-input').type(courseCodes.join('\n'))
cy.cs('search-name').type(courseListName)
cy.cs('save-courselist').click()
}
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
const deleteAllPreviousSearches = () => {
cy.get('[data-cy=history-search]').click()
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper li').then($options => {
if ($options.length > 0) {
cy.wrap($options).each(($option, index, $list) => {
cy.wrap($option).click()
cy.get('[data-cy=delete-courselist]').click()
cy.cs('delete-courselist').click()
if (index < $list.length - 1) {
cy.get('[data-cy=history-search]').click()
cy.cs('history-search').click()
}
})
} else {
@@ -57,7 +57,7 @@ describe('When search modal is opened', () => {
hasLanded()
cy.url().should('include', '/completedcoursessearch')
cy.contains('Search completed courses of students')
cy.get('[data-cy=open-completed-courses-modal-button]').click()
cy.cs('open-completed-courses-modal-button').click()
})
it('Modal opens correctly', () => {
@@ -74,8 +74,8 @@ describe('When search modal is opened', () => {
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
hasLanded()
openCompletedCoursesModal()
cy.contains('[data-cy=student-no-input]', '433237, 457144')
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
cy.cs('student-no-input').contains('433237, 457144')
cy.cs('course-list-input').contains('TKT10001, TKT10002')
})
describe('When a search is executed with invalid input', () => {
@@ -83,16 +83,16 @@ describe('When search modal is opened', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.get('[data-cy=student-no-input]').type('1')
cy.get('[data-cy=course-list-input]').type('1')
cy.get('[data-cy=completed-courses-search-button]').click()
cy.get('[data-cy=rights-notification]').should(
cy.cs('student-no-input').type('1')
cy.cs('course-list-input').type('1')
cy.cs('completed-courses-search-button').click()
cy.cs('rights-notification').should(
'contain.text',
'The information for the following students could not be displayed'
)
cy.contains('[data-cy=rights-notification]', '1')
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
cy.cs('rights-notification').contains('1')
cy.cs('completed-courses-table-div').contains('Student number')
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
})
})
@@ -102,9 +102,9 @@ describe('When search modal is opened', () => {
hasLanded()
openCompletedCoursesModal()
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
cy.get('[data-cy=completed-courses-search-button]').click()
cy.cs('student-no-input').type(studentSet1.join('\n'))
cy.cs('course-list-input').type(coursesSet1.join('\n'))
cy.cs('completed-courses-search-button').click()
})
})
@@ -120,10 +120,10 @@ describe('When search modal is opened', () => {
it('Finds correct students and courses', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
for (const studentNumber of studentSet1) {
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
cy.cs('completed-courses-table-div').contains(studentNumber)
}
for (const courseCode of coursesSet1) {
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
cy.cs('completed-courses-table-div').contains(courseCode)
}
})
})
@@ -150,7 +150,7 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', courses.join(', '))
cy.cs('course-list-input').contains(courses.join(', '))
})
it('Course list can be deleted', () => {
@@ -159,7 +159,7 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.get('[data-cy=delete-courselist]').click()
cy.cs('delete-courselist').click()
cy.contains('You have no previous searches.')
})
@@ -169,11 +169,11 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.get('[data-cy=course-list-input]').type(',TKT10001')
cy.get('[data-cy=save-courselist]').click()
cy.cs('course-list-input').type(',TKT10001')
cy.cs('save-courselist').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
})
})
+37 -34
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')
@@ -117,7 +118,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode1)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', coursecode1).click()
cy.cs(`course-${coursecode1}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode1}%22%5D`)
@@ -135,7 +136,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode2)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', coursecode2).click()
cy.cs(`course-${coursecode2}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode2}%22%5D`)
@@ -154,7 +155,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', `${coursecode},`).click()
cy.cs(`course-${coursecode}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
@@ -179,7 +180,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', coursecode).click()
cy.cs(`course-${coursecode}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
@@ -208,7 +209,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', coursecode).click()
cy.cs(`course-${coursecode}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
@@ -222,7 +223,7 @@ describe('Basic user', () => {
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains(
'Population of course Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
'Population of course Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
)
cy.contains('Showing 4 out of 4 students')
@@ -241,7 +242,7 @@ describe('Basic user', () => {
searchByCourseCode(coursecode)
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', coursecode).click()
cy.cs(`course-${coursecode}`).click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
@@ -281,8 +282,8 @@ describe('Basic user', () => {
searchByCourseCode('TKT')
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', /^TKT20001/).click()
cy.contains('td', /^TKT10002/).click()
cy.cs(`course-TKT20001`).click()
cy.cs(`course-TKT10002`).click()
cy.contains('Fetch statistics').should('be.enabled')
cy.contains('Fetch statistics').click()
@@ -292,15 +293,16 @@ describe('Basic user', () => {
openSummaryTab()
cy.contains('Tietorakenteet ja algoritmit').click()
cy.contains('Ohjelmoinnin perusteet').should('not.exist')
cy.contains('TKT20001')
cy.contains('58131')
cy.contains('TKT10002').should('exist')
cy.contains('TKT20001').should('exist')
cy.contains('58131').should('not.exist')
openSummaryTab()
cy.contains('Ohjelmoinnin perusteet').click()
cy.contains('TKT10002').should('exist')
cy.contains('TKT20001').should('exist')
cy.contains('Käyttöjärjestelmät').should('not.exist')
cy.contains('TKT10002')
cy.contains('581325')
cy.contains('581325').should('not.exist')
openSummaryTab()
})
})
@@ -308,12 +310,12 @@ describe('Basic user', () => {
it('On searches with multiple courses, has correct links on the Course tab', () => {
cy.intercept({
pathname: '/api/v2/coursesmulti',
query: { code: 'TKT' },
query: { code: 'BSCS' },
}).as('courses')
cy.intercept({
pathname: '/api/v3/courseyearlystats',
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
}).as('yearlystats')
cy.intercept({
@@ -324,16 +326,16 @@ describe('Basic user', () => {
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
searchByCourseCode('TKT')
searchByCourseCode('BSCS')
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('td', /^TKT20001/).click()
cy.contains('td', /^TKT10002/).click()
cy.cs(`course-BSCS1003`).click()
cy.cs(`course-BSCS1001`).click()
cy.contains('Fetch statistics').should('be.enabled')
cy.contains('Fetch statistics').click()
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
cy.url().should('contain', 'courseCodes=%5B%22TKT10002%22%2C%22TKT20001%22%5D')
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)')
@@ -343,9 +345,11 @@ describe('Basic user', () => {
// necessary if the test is flaky.
cy.go('back')
cy.cs('CourseSelector').click()
cy.cs('CourseSelectorOptionTKT20001').click()
cy.cs('CourseSelectorOptionBSCS1003').click()
// 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 Tietorakenteet ja algoritmit')
cy.contains('Population of course Data Structures and Algorithms 2022-2023 (open and normal)')
})
it('Searching course by name displays right courses', () => {
@@ -353,7 +357,7 @@ describe('Basic user', () => {
searchByCourseName('tietokantojen perusteet')
cy.contains('Tietokantojen perusteet')
cy.contains('td', 'TKT10004, BSCS2001, 581328, A581328, AYTKT10004').click()
cy.cs(`course-TKT10004`).click()
cy.contains('Search for courses').should('not.exist')
@@ -370,7 +374,7 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseName('tietorakenteet ja algoritmit')
cy.contains('Tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.cs(`course-TKT20001`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
@@ -380,7 +384,7 @@ describe('Basic user', () => {
clickNewQueryButton()
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
searchByCourseName('tietorakenteet ja algoritmit')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
cy.cs(`course-TKT20001`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
@@ -393,7 +397,6 @@ describe('Basic user', () => {
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')
@@ -404,7 +407,7 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseName('tietokantojen perusteet')
cy.contains('td', /^TKT10004/).click()
cy.cs(`course-TKT10004`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004 • Tietokantojen perusteet')
@@ -448,7 +451,7 @@ describe('Basic user', () => {
it('Population of course shows grades for each student', () => {
searchByCourseCode('TKT20001')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
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')
@@ -456,13 +459,13 @@ describe('Basic user', () => {
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('td', '394776').siblings().eq(1).contains('3')
cy.contains('td', '497388').siblings().eq(1).contains('2')
cy.contains('td', '394776').siblings().eq(2).contains('3')
cy.contains('td', '497388').siblings().eq(2).contains('2')
})
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
searchByCourseCode('TKT20001')
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
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')
@@ -476,7 +479,7 @@ describe('Basic user', () => {
it('Language distribution is correct', () => {
searchByCourseCode('TKT20003')
cy.contains('td', 'TKT20003, 582219').click()
cy.cs(`course-TKT20003`).click()
cy.contains('TKT20003 • Käyttöjärjestelmät')
cy.contains('582219 • Käyttöjärjestelmät')
cy.cs('course-population-for-2021-2022').click()
@@ -494,7 +497,7 @@ describe('Basic user', () => {
cy.cs('combine-substitutions-toggle').should('have.class', 'Mui-checked').click()
cy.cs('combine-substitutions-toggle').should('not.have.class', 'Mui-checked')
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002$/).click()
cy.cs(`course-TKT10002`).click()
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
})
@@ -727,7 +730,7 @@ describe('Basic user', () => {
cy.url().should('include', '/coursestatistics')
cy.contains('Search for courses')
searchByCourseCode('TKT10002')
cy.contains('td', /^TKT10002, BSCS1001, 581325, A581325, AYTKT10002$/).click()
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')
+25 -40
View File
@@ -1,13 +1,9 @@
/// <reference types="cypress" />
const openCustomPopupForm = () => {
cy.get('[data-cy="custom-pop-search-button"]').click()
}
const fillName = () => {
const name = `TEST-${new Date().getTime()}`
cy.contains('Insert name for this custom population if you wish to save it')
cy.get('[data-cy="custom-population-name-input"]').type(name)
cy.contains('Insert a name for this custom population if you wish to save it')
cy.cs('custom-population-name-input').type(name)
return name
}
@@ -16,38 +12,33 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
cy.get('[data-cy="history-search"]').children().eq(0).click()
cy.contains('Saved populations')
.parent()
.parent()
.parent()
.parent()
.get('.dropdown')
.then(dropdown => {
const searchItems = dropdown.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
cy.get('button').contains('Delete').click()
}
}
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
.find('li[role=option]')
.then($lis => {
const labels = [...$lis].map(li => li.textContent?.trim() || '')
labels.forEach((label, index) => {
if (index !== 0) cy.cs('history-search').click()
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
cy.contains('button', 'Delete').click()
})
})
}
const fillForm = (content, separator) => {
cy.get('[data-cy="student-number-input"]').type(content.join(separator))
cy.cs('student-number-input').type(content.join(separator))
}
const search = () => {
cy.get('[data-cy="search-button"]').click()
cy.cs('search-button').click()
}
const searchFor = (studentnumbers, separator) => {
openCustomPopupForm()
fillForm(studentnumbers, separator)
search()
}
@@ -74,7 +65,7 @@ const containsSpecificStudents = (studentnumbers = []) => {
}
const checkRightsNotification = studentNumbers => {
cy.get('[data-cy="rights-notification"]').within(() => {
cy.cs('rights-notification').within(() => {
cy.contains(
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
)
@@ -96,7 +87,6 @@ describe('Custom population tests', () => {
cy.visit('/custompopulation')
cy.url().should('include', '/custompopulation')
cy.contains('Custom population')
cy.get('button').contains('Custom population').click()
deleteAllSearches()
})
@@ -126,7 +116,7 @@ describe('Custom population tests', () => {
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Custom population')
cy.get('button').contains('Back to search form')
})
it("Doesn't return students user has no right to", () => {
@@ -138,16 +128,15 @@ describe('Custom population tests', () => {
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Custom population')
cy.get('button').contains('Back to search form')
})
})
describe('Custom population search saving', () => {
it('Saves a custom population search', { retries: 2 }, () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
openCustomPopupForm()
cy.contains(
'Insert student numbers you wish to use for population. Separate each number with a comma, semicolon, space, or newline.'
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
)
const name = fillName()
// A comma and a newline on purpose, the page should be able to handle it
@@ -157,17 +146,16 @@ describe('Custom population tests', () => {
// Round 1
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
// Round 2
cy.visit('/custompopulation')
cy.contains('Custom population')
openCustomPopupForm()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
})
@@ -176,28 +164,25 @@ describe('Custom population tests', () => {
it('Updates a custom population search', () => {
cy.fixture('customPopulations').then(
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
openCustomPopupForm()
const name = fillName()
fillForm(students1, ' ')
save()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length)
containsSpecificStudents(students1)
cy.contains('Custom population')
openCustomPopupForm()
cy.contains('Back to search form').click()
selectSavedPopulation(name)
fillForm(['\n', ...students2], ', ')
save()
cy.visit('/custompopulation')
cy.contains('Custom population')
openCustomPopupForm()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length + students2.length)
containsSpecificStudents([...students1, ...students2])
}
+133 -105
View File
@@ -93,31 +93,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]),
[2023, 1519, 1519, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 209],
[2021, 5133, 5108, 0, 25, 428],
[2020, 5801, 5796, 0, 5, 94],
[2019, 5305, 5305, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 21],
[2017, 1211, 1211, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it("Toggling 'Show special categories' on displays additional information", () => {
cy.cs('special-categories-toggle').click()
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 0, 0, 209],
[2021, 5133, 5108, 0, 25, 0, 0, 428],
[2020, 5801, 5796, 0, 5, 0, 0, 94],
[2019, 5305, 5305, 0, 0, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 0, 0, 21],
[2017, 1211, 1211, 0, 0, 0, 0, 189],
...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')
@@ -190,14 +173,14 @@ describe('Degree programme overview', () => {
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 67],
['2022 - 2023', 2725, 2720, 0, 5, 337],
['2021 - 2022', 4092, 4042, 0, 50, 198],
['2020 - 2021', 5420, 5415, 0, 5, 321],
['2019 - 2020', 6043, 6043, 0, 0, 101],
['2018 - 2019', 4856, 4851, 0, 5, 107],
['2017 - 2018', 2350, 2345, 0, 5, 26],
...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')
@@ -231,10 +214,10 @@ describe('Degree programme overview', () => {
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 44.5 months')
cy.contains('22 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('14 graduated over year late')
cy.contains('median study time: 6 semesters')
cy.contains('26 graduated on time')
cy.contains('10 graduated max year overtime')
cy.contains('12 graduated over year late')
})
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
@@ -262,7 +245,7 @@ describe('Degree programme overview', () => {
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('median study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
@@ -274,9 +257,9 @@ describe('Degree programme overview', () => {
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 69 months')
cy.contains('3 graduated on time')
cy.contains('7 graduated max year overtime')
cy.contains('median study time: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
@@ -286,11 +269,11 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('StudyTracksAndClassStatisticsTab').click()
})
describe('Info boxes', () => {
@@ -319,7 +302,7 @@ describe('Degree programme overview', () => {
it('Students of the degree programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
// [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],
@@ -398,44 +381,46 @@ describe('Degree programme overview', () => {
it('Student progress data is shown correctly', () => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
...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],
['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],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
cy.checkTableStats(tableContents, 'study-programme-progress')
})
it('Progress section', () => {
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
cy.cs('programme-progress-bar-chart-section')
.should('contain', 'Less than 30 credits')
.should('contain', '30–60 credits')
.should('contain', 'At least 180 credits')
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
cy.contains('Graduated: 134')
})
it('Average graduation times section', () => {
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
cy.contains('On time: 15')
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.contains('On time: 16')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
@@ -444,9 +429,9 @@ describe('Degree programme overview', () => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('15 graduated on time')
cy.contains('3 graduated max year overtime')
cy.contains('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')
})
})
@@ -465,7 +450,7 @@ 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, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
// [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],
@@ -519,7 +504,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('programme-courses-tab').click()
cy.cs('ProgrammeCoursesTab').click()
cy.cs('by-credit-type-section')
cy.cs('by-credit-type-tab')
@@ -528,14 +513,21 @@ describe('Degree programme overview', () => {
describe('By credit type tab', () => {
beforeEach(() => {
cy.get('tbody').within(() => {
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').eq(3).click()
})
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(8)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('341')
cy.get('td').eq(1).contains('MAT11004')
cy.get('td').eq(2).contains('Differentiaalilaskenta')
cy.get('td').eq(3).contains('1170')
})
})
})
@@ -548,12 +540,12 @@ describe('Degree programme overview', () => {
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.eq(9)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('121')
cy.get('td').eq(1).contains('MAT21009')
cy.get('td').eq(2).contains('Kandidaatintutkielma')
cy.get('td').eq(3).contains('414')
})
})
})
@@ -568,12 +560,12 @@ describe('Degree programme overview', () => {
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.eq(5)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('311')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('1220')
})
})
})
@@ -632,31 +624,67 @@ describe('Degree programme overview', () => {
})
it("'Show credits/Show students' toggle works", () => {
cy.get('thead tr').within(() => {
cy.get('th').should('have.length', 8)
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
cy.get('thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 6)
const headers = ['Type', 'Code', 'Name', 'Total credits', 'Credits produced by', 'Transferred credits']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Degree students',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
'Other',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
})
cy.cs('show-credits-students-toggle').click()
cy.get('thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Type',
'Code',
'Name',
'Total students',
'Breakdown of total',
'Breakdown of passed',
'Not included in total or passed',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 10)
const headers = [
'Type',
'Code',
'Name',
'Total',
'Passed',
'Not',
'Major',
'Non-major',
'Non-degree',
'Transferred',
'Not completed',
'Degree students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
'Other students',
'Students with transferred credits',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
@@ -665,12 +693,12 @@ describe('Degree programme overview', () => {
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.eq(5)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('341')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('375')
})
})
})
@@ -682,7 +710,7 @@ describe('Degree programme overview', () => {
})
// FIXME: Flaky
it('test', () => {
it.skip('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
@@ -693,7 +721,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('degree-courses-tab').click()
cy.cs('DegreeCoursesTab').click()
})
it('Curriculum section', () => {
@@ -729,7 +757,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('tags-tab').click()
cy.cs('TagsTab').click()
})
it('info box', () => {
@@ -856,7 +884,7 @@ describe('Degree programme overview', () => {
})
it('can access basic information', () => {
cy.cs('basic-information-tab').click()
cy.cs('BasicInformationTab').click()
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
@@ -866,7 +894,7 @@ describe('Degree programme overview', () => {
})
it('can access study tracks', () => {
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('StudyTracksAndClassStatisticsTab').click()
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
@@ -874,9 +902,9 @@ describe('Degree programme overview', () => {
})
it("doesn't see other tabs", () => {
cy.cs('degree-courses-tab').should('not.exist')
cy.cs('tags-tab').should('not.exist')
cy.cs('update-statistics-tab').should('not.exist')
cy.cs('DegreeCoursesTab').should('not.exist')
cy.cs('TagsTab').should('not.exist')
cy.cs('UpdateStatisticsTab').should('not.exist')
})
})
})
+4 -5
View File
@@ -273,12 +273,12 @@ describe('Population Statistics with Bachelor + Master', { testIsolation: false
checkFilteredStudentCount(0)
// Non-Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(5)
// Non-Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').should('be.checked')
checkFilteredStudentCount(24)
cy.cs('studyRightStatusFilter-clear').click()
@@ -385,7 +385,6 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
before(() => {
cy.init('/custompopulation')
cy.cs('custom-pop-search-button').click()
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
defaultAmountOfStudents = students.length
+19 -56
View File
@@ -1,42 +1,23 @@
/// <reference types="cypress" />
const hasLanded = () => {
cy.contains('This view displays amounts')
cy.contains('Show number of')
}
const chooseSemester = (semester, fromOrTo) => {
cy.get(`[data-cy="semester-${fromOrTo}"]`).click()
cy.get(`[data-cy="semester-${fromOrTo}"]`).within(() => {
cy.get('div.visible.menu.transition').within(() => {
cy.contains('div.item', semester).click()
})
cy.get('div.menu.transition').should('not.have.class', 'visible')
})
cy.cs(`semester-${fromOrTo}`).click()
cy.cs(`select-opt-${semester}`).click()
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
}
const checkNumbers = (numbers, numberOfColumns, mode) => {
const offset = mode === 'semesters' ? 2 : 1
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', numberOfColumns)
cy.get('td').eq(0).contains('All courses total')
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + offset)
.contains(number)
})
})
}
const offset = 1 // skip first col with codes / names
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
cy.get('@tbody').find('tr').first().as('totalRow')
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
const checkStyle = (styles, numberOfColumns) => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', numberOfColumns)
styles.forEach((style, index) => {
cy.get('td')
.eq(index + 2)
.should('have.attr', 'style')
.and('include', style)
})
numbers.forEach((number, index) => {
cy.get('@totalRow')
.find('td')
.eq(index + offset)
.should('contain', number)
})
}
@@ -44,8 +25,7 @@ describe('When language center is opened', () => {
describe('as an admin user', () => {
beforeEach(() => {
cy.init('/languagecenterview', 'admin')
cy.get('[data-cy="completions-button"]').click()
hasLanded()
cy.cs('completions-button').click()
})
describe('Faculties tab', () => {
@@ -64,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 = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + 1)
@@ -73,16 +53,14 @@ describe('When language center is opened', () => {
})
})
// FIXME: Flaky
it('Faculties tab "exceeding" button works', () => {
cy.get('[data-cy="difference-button"]').click()
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
cy.cs('difference-button').click()
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
})
// FIXME: Flaky
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
})
})
@@ -93,23 +71,8 @@ describe('When language center is opened', () => {
chooseSemester('Kevät 2024', 'to')
})
// FIXME: Flaky
it('Semester tab shows numbers', () => {
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
})
// FIXME: Flaky
it('Coloring mode works on semester tab', () => {
cy.contains('Compare to average of course').click()
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
checkStyle(
expectedAlphas.map(alpha => `background-color: rgba(0, 170, 0, ${alpha})`),
17
)
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').eq(16).should('have.attr', 'style').and('not.include', 'background-color')
})
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
})
})
})
+3 -1
View File
@@ -15,7 +15,9 @@ const deleteAllSearches = () => {
})
}
describe('Open uni population tests', () => {
// 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(() => {
+18 -20
View File
@@ -252,8 +252,8 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.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')
@@ -319,7 +319,7 @@ describe('Population statistics tests', () => {
})
it("'General tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.cs('ooditable-general').within(() => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
@@ -330,18 +330,19 @@ describe('Population statistics tests', () => {
})
})
// FIXME: Flaky
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
})
cy.cs('ooditable-courses').within(() => {
cy.contains('MAT12001')
cy.contains('MAT21001')
})
})
it("'Modules tab' Displays correct modules based on the selected programme", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Modules').click()
cy.cs('student-table-tabs').contains('Modules').click()
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011')
cy.contains('MAT110')
})
@@ -349,18 +350,17 @@ describe('Population statistics tests', () => {
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click()
cy.contains('Courses of class').click()
cy.cs('student-table-tabs').within(() => {
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011').should('not.exist')
cy.contains('MAT110')
})
})
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
// TODO: This fails in the pipeline, but works locally. Investigate.
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Tags').click()
cy.contains('No tags defined. You can define them here.').find('a').click()
})
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create new tag')
@@ -383,17 +383,15 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.get('textarea').type(existing)
cy.get('textarea').type('{enter}')
cy.get('textarea').type(nonExisting)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.contains('#checkstudentsresults', 'Results').within(() => {
cy.contains('Student numbers in list and in Sisu').click()
cy.contains('#found', existing)
cy.contains('Student numbers in list but not in Sisu').click()
cy.contains('#notfound', nonExisting)
cy.contains('Student numbers in Sisu but not in list').click()
cy.contains('#notsearched', '457144')
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
cy.cs('found-data').contains(existing)
cy.cs('not-found-title').click()
cy.cs('not-found-data').contains(nonExisting)
cy.cs('not-searched-title').click()
cy.cs('not-searched-data').contains('457144')
})
cy.contains('button', 'Close').click()
cy.contains('Student numbers in list and in Sisu').should('not.exist')
+11 -11
View File
@@ -31,7 +31,7 @@ describe('Study guidance group tests', () => {
const studyGuidanceGroupsTableContent = [
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add year'],
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
]
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
@@ -52,7 +52,7 @@ describe('Study guidance group tests', () => {
it('has a working back button', () => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
cy.get('button.ui.button').contains('Back').click()
cy.get('button').contains('Back to groups').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
})
@@ -100,9 +100,9 @@ describe('Study guidance group tests', () => {
})
it('shows the correct labels', () => {
cy.get('.ui.medium.header').contains('MAT kandit 2020')
cy.get('.ui.blue.tag.label:first').contains('Matemaattisten tieteiden kandiohjelma')
cy.get('.ui.blue.tag.label:last').contains('2020 - 2021')
cy.get('h4').contains('MAT kandit 2020')
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
cy.get('.MuiChip-root:last').contains('2020 - 2021')
})
it('shows the correct panes', () => {
@@ -113,17 +113,17 @@ describe('Study guidance group tests', () => {
cy.contains('Students (3)')
})
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
cy.cs('Credit accumulation (for 3 students)').click()
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
cy.get('.date-picker')
.eq(0)
.within(() => cy.get('input').should('have.value', '01.08.2020'))
cy.contains('Reset All Filters')
cy.get('.ui.primary.button').contains('Show all credits').click()
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
})
it.skip('students table has the correct tabs', () => {
+19 -19
View File
@@ -10,17 +10,19 @@ describe('Teachers page tests', () => {
const teacher1 = 'Luokkanen Liisa Viljami'
const teacher2 = 'Perälä Juhani Susanna'
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
it('Check Statistics', () => {
cy.get(':nth-child(1) > .ui > .search').click()
cy.cs('semester-start').click()
cy.contains('Syksy 2020').click()
cy.cs('course-providers').click()
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
cy.cs('course-providers').children('.icon').click()
cy.contains('button', 'Search').click()
cy.get('body').click(0, 0) // Click outside of the select
cy.cs('search-statistics').click()
cy.contains('Teacher')
const headers = ['Name', 'Credits', 'Credits transferred', 'Passed']
cy.get('table thead tr th').should('have.length', 4)
headers.forEach((header, index) => {
statisticsHeaders.forEach((header, index) => {
cy.get('table thead tr th').eq(index).should('contain', header)
})
cy.contains('td', teacher1).siblings().eq(0).contains('235')
@@ -30,15 +32,15 @@ describe('Teachers page tests', () => {
})
it('Teacher search works', () => {
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher1.split(' ')[0])
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(2).contains('td', teacher1)
cy.get('table tbody tr').eq(3).contains('td', teacher1)
})
it('Can check teacher page', () => {
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher2)
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher2)
// Prevent opening in new tab
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
@@ -46,19 +48,17 @@ describe('Teachers page tests', () => {
cy.contains(teacher2)
cy.contains('Syksy 2023').click()
cy.contains('Kevät 2019').click()
cy.get('.ok-sortable-table tbody tr')
.eq(0)
.within(() => {
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
rowContent.forEach((content, index) => {
cy.get('td').eq(index).contains(content)
})
cy.contains('tr', 'MAT12004').within(() => {
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
rowContent.forEach((content, index) => {
cy.get('td').eq(index).contains(content)
})
})
})
it('Check leaderboad works', () => {
cy.get('.borderless > :nth-child(2)').click()
cy.get(':nth-child(1) > .ui > .search').click()
cy.cs('Leaderboard').click()
cy.cs('academic-year').click()
cy.contains('2020-2021').click()
})
})
-6
View File
@@ -39,12 +39,6 @@ describe('University view', () => {
checkProgressTables()
})
it("'Graduated included / Graduated excluded' toggle works", () => {
cy.cs('graduated-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
it("'All study rights / Special study rights excluded' toggle works", () => {
cy.cs('study-right-toggle').click()
checkProgressBarCharts()
-1
View File
@@ -13,7 +13,6 @@
// https://on.cypress.io/configuration
// ***********************************************************
require('@cypress/code-coverage/support')
require('./commands')
Cypress.on('uncaught:exception', error => {
+8
View File
@@ -25,6 +25,8 @@ services:
build:
context: services
dockerfile: backend/Dockerfile
profiles:
- oodikone
frontend:
<<: *defaults
@@ -32,6 +34,8 @@ services:
build:
context: services
dockerfile: frontend/Dockerfile
profiles:
- oodikone
kone-db:
<<: *defaults
@@ -47,6 +51,7 @@ services:
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:7.2.6
container_name: redis
ports:
- 6379:6379
@@ -101,6 +106,8 @@ services:
- 1337:80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
profiles:
- oodikone
# 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:
@@ -114,6 +121,7 @@ services:
<<: *defaults
build:
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
-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
+24 -3
View File
@@ -1,5 +1,26 @@
services:
frontend:
api-tests:
volumes:
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/instrumented:/opt/app-root/frontend/src
- 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
volumes:
backend-tests-nodemod:
+3 -1
View File
@@ -21,6 +21,7 @@ services:
build:
<<: *build_defaults
context: services/backend
container_name: backend
depends_on:
- kone-db
- redis
@@ -132,6 +133,7 @@ services:
build:
<<: *build_defaults
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
@@ -168,7 +170,7 @@ services:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
+2 -2
View File
@@ -36,7 +36,7 @@ All things listed below should work automatically, if new combinations are done.
- _Students of the studyprogramme by starting year_
- Graduated field separated for both programmes
- All = currently enrolled + absent + inactive + graduated licentiate
- All = currently enrolled + absent + passive + graduated licentiate
- _Progress of students of the studyprogramme by starting year_
- computed for whole bachelor + master/licentiate studyright period
- _Average graduation times_
@@ -85,7 +85,7 @@ All things listed below should work automatically, if new combinations are done.
- **Graduated from programme** - possibility to filter out students based on graduation either in bachelor's or master's.
- **Studyright status** - active or inactive based on the chosen degree
- **Studyright status** - active or passive based on the chosen degree
- **Studytrack filter** (needs programme code) is not touched at the moment, thus **may need future manual work**.
+5 -5
View File
@@ -55,11 +55,11 @@ The `semester_enrollments` column is an array of JSON objects. Each object conta
The `type` field represents one of the following enrollment types. The `semester` field refers to a semester code, defined as `semestercode` in the model Semester.
| value | meaning | notes |
| ----: | :------- | :------------------------------- |
| 1 | Present | |
| 2 | Absent | |
| 3 | Inactive | neither 1 or 2 has been declared |
| value | meaning | notes |
| ----: | :------ | :------------------------------- |
| 1 | Present | |
| 2 | Absent | |
| 3 | Passive | neither 1 or 2 has been declared |
## Credit type code
+4 -14
View File
@@ -2,26 +2,19 @@
A collection of useful tools, documentation and other resources.
## AI tools
- [ChatGPT](https://chatgpt.com)
- [CurreChat](https://curre.helsinki.fi/chat)
- [GitHub Copilot](https://github.com/features/copilot)
- Free for students!
- [Microsoft Copilot](https://copilot.microsoft.com)
## Code style and maintainability
- [CodeClimate documentation](https://docs.codeclimate.com/docs)
- [Advanced configuration](https://docs.codeclimate.com/docs/advanced-configuration)
- [ESLint documentation](https://eslint.org/docs/v8.x)
- [Prettier documentation](https://prettier.io/docs/en)
- [qlty.sh](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
- Qlty.sh replaced Code climate which was introduced by Riku
- The usefulness of qlty.sh is sometimes questionable
## Database
- [pgcli](https://www.pgcli.com)
- A useful commandline tool to interact with the database
- Install: `$ pip install pgcli`
- Can be installed from some package managers or optionally via `$ pip install pgcli`
- [Postgres documentation](https://www.postgresql.org/docs/15/index.html)
- [Sequelize documentation](https://sequelize.org/docs/v6/)
@@ -29,8 +22,6 @@ A collection of useful tools, documentation and other resources.
- [GitHub documentation](https://docs.github.com/en)
- [GitHub Actions](https://docs.github.com/en/actions)
- [GitHub Education](https://github.com/education/students)
- Free benefits for students!
- [Mermaid.js documentation](https://mermaid.js.org/intro/syntax-reference.html)
## TypeScript
@@ -46,7 +37,6 @@ A collection of useful tools, documentation and other resources.
- [Material Icons](https://mui.com/material-ui/material-icons/)
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
<!--
+20 -2
View File
@@ -62,7 +62,7 @@ export default tseslint.config(
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
'id-denylist': ['error', 'err'],
'no-alert': 'error',
'no-await-in-loop': 'error',
'no-console': 'error',
@@ -75,7 +75,14 @@ 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/[^/]+$' }] }],
'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 }
],
@@ -205,6 +212,17 @@ export default tseslint.config(
]
},
// Override for backend api tests
{
files: [
"services/backend/tests**/*.ts"
],
rules: {
"import-x/no-extraneous-dependencies": "off",
'no-console': 'off',
}
},
// Overrides for CommonJS
{
files: [
-6
View File
@@ -1,6 +0,0 @@
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
module.exports = {
cache: false,
parserPlugins: parserPlugins.concat('typescript', 'jsx'),
}
+31 -4087
View File
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -2,7 +2,7 @@
"name": "oodikone",
"description": "An application for analyzing university data.",
"engines": {
"node": ">=22.0.0"
"node": ">=24.0.0"
},
"scripts": {
"cypress": "cypress",
@@ -16,7 +16,8 @@
"cli": "./cli.sh",
"oodikone": "./run.sh oodikone anon up --detach",
"oodikone:real": "./run.sh oodikone real up --detach",
"oodikone:test": "./run.sh oodikone test 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",
"updater": "./run.sh updater anon up --detach",
"updater:real": "./run.sh updater real up --detach",
"both": "./run.sh both anon up --detach",
@@ -25,11 +26,12 @@
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
"flushredis": "docker exec -it redis redis-cli flushall",
"prepare": "husky",
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"i:both": "npm run install:both",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
@@ -43,10 +45,8 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^15.0.0",
"cypress": "^15.9.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
@@ -58,13 +58,9 @@
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"nyc": "^17.1.0",
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21"
}
}
+6 -6
View File
@@ -35,7 +35,7 @@ parse_params() {
option=${args[0]}
# parse arguments
[[ ("$option" != "oodikone" && "$option" != "updater" && "$option" != "both") ]] && \
[[ ("$option" != "oodikone" && "$option" != "updater" && "$option" != "both" && "$option" != "tests") ]] && \
usage && die "Wrong option: $option"
[[ ${#args[@]} -eq 1 ]] && usage && die "Wrong number of arguments"
@@ -47,7 +47,7 @@ parse_params() {
compose_command=${args[*]:1}
else
version=${args[1]}
[[ "$version" != "anon" && "$version" != "real" && "$version" != "test" ]] && usage && \
[[ "$version" != "anon" && "$version" != "real" && "$version" != "api" ]] && usage && \
die "Wrong version $version"
compose_command=${args[*]:2}
fi
@@ -70,8 +70,8 @@ parse_env() {
if [[ "$version" == "real" ]]; then
env="--file docker-compose.yml --file docker-compose.real.yml"
fi
if [[ "$version" == "test" ]]; then
env="--file docker-compose.yml --file docker-compose.test.yml"
if [[ "$version" == "api" ]]; then
env="--file docker-compose.ci.yml"
fi
return 0
}
@@ -85,9 +85,9 @@ parse_params "$@"
parse_profiles
parse_env
if [[ "$compose_command" == "" ]]; then
final_command="docker-compose ${env}"
final_command="docker compose ${env}"
else
final_command="docker-compose ${env} ${profiles} ${compose_command}"
final_command="docker compose ${env} ${profiles} ${compose_command}"
fi
msg "${BLUE}Running: ${final_command}${NOFORMAT}"
-24
View File
@@ -11,7 +11,6 @@ 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
## services. The real data databases inside service will have names suffixed by "-real".
@@ -139,29 +138,6 @@ 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() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# This script is used for instrumenting the frontend code
# Counters are added for calculating the code coverage
# Instrumented code is used for generating code coverage reports
# Ensure the script exits if any command fails
set -euo pipefail
# Define the directories
SOURCE_DIR="services/frontend/src"
INSTRU_DIR="services/frontend/instrumented"
# Print status
echo -n "Instrumenting frontend code... "
# Create the instrumented directory if it does not exist
mkdir -p $INSTRU_DIR
# Instrument the code
nyc instrument --all --compact=false $SOURCE_DIR $INSTRU_DIR
# Copy remaining files without overwriting instrumented files
#
# Images, CSS and other static files are not instrumended
# but they are still needed to run the application
rsync -av --quiet --ignore-existing $SOURCE_DIR/ $INSTRU_DIR/
# Print status
echo "Done!"
+7
View File
@@ -0,0 +1,7 @@
*/.dockerignore
*/.eslintrc.json
*/.gitignore
*/*Dockerfile
*/build
*/dist
*/node_modules
-3
View File
@@ -1,3 +0,0 @@
.dockerignore
*Dockerfile
node_modules/
+2 -2
View File
@@ -1,4 +1,4 @@
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
FROM registry.access.redhat.com/ubi9/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-22-minimal
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:24-alpine
ENV TZ="Europe/Helsinki"
@@ -10,4 +10,4 @@ COPY . .
RUN npm ci
EXPOSE 8080
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
+864 -344
View File
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -2,12 +2,15 @@
"name": "backend",
"private": true,
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
@@ -16,14 +19,14 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.23",
"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",
@@ -35,10 +38,11 @@
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.17",
"@types/morgan": "^1.9.10",
"@types/node": "^22.15.31",
"@types/validator": "^13.15.1",
"@types/node": "^24.10.1",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
},
+1 -6
View File
@@ -1,5 +1,6 @@
// Node env to use
export const isDev = process.env.NODE_ENV === 'development'
export const isTest = process.env.NODE_ENV === 'test'
export const isStaging = process.env.REACT_APP_STAGING === 'true'
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
@@ -32,12 +33,6 @@ 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
@@ -1,5 +1,5 @@
import { Request, Response } from 'express'
import { omit } from 'lodash'
import { omit } from 'lodash-es'
import morgan from 'morgan'
import { FormattedUser } from '../types'
+23 -73
View File
@@ -1,10 +1,9 @@
import * as Sentry from '@sentry/node'
import { NextFunction, Request, Response } from 'express'
import { intersection } from 'lodash'
import { intersection } from 'lodash-es'
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
import { FormattedUser, IamAccess } from '../types'
import { requiredGroup } from '../config'
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -14,30 +13,6 @@ const hasRequiredIamGroup = (iamGroups: string[], iamRights: string[]) => {
return intersection(iamGroups, requiredGroup).length > 0 || iamRights.length > 0
}
const getUser = async (
showAsUser: string | undefined,
username: string,
name: string,
email: string,
iamGroups: string[],
specialGroup: Record<string, boolean>,
sisId: string,
iamAccess: IamAccess
) => {
if (showAsUser && specialGroup?.superAdmin) {
return await getMockedUser({ userToMock: showAsUser, mockedBy: username })
}
return await getUserToska({
username,
name,
email,
iamGroups,
specialGroup,
sisId,
iamAccess,
})
}
type Headers = {
displayname?: string
hygroupcn?: string
@@ -49,7 +24,7 @@ type Headers = {
'x-show-as-user'?: string
}
const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
export default async (req: Request, _: Response, next: NextFunction) => {
const {
'shib-session-id': sessionId,
'x-show-as-user': showAsUser,
@@ -61,20 +36,15 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
uid: username,
} = req.headers as Headers
const missingHeaders: string[] = []
if (!sessionId) missingHeaders.push('shib-session-id')
if (!username) missingHeaders.push('uid')
if (missingHeaders.length > 0) {
const reqUser = username ?? 'Anonymous user'
if (!username || !sessionId)
throw new ApplicationError(
`${reqUser} requested ${req.url} without valid request headers. Missing: ${missingHeaders.join(', ')}`,
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
403,
{ logoutUrl }
)
}
const iamGroups = parseIamGroups(hygroupcn!)
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId!, iamGroups)
const iamRights = Object.keys(iamAccess)
@@ -83,49 +53,29 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
message: 'User does not have required iam group',
meta: { username, name, email, iamGroups, iamRights },
})
throw new ApplicationError(`User '${username}' does not have required iam group`, 403, { logoutUrl })
}
const user = await getUser(showAsUser, username!, name!, email!, iamGroups, specialGroup, sisId!, iamAccess)
if (!user) {
throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
}
const user =
showAsUser && specialGroup?.superAdmin
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
: await getUser({
username,
name: name!,
email: email!,
iamGroups,
specialGroup,
sisId: sisId!,
iamAccess,
})
Sentry.setUser({ username: user.mockedBy ?? username! })
// Set username just-in-case there is error for invalid user
Sentry.setUser({ username: user?.mockedBy ?? username })
if (!user) throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
req.user = user
req.logoutUrl = logoutUrl!
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']
+1 -1
View File
@@ -1,4 +1,4 @@
const { orderBy } = require('lodash')
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
+3 -9
View File
@@ -3,7 +3,7 @@ import compression from 'compression'
import cors from 'cors'
import { Express, json as jsonExpress } from 'express'
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
import { frontUrl, languageCenterViewEnabled } from './config'
import accessLogger from './middleware/accessLogger'
import * as auth from './middleware/auth'
import currentUserMiddleware from './middleware/currentUser'
@@ -34,8 +34,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 }))
@@ -66,12 +65,7 @@ const routes = (app: Express, url: string) => {
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)
+9 -6
View File
@@ -1,20 +1,19 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { isDev } from '../config'
import { isDev, isTest } from '../config'
const router = Router()
const changelog: { data?: Release[] } = {}
export type ChangelogResBody = Release[]
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
router.get<never, Release[]>('/', async (_, res) => {
if (changelog.data) {
return res.status(200).send(changelog.data)
}
if (isDev) {
if (isDev || isTest) {
const fakeRelease: Release[] = [
{
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
@@ -35,8 +34,11 @@ router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) =>
version: '0.0.1',
},
]
changelog.data = fakeRelease
return res.status(200).json(fakeRelease)
}
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
description: release.body,
@@ -44,6 +46,7 @@ router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) =>
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
})
@@ -1,22 +1,24 @@
import { Router } from 'express'
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
import type { CloseToGraduationResBody } from '@oodikone/shared/routes/closeToGraduation'
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const { user } = req
if (hasFullAccessToStudentData(user.roles)) {
const result = await getCloseToGraduationData()
return res.json(result)
if (!hasFullAccessToStudentData(user.roles)) {
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
const result = await getCloseToGraduationData(studentNumbers)
res.json(result)
}
const studentsInUsersGuidanceGroups = await getAllStudentsUserHasInGroups(user.sisPersonId)
const result = await getCloseToGraduationData(studentsInUsersGuidanceGroups)
res.json(result)
const result = await getCloseToGraduationData()
return res.json(result)
})
export default router
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { difference, intersection } from 'lodash'
import { difference, intersection } from 'lodash-es'
import { CanError } from '@oodikone/shared/routes'
import { tryCatch } from '@oodikone/shared/util'
@@ -42,18 +42,11 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
return res.status(400).json({ error: 'Courses must be of type array' })
}
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
// Teachers can also get rights to students via Importer if
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(
Promise.race([
importerClient.post('/teacher-rights/', { teacherId, studentNumbers }),
answerTimeout as Promise<never>, // This will always reject with an Error
])
)
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
: { data: null, error: null }
if (error) {
+19 -12
View File
@@ -1,19 +1,20 @@
import { Router } from 'express'
import type { ExcludedCoursesResBody, ExcludedCoursesReqBody } from '@oodikone/shared/routes/courseExclusions'
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
export type ExcludedCoursesParams = { code: string }
export type ExcludedCoursesResBody = void
export type ExcludedCoursesReqBody = {
courseCodes: string[]
curriculumVersion: string
}
router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
const hasAccessToProgramme = programmeRights.map(({ code }) => code).includes(programmeCode)
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
@@ -22,9 +23,15 @@ router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBod
res.status(201).end()
})
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
const hasAccessToProgramme = programmeRights.map(({ code }) => code).includes(programmeCode)
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
+37 -63
View File
@@ -2,106 +2,80 @@ import crypto from 'crypto'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
CoursesMultiResBody,
CoursesMultiReqBody,
CoursesMultiQuery,
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { CourseWithSubsId } from '../types/course'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
type CoursesMultiReqBody = never
type CoursesMultiResBody = { courses: CourseWithSubsId[] }
type CoursesMultiQuery = {
name: string
code: string
combineSubstitutions: string
}
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
async (req, res) => {
const { name, code, combineSubstitutions } = 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: CourseWithSubsId[] = await getCoursesByNameAndOrCode(name, code)
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
const mergedCourses = new Map<string, CourseWithSubsId>()
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? (course.subsId?.toString() ?? '') : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
if (!(course.max_attainment_date && course.min_attainment_date)) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
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.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>>
type CourseYearlyStatsReqBody = never
type CourseYearlyStatsQuery = {
codes: string[]
separate: string
combineSubstitutions: string
}
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/v3/courseyearlystats',
async (req, res) => {
const { roles, programmeRights } = req.user
const { codes, combineSubstitutions, separate } = req.query
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
return res.status(403).json({ error: 'No programmes so no access to course stats' })
}
const { codes } = req.query
if (!codes) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
const { roles, programmeRights } = req.user
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasAccessToCourseStats = userHasFullAccessToStudentData || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasAccessToCourseStats && !fullStudyProgrammeRights.length) {
return res.status(403).json({ error: 'No valid rights provided' })
}
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific degree programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
res.json(results)
}
)
+230 -192
View File
@@ -1,8 +1,18 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
import type { CanError, GenericApplicationError } from '@oodikone/shared/routes'
import type {
Graduated,
Name,
NameWithCode,
ProgrammeFilter,
SpecialGroups,
StatsType,
Unarray,
YearType,
} from '@oodikone/shared/types'
import * as auth from '../middleware/auth'
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
import { getDegreeProgrammesOfFaculty, getFacultyCodeById, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
import { getFacultyCredits } from '../services/faculty/facultyCredits'
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
@@ -18,242 +28,270 @@ import {
setFacultyStudentStats,
getFacultyProgressStats,
setFacultyProgressStats,
type BasicData,
type ThesisWriterData,
GraduationData,
} 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 logger from '../util/logger'
import { ApplicationError } from '../util/customErrors'
// Faculty uses a lot of tools designed for Degree programme.
// Some of them have been copied here and slightly edited for faculty purpose.
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const faculties = await getFacultiesForFacultyList()
res.json(faculties)
})
type GetFacultyListResBody = {
id: string
code: string
name: Name
}[]
interface GetStatsRequest extends Request {
query: {
programme_filter: ProgrammeFilter
special_groups: SpecialGroups
year_type: YearType
}
router.get<never, GetFacultyListResBody>('/', async (_req, res) => res.json(await getFacultiesForFacultyList()))
type GetStatsParams = { id: string }
type GetStatsReqBody = never
type GetStatsResBody = BasicData
type GetStatsQuery = {
programme_filter: ProgrammeFilter
special_groups: SpecialGroups
year_type: YearType
}
router.get('/:id/basicstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
router.get<GetStatsParams, CanError<GetStatsResBody, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
'/:id/basicstats',
auth.roles(['facultyStatistics']),
async (req, res) => {
const { id: facultyId } = req.params
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getBasicStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
const data = await getBasicStats(faculty.code, yearType, programmeFilter, specialGroups)
if (data) return res.json(data)
// TODO: The types could be handled better. Possible null value is causing issues with TS.
let updatedStats: any = await combineFacultyBasics(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
interface GetCreditStatsRequest extends Request {
query: {
year_type: YearType
const updatedStats = await combineFacultyBasics(faculty.code, programmes, yearType, specialGroups)
if (updatedStats) await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
else throw new ApplicationError('No data', 204)
return res.json(updatedStats)
}
)
type GetFacultyStatsResBody = ThesisWriterData
router.get<GetStatsParams, CanError<GetFacultyStatsResBody, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
'/:id/thesisstats',
auth.roles(['facultyStatistics']),
async (req, res) => {
const { id: facultyId } = req.params
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
const data = await getThesisWritersStats(faculty.code, yearType, programmeFilter, specialGroups)
if (data) return res.json(data)
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
const updatedStats = await combineFacultyThesisWriters(faculty.code, programmes, yearType, specialGroups)
if (updatedStats) await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
else throw new ApplicationError('No data', 204)
return res.json(updatedStats)
}
)
type GetCreditStatsResBody = {
codes: string[]
ids: string[]
programmeNames: Record<string, NameWithCode>
}
type GetCreditStatsQuery = {
year_type: YearType
}
router.get('/:id/creditstats', auth.roles(['facultyStatistics']), async (req: GetCreditStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
router.get<
GetStatsParams,
CanError<GetCreditStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetCreditStatsQuery
>('/:id/creditstats', auth.roles(['facultyStatistics']), async (req, res) => {
const { id: facultyId } = req.params
const { year_type: yearType } = req.query
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
return res.json(stats)
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
return res.json(await getFacultyCredits(faculty.code, yearType === 'ACADEMIC_YEAR'))
})
router.get('/:id/thesisstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
type GetGraduationStatsResBody = GraduationData
type GetGraduationStatsQuery = {
programme_filter: ProgrammeFilter
}
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getThesisWritersStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
router.get<
GetStatsParams,
CanError<GetGraduationStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetGraduationStatsQuery
>('/:id/graduationtimes', auth.roles(['facultyStatistics']), async (req, res) => {
const { id: facultyId } = req.params
const { programme_filter: programmeFilter } = req.query
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
const faculty = await getFacultyCodeById(facultyId)
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)
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
const updatedStats = await countGraduationTimes(faculty.code, programmes)
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
let updatedStats: any = await combineFacultyThesisWriters(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
interface GetGraduationStatsRequest extends Request {
query: {
programme_filter: ProgrammeFilter
}
type GetProgressStatsResBody = {
bachelorsProgStats: Record<string, number[][]>
bcMsProgStats: Record<string, number[][]>
creditCounts: Record<string, Record<string, number[]>>
doctoralProgStats: Record<string, number[][]>
id: string
mastersProgStats: Record<string, number[][]>
programmeNames: Record<string, NameWithCode>
yearlyBachelorTitles: string[][]
yearlyBcMsTitles: string[][]
yearlyMasterTitles: string[][]
years: string[]
}
router.get(
'/:id/graduationtimes',
auth.roles(['facultyStatistics']),
async (req: GetGraduationStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
const { programme_filter: programmeFilter } = req.query
const data = await getGraduationStats(code, programmeFilter)
if (data) {
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes) {
return res.status(422).end()
}
let updatedStats: any = await countGraduationTimes(code, programmes)
if (updatedStats) {
updatedStats = await setGraduationStats(updatedStats, programmeFilter)
}
return res.json(updatedStats)
}
)
interface GetProgressStatsRequest extends Request {
query: {
graduated: Graduated
special_groups: SpecialGroups
}
type GetProgressStatsQuery = {
graduated: Graduated
special_groups: SpecialGroups
}
router.get(
'/:id/progressstats',
auth.roles(['facultyStatistics']),
async (req: GetProgressStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
router.get<
GetStatsParams,
CanError<GetProgressStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetProgressStatsQuery
>('/:id/progressstats', auth.roles(['facultyStatistics']), async (req, res) => {
const { id: facultyId } = req.params
const { special_groups: specialGroups } = req.query
const programmes = await getDegreeProgrammesOfFaculty(code, true)
if (!programmes) {
return res.status(422).end()
}
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
const { graduated, special_groups: specialGroups } = req.query
const data = await getFacultyProgressStats(code, specialGroups, graduated)
if (data) {
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, true)
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
let updatedStats: any = await combineFacultyStudentProgress(code, programmes, specialGroups, graduated)
if (updatedStats) {
updatedStats = await setFacultyProgressStats(updatedStats, specialGroups, graduated)
}
return res.json(updatedStats)
}
)
const data = await getFacultyProgressStats(faculty.code, specialGroups)
if (data) return res.json(data)
interface GetStudentStatsRequest extends GetProgressStatsRequest {}
const updatedStats = await combineFacultyStudentProgress(faculty.code, programmes, specialGroups)
if (updatedStats) await setFacultyProgressStats(updatedStats, specialGroups)
router.get(
'/:id/studentstats',
auth.roles(['facultyStatistics']),
async (req: GetStudentStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
return res.json(updatedStats)
})
const { graduated, special_groups: specialGroups } = req.query
export type DegreeProgramme = Pick<
Unarray<ProgrammesOfOrganization>,
'code' | 'name' | 'degreeProgrammeType' | 'progId'
>
/* Fetch cached data if exists */
const data = await getFacultyStudentStats(code, specialGroups, graduated)
if (data) {
return res.json(data)
}
const newProgrammes = await getDegreeProgrammesOfFaculty(code, true)
if (!newProgrammes.length) {
return res.status(422).end()
}
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
if (updatedStats) {
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
}
return res.json(updatedStats)
}
)
interface GetUpdateBasicViewRequest extends Request {
query: {
stats_type: StatsType
}
type GetStudetStatsResBody = {
id: string
years: string[]
facultyTableStats: Record<string, (number | string)[]>
facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>>
/**
* NOTE: programmeStats order is random (not by the year). Make sure to index properly.
*
* (Order is based on the order in which studyrights were processed in the backend)
*/
programmeStats: Record<string, Record<string, (string | number)[]>>
titles: string[]
programmeNames: Record<string, DegreeProgramme>
}
router.get(
'/:id/update_basicview',
auth.roles(['facultyStatistics']),
async (req: GetUpdateBasicViewRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
const { stats_type: statsType } = req.query
try {
const result = await updateFacultyOverview(code, statsType)
return res.json(result)
} catch (error) {
const message = `Failed to update basic stats for faculty ${code}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
}
)
router.get<
GetStatsParams,
CanError<GetStudetStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetProgressStatsQuery
>('/:id/studentstats', auth.roles(['facultyStatistics']), async (req, res) => {
const { id: facultyId } = req.params
const { special_groups: specialGroups } = req.query
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
const data = await getFacultyStudentStats(faculty.code, specialGroups)
if (data) return res.json(data)
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, true)
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
const updatedStats = await combineFacultyStudents(faculty.code, programmes, specialGroups)
if (updatedStats) await setFacultyStudentStats(updatedStats, specialGroups)
return res.json(updatedStats)
})
type GetUpdateBasicViewResBody = never
type GetUpdateBasicViewQuery = {
stats_type: StatsType
}
router.get<
GetStatsParams,
CanError<GetUpdateBasicViewResBody, GenericApplicationError>,
GetStatsReqBody,
GetUpdateBasicViewQuery
>('/:id/update_basicview', auth.roles(['facultyStatistics']), async (req, res) => {
const { id: facultyId } = req.params
const { stats_type: statsType } = req.query
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
router.get('/:id/update_progressview', auth.roles(['facultyStatistics']), async (req: Request, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
try {
const result = await updateFacultyProgressOverview(code)
return res.json(result)
await updateFacultyOverview(faculty.code, statsType)
return res.json()
} catch (error) {
const message = `Failed to update progress tab stats for faculty ${code}`
logger.error(message, { error })
return res.status(500).json({ error: message })
throw new ApplicationError(`Failed to update basic stats for faculty ${faculty.code}`, 500)
}
})
router.get<GetStatsParams, CanError<never, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
'/:id/update_progressview',
auth.roles(['facultyStatistics']),
async (req, res) => {
const { id: facultyId } = req.params
const faculty = await getFacultyCodeById(facultyId)
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
try {
await updateFacultyProgressOverview(faculty.code)
return res.json()
} catch (error) {
throw new ApplicationError(`Failed to update progress tab stats for faculty ${faculty.code}`, 500)
}
}
)
export default router
+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,
})
+1 -1
View File
@@ -17,7 +17,7 @@ router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
const { user, logoutUrl } = req
if (!user) {
throw new ApplicationError('User not found', 404)
throw new ApplicationError('User not found', 401)
}
res.send({
+140 -110
View File
@@ -1,6 +1,5 @@
import crypto from 'crypto'
import { Router } from 'express'
import { difference, intersection, uniq } from 'lodash'
import { difference, intersection, uniq } from 'lodash-es'
import type { CanError } from '@oodikone/shared/routes'
import {
@@ -10,34 +9,107 @@ import {
type PopulationstatisticsbycourseResBody,
type PopulationstatisticsbycourseReqBody,
type PopulationstatisticsbycourseParams,
type PostByStudentNumbersResBody,
type PostByStudentNumbersReqBody,
type GetCustomPopulationResBody,
type CustomPopulationQuery,
type PopulationstatisticsStudyprogrammesResBody,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
} from '@oodikone/shared/routes/populations'
import { GenderCode, 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 { SISStudyRightModel } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { getStudentTags } from '../services/populations/getStudentData'
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 { ParsedCourse } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
const obfuscateStuff = ({
result,
programme,
combinedProgramme,
userRoles,
userProgrammeRights,
studentsUserCanAccess,
}: {
result: Awaited<ReturnType<typeof statisticsOf>>
[key: string]: any
}): Awaited<ReturnType<typeof statisticsOf>> => {
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme)
if (hasFullAccessToStudents || hasFullRightsToProgramme || hasFullRightsToCombinedProgramme) {
return result
}
const { criteria, students, coursestatistics } = result
const encryptionMap = new Map<string, string>()
return {
criteria,
students: students.map(student => {
if (studentsUserCanAccess.has(student.studentNumber)) return student
// correct year for age distribution calculation but the date is always January 1st
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
encryptionMap.set(student.studentNumber, studentNumber)
return {
...student,
obfuscated: true,
iv,
studentNumber,
firstnames: '',
lastname: '',
phoneNumber: '',
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
tags: [],
birthdate: obfuscatedBirthDate,
}
}),
coursestatistics: {
courses: coursestatistics.courses,
enrollments: coursestatistics.enrollments.map(enrollment => ({
...enrollment,
studentnumber: encryptionMap.get(enrollment.studentnumber) ?? enrollment.studentnumber,
})),
credits: coursestatistics.credits.map(credit => ({
...credit,
student_studentnumber: encryptionMap.get(credit.student_studentnumber) ?? credit.student_studentnumber,
})),
},
}
}
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
const { 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)) {
@@ -48,18 +120,16 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
}
const hasCorrectStatus = (studentStatuses: string[]) =>
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
const hasCorrectStatus = (studentStatuses: (string | undefined)[]) =>
studentStatuses.every(status => status && ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
// NOTE: This has always the length of at least 1, [undefined]
if (studentStatuses[0] && !hasCorrectStatus(studentStatuses)) {
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme!)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
@@ -68,10 +138,8 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
const { startDate, endDate } = parseDateRangeFromParams({
...req.query,
years: req.query.years,
})
// NOTE: Years are academic years
const { startDate, endDate } = parseDateRangeFromParams({ semesters, years })
const studentNumbers = await getStudentNumbersWithStudyRights({
programmeCodes: [programme],
@@ -81,35 +149,21 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
})
const studyRights = [programme]
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
const result = await statisticsOf(studentNumbers, studyRights, tagMap, startDate)
const processed = obfuscateStuff({
result,
// Obfuscate if user has only limited degree programme rights and there are any students
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
// correct year for age distribution calculation but the date is always January 1st
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
return {
...student,
firstnames: '',
lastname: '',
phoneNumber: '',
iv,
studentNumber,
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
}
})
}
programme,
combinedProgramme,
return res.json(result)
userRoles,
userProgrammeRights,
studentsUserCanAccess: new Set(studentsUserCanAccess),
})
return res.json(processed)
}
)
@@ -119,7 +173,12 @@ router.get<
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/v3/populationstatisticsbycourse', async (req, res) => {
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
const {
id: userId,
roles: userRoles,
programmeRights: userProgrammeRights,
studentsUserCanAccess: allStudentsUserCanAccess,
} = req.user
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
if (!coursecodeJSON || !from || !to) {
@@ -139,79 +198,51 @@ router.get<
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const studyRights = []
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
const result: any = await statisticsOf(studentNumbers, studyRights, tagList)
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
? await getCourseProvidersForCourses(coursecodes)
: []
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
const studentsUserCanAccess =
hasFullAccessToStudentData(roles) || found
? new Set(studentNumbers)
: new Set(intersection(studentNumbers, allStudentsUserCanAccess))
const studentsUserCanAccess = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
? new Set(studentNumbers)
: new Set(allStudentsUserCanAccess)
const randomHash = crypto.randomBytes(12).toString('hex')
const obfuscateStudent = ({
studyRights,
studentNumber,
courses,
gender_code,
}: {
studyRights: SISStudyRightModel[]
studentNumber: string
courses: ParsedCourse[]
gender_code: GenderCode
}) => ({
courses,
studyRights,
gender_code,
studentNumber: crypto.createHash('md5').update(`${studentNumber}${randomHash}`).digest('hex'),
firstnames: '',
lastname: '',
phone_number: '',
name: '',
email: '',
started: null,
credits: '',
tags: [],
obfuscated: true,
const studyRights = []
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
const processed = obfuscateStuff({
result,
userRoles,
userProgrammeRights,
studentsUserCanAccess,
})
if ('students' in result) {
result.students = result.students.map(student =>
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
)
}
res.json(result)
return res.json(processed)
})
router.post<never, PostByStudentNumbersResBody, PostByStudentNumbersReqBody>(
// Used in custom population and single study guidance groups
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
'/v3/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentnumberlist, tags } = req.body
const { studentNumbers, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
const { startDate } = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
: { startDate: undefined }
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
@@ -229,14 +260,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,
})
}
)
+46 -53
View File
@@ -1,65 +1,58 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import type { CanError } from '@oodikone/shared/routes'
import type {
GetStudentRequestResBody,
GetStudentRequestReqBody,
GetStudentRequestQuery,
GetStudentDetailParams,
GetStudentDetailResBody,
GetStudentDetailReqBody,
} from '@oodikone/shared/routes/students'
import { splitByEmptySpace } from '@oodikone/shared/util'
import { bySearchTermAndStudentNumbers, withStudentNumber } from '../services/students'
import { hasFullAccessToStudentData, splitByEmptySpace } from '../util'
import { ApplicationError } from '../util/customErrors'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
interface GetStudentsRequest extends Request {
query: {
searchTerm: string
router.get<never, CanError<GetStudentRequestResBody>, GetStudentRequestReqBody, GetStudentRequestQuery>(
'/',
async (req, res) => {
const { searchTerm } = req.query
const trimmedSearchTerm = searchTerm?.trim()
const validSearchTerm =
trimmedSearchTerm &&
!splitByEmptySpace(trimmedSearchTerm)
.slice(0, 2)
.find(term => term.length > 2)
if (!trimmedSearchTerm || validSearchTerm) {
return res.status(400).json({ error: 'at least one search term must be longer than 2 characters' })
}
const { roles, studentsUserCanAccess } = req.user
const results: ReturnType<typeof bySearchTermAndStudentNumbers> = hasFullAccessToStudentData(roles)
? bySearchTermAndStudentNumbers(trimmedSearchTerm)
: bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
return res.json(await results)
}
}
)
router.get('/', async (req: GetStudentsRequest, res: Response) => {
const {
user: { roles, studentsUserCanAccess },
query: { searchTerm },
} = req
router.get<GetStudentDetailParams, CanError<GetStudentDetailResBody>, GetStudentDetailReqBody>(
'/:studentNumber',
async (req, res) => {
const { studentNumber } = req.params
const { id, roles, studentsUserCanAccess } = req.user
const trimmedSearchTerm = searchTerm ? searchTerm.trim() : undefined
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber))
return res.status(403).json({ error: 'User does not have permission to view current student.' })
if (
trimmedSearchTerm &&
!splitByEmptySpace(trimmedSearchTerm)
.slice(0, 2)
.find(term => term.length > 2)
) {
throw new ApplicationError('at least one search term must be longer than 2 characters', 400)
const student = await withStudentNumber(studentNumber, id)
if (!student) return res.status(404).json({ error: 'Student not found' })
res.json(student)
}
let results: Awaited<ReturnType<typeof bySearchTermAndStudentNumbers>> = []
if (trimmedSearchTerm) {
results = hasFullAccessToStudentData(roles)
? await bySearchTermAndStudentNumbers(trimmedSearchTerm)
: await bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
}
res.json(results)
})
interface GetStudentRequest extends Request {
params: {
studentNumber: string
}
}
router.get('/:studentNumber', async (req: GetStudentRequest, res: Response) => {
const { studentNumber } = req.params
const {
user: { id, roles, studentsUserCanAccess },
} = req
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber)) {
return res
.status(403)
.json({ error: `User does not have permission to view data for student number ${studentNumber}.` })
}
const student = await withStudentNumber(studentNumber, id)
if (!student) {
return res.status(404).json({ error: `Student not found with student number ${studentNumber}.` })
}
res.json(student)
})
)
export default router
+8 -10
View File
@@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
import {
getBasicStats,
setBasicStats,
@@ -11,10 +12,8 @@ import {
} from '../services/analyticsService'
import { getCreditsProduced } from '../services/providerCredits'
import { getBasicStatsForStudytrack } from '../services/studyProgramme/studyProgrammeBasics'
import {
getStudyProgrammeCoursesForStudyTrack,
getStudyProgrammeStatsForColorizedCoursesTable,
} from '../services/studyProgramme/studyProgrammeCourses'
import { getStudyProgrammeCoursesForStudyTrack } from '../services/studyProgramme/studyProgrammeCourses'
import { getStudyProgrammeStatsForColorizedCoursesTable } from '../services/studyProgramme/studyProgrammeCoursesBySemester'
import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/studyProgrammeGraduations'
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
@@ -34,11 +33,11 @@ interface GetCreditStatsRequest extends Request {
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
const { codes, specialGroups, yearType } = req.query
const stats = {}
const stats: CreditStatsPayload = {}
for (const code of JSON.parse(codes)) {
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
}
return res.json({ stats })
return res.json(stats)
})
interface GetStatsRequest extends Request {
@@ -134,13 +133,13 @@ interface GetStudyTrackStatsRequest extends Request {
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
const code = req.params.id
const { graduated, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
return res.status(422).end()
}
void logInfoForGrafana(code, combinedProgramme)
const data = await getStudyTrackStats(code, combinedProgramme, graduated, specialGroups)
const data = await getStudyTrackStats(code, combinedProgramme, specialGroups)
if (data) {
return res.json(data)
}
@@ -150,13 +149,12 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
studyProgramme: code,
combinedProgramme,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
studyRightsOfProgramme,
})
if (updated) {
await setStudyTrackStats(updated, graduated, specialGroups)
await setStudyTrackStats(updated, specialGroups)
}
return res.json(updated)
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { difference } from 'lodash'
import { difference } from 'lodash-es'
import { CanError } from '@oodikone/shared/routes'
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
+8 -9
View File
@@ -1,19 +1,17 @@
import { Request, Response, Router } from 'express'
import { mapToProviders } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
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, splitByEmptySpace } from '../util'
import { getFullStudyProgrammeRights, 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: {
@@ -28,9 +26,10 @@ router.get('/', fullAccessAuth(), async (req: GetTeachersRequest, res: Response)
}
const trimmedSearchTerm = searchTerm.trim()
const stringSearchTermIsInvalid = !splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
const numericSearchTermIsInvalid = !Number.isNaN(Number(trimmedSearchTerm)) && trimmedSearchTerm.length < 6
if (stringSearchTermIsInvalid || numericSearchTermIsInvalid) {
const searchTermIsInvalid =
!validateParamLength(trimmedSearchTerm, 4) &&
!splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
if (searchTermIsInvalid) {
return res.status(400).json({ error: 'Invalid search term' })
}
@@ -57,7 +56,7 @@ router.get('/top', fullAccessAuth(), async (req: GetTopTeachersRequest, res: Res
}
await findAndSaveTeachers(Number(yearcode), Number(yearcode))
const updatedStats = await getTeacherStats(category, Number(yearcode))
res.json(updatedStats)
return res.json(updatedStats)
})
interface PostTopTeachersRequest extends Request {
+26 -8
View File
@@ -1,8 +1,7 @@
import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
import { magicFacultyCode } from '../config/organizationConstants'
import { OrganizationModel } from '../models'
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
@@ -15,6 +14,7 @@ import {
setFacultyProgressStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { GraduationTarget } from '../services/graduationHelpers'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import logger from '../util/logger'
@@ -39,18 +39,17 @@ interface GetProgressStatsRequest extends Request {
router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Response) => {
const specialGroups = req.query?.specialsIncluded === 'true' ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
const graduated = req.query?.graduated
const allFaculties = await getSortedFaculties()
const facultyCodes = allFaculties.map(faculty => faculty.code)
const codeToData: Record<string, FacultyProgressData> = {}
for (const facultyCode of facultyCodes) {
let data: any = await getFacultyProgressStats(facultyCode, specialGroups, graduated)
let data = await getFacultyProgressStats(facultyCode, specialGroups)
if (!data) {
logger.info(`Data missing from server: Refreshing progress faculty data for faculty ${facultyCode}`)
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, true)
data = await combineFacultyStudentProgress(facultyCode, programmes, specialGroups, graduated)
await setFacultyProgressStats(data, specialGroups, graduated)
data = await combineFacultyStudentProgress(facultyCode, programmes, specialGroups)
await setFacultyProgressStats(data, specialGroups)
}
codeToData[facultyCode] = data
}
@@ -71,6 +70,12 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
master: {} as Record<string, number[]>,
doctor: {} as Record<string, number[]>,
},
graduatedCount: {
bachelor: {} as Record<string, number>,
bachelorMaster: {} as Record<string, number>,
master: {} as Record<string, number>,
doctor: {} as Record<string, number>,
},
}
const unifyProgressStats = (progStats: number[][][]) => {
@@ -96,6 +101,14 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
}
for (const fieldName of degreeNames) {
if (!facultyData.graduatedCount[fieldName] || Object.keys(facultyData.graduatedCount[fieldName]).length === 0)
continue
if (!universityData.graduatedCount[fieldName][year]) universityData.graduatedCount[fieldName][year] = 0
universityData.graduatedCount[fieldName][year] += facultyData.graduatedCount[fieldName][year]
}
const progStats = ['bachelorsProgStats', 'bcMsProgStats', 'mastersProgStats', 'doctoralProgStats'] as const
for (const fieldName of progStats) {
if (Object.keys(facultyData[fieldName] || {}).length === 0) continue
@@ -112,7 +125,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
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 programmeFilter = 'NEW_DEGREE_PROGRAMMES'
for (const facultyCode of facultyCodes) {
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
if (!data) {
@@ -125,7 +138,12 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
}
const universityData = {
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
goals: {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
},
programmeNames: getProgrammeNames(allFaculties),
byGradYear: {
medians: {} as Record<string, LevelGraduationStats[]>,
+1 -1
View File
@@ -4,11 +4,11 @@ import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { FormattedUser } from 'src/types'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const router = Router()
-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
@@ -1,4 +1,5 @@
import { Graduated, SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
import { SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
import { BasicStats, CreditStats, ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -15,11 +16,12 @@ const createRedisKeyForCreditStats = (id: string, yearType: YearType, specialGro
const createRedisKeyForGraduationStats = (id: string, yearType: YearType, specialGroups: SpecialGroups) => {
return `GRADUATION_STATS_${id}_${yearType}_${specialGroups}`
}
const createRedisKeyForStudyTrackStats = (id: string, graduated: Graduated, specialGroups: SpecialGroups) => {
return `STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
const createRedisKeyForStudyTrackStats = (id: string, specialGroups: SpecialGroups) => {
return `STUDYTRACK_STATS_${id}_${specialGroups}`
}
const shouldSaveToRedis = (id: unknown) => {
// *Should* always be a string (previously unknown)
const shouldSaveToRedis = (id: string) => {
if (typeof id !== 'string') return false
if (isRelevantProgramme(id)) return true
const splitId = id.split('-')
@@ -31,7 +33,7 @@ export const getBasicStats = async (
combinedProgramme: string | null,
yearType: YearType,
specialGroups: SpecialGroups
) => {
): Promise<BasicStats | null> => {
const searchKey = combinedProgramme ? `${id}-${combinedProgramme}` : id
const redisKey = createRedisKeyForBasicStats(searchKey, yearType, specialGroups)
const dataFromRedis = await redisClient.get(redisKey)
@@ -41,22 +43,13 @@ export const getBasicStats = async (
return JSON.parse(dataFromRedis)
}
export const setBasicStats = async (data, yearType: YearType, specialGroups: SpecialGroups) => {
export const setBasicStats = async (data: BasicStats, yearType: YearType, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
return
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getCreditStats = async (providerCode: string, isAcademicYear: boolean, specialGroups: boolean) => {
@@ -69,29 +62,20 @@ export const getCreditStats = async (providerCode: string, isAcademicYear: boole
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis)
return JSON.parse(dataFromRedis) as CreditStats
}
export const setCreditStats = async (data, isAcademicYear: boolean, specialGroups: boolean) => {
export const setCreditStats = async (data: CreditStats, isAcademicYear: boolean, specialGroups: boolean) => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(
id,
isAcademicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR',
specialGroups ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
if (!isRelevantProgramme(id) && !isFaculty(id)) {
return dataToRedis
return
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getGraduationStats = async (
@@ -106,56 +90,37 @@ export const getGraduationStats = async (
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis)
return JSON.parse(dataFromRedis) as ProgrammeGraduationStats
}
export const setGraduationStats = async (data, yearType: YearType, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForGraduationStats(id, yearType, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
return
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const redisKey = createRedisKeyForGraduationStats(id, yearType, specialGroups)
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getStudyTrackStats = async (
id: string,
combinedProgramme: string | null,
graduated: Graduated,
specialGroups: SpecialGroups
) => {
const searchKey = combinedProgramme ? `${id}-${combinedProgramme}` : id
const redisKey = createRedisKeyForStudyTrackStats(searchKey, graduated, specialGroups)
const redisKey = createRedisKeyForStudyTrackStats(searchKey, specialGroups)
const dataFromRedis = await redisClient.get(redisKey)
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis) as StudyTrackStats & { status: string; lastUpdated: string }
return JSON.parse(dataFromRedis) as StudyTrackStats
}
export const setStudyTrackStats = async (data: StudyTrackStats, graduated: Graduated, specialGroups: SpecialGroups) => {
export const setStudyTrackStats = async (data: StudyTrackStats, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
return
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const redisKey = createRedisKeyForStudyTrackStats(id, specialGroups)
await redisClient.set(redisKey, JSON.stringify(data))
}
@@ -1,9 +1,9 @@
import { orderBy } from 'lodash'
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getSortRank } from '../../../src/util/sortRank'
import { CourseModel } from '../../models'
import { CourseWithSubsId } from '../../types'
import { getSortRank } from '../../util/sortRank'
const nameLikeTerm = (name: string) => {
if (!name) {
@@ -40,63 +40,50 @@ const codeLikeTerm = (code: string) => {
}
}
const getRawCourses = async (name: string, code: string) => {
return await CourseModel.findAll({
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) =>
await CourseModel.findAll({
where: {
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
...nameLikeTerm(name),
...codeLikeTerm(code),
},
order: [['id', 'desc']],
raw: true,
})
}
const getCourses = async (name: string, code: string) => {
const rawCourses = await getRawCourses(name, code)
const courses: CourseWithSubsId[] = rawCourses
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
const rawCourses = await getRawCourses(name, code, includeSpecial)
return rawCourses
.map(course => ({
...course.toJSON(),
...course,
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
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
}))
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
return courses
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code)) as CourseWithSubsId[]
}
export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
const courses = await getCourses(name, code)
let substitutionGroupIndex = 0
const visitedCourses: string[] = []
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
const courses = await getCourses(name, code, includeSpecial)
const assignSubstitutionGroup = (course: CourseWithSubsId) => {
if (visitedCourses.includes(course.code)) {
return
}
let relatedCourses: CourseWithSubsId[] = []
if (course.substitutions !== null) {
relatedCourses = courses.filter(currentCourse => course.substitutions.includes(currentCourse.code))
}
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 => {
if (visitedCourses.includes(relatedCourse.id)) {
return
}
visitedCourses.push(relatedCourse.id)
relatedCourse.subsId = substitutionGroupIndex
visitedCourses.add(relatedCourse.id)
relatedCourse.subsId = index
})
}
courses.forEach(course => {
if (!visitedCourses.includes(course.id)) {
substitutionGroupIndex++
assignSubstitutionGroup(course)
}
})
courses
.filter(course => !visitedCourses.has(course.id) && !visitedCourses.has(course.code))
.forEach((course, index) => assignSubstitutionGroup(course, index))
return courses
}
@@ -1,108 +1,23 @@
import { max, min } from 'lodash'
import { Name } from '@oodikone/shared/types'
import { getSemesterNames } from '../semesters'
import { FormattedProgramme, OrganizationDetails } from './helpers'
type Programme = {
name: Name
students: Record<number, string[]>
passed: Record<number, string[]>
credits: Record<number, number>
}
type Faculty = {
name: Name | null
students: string[]
passed: string[]
credits: number
}
type FacultyYearStats = {
year: string
allStudents: string[]
allPassed: string[]
faculties: Record<string, Faculty>
allCredits: number
}
type Grades = Record<string, string[]>
type GroupAttempts = {
grades: Grades
categories: {
passed: string[]
failed: string[]
}
}
type GroupStudents = {
grades: Record<string, { grade: string; passed: boolean }>
studentNumbers: string[]
}
type Group = {
code: number
name: string | Name
coursecode: string
attempts: GroupAttempts
students: GroupStudents
enrollments: { studentNumber: string; enrollmentDateTime: Date }[]
allEnrollments: { studentNumber: string; enrollmentDateTime: Date }[]
yearCode: number
}
type Student = {
earliestAttainment: Date
code: number
}
import type { Name } from '@oodikone/shared/types'
import type { Programme, FacultyYearStats, Grades, Group, Student } from '@oodikone/shared/types/courseYearlyStats'
import { getSemesterNamesByCode } from '../semesters'
import type { OrganizationDetails } from './helpers'
export class CourseYearlyStatsCounter {
private groups: Record<number, Group>
private programmes: Record<string, Programme>
private facultyStats: Record<number, FacultyYearStats>
private obfuscated: boolean
private students: Map<string, Student>
constructor() {
this.groups = {}
this.programmes = {}
this.facultyStats = {}
this.obfuscated = false
this.students = new Map()
}
private initProgramme(code: string, name: Name) {
this.programmes[code] = {
name,
students: {},
passed: {},
credits: {},
}
}
private initFacultyYear(code: number) {
const year = `${1949 + Number(code)}-${1950 + Number(code)}`
this.facultyStats[code] = {
year,
allStudents: [],
allPassed: [],
faculties: {},
allCredits: 0,
}
}
private initFaculty(yearCode: number, facultyCode: string, organization: OrganizationDetails | null) {
this.facultyStats[yearCode].faculties[facultyCode] = {
name: organization ? organization.name : null,
students: [],
passed: [],
credits: 0,
}
}
private initGroup(groupCode: number, name: string | Name, courseCode: string, yearCode: number) {
this.groups[groupCode] = {
this.groups[groupCode] ??= {
code: groupCode,
name,
coursecode: courseCode,
@@ -124,37 +39,45 @@ export class CourseYearlyStatsCounter {
}
public markStudyProgramme(
code: string,
name: Name,
studentNumber: string,
yearCode: number,
passed: boolean,
credits: number,
facultyCode: string | null,
organization: OrganizationDetails | null
code: string,
name: Name,
facultyCode: string,
organization: OrganizationDetails
) {
if (!this.programmes[code]) {
this.initProgramme(code, name)
}
if (!this.facultyStats[yearCode]) {
this.initFacultyYear(yearCode)
}
if (
facultyCode &&
!this.facultyStats[yearCode].faculties[facultyCode] &&
!this.facultyStats[yearCode].allPassed.includes(studentNumber)
) {
this.initFaculty(yearCode, facultyCode, organization)
this.programmes[code] ??= {
name,
students: {},
passed: {},
credits: {},
}
this.programmes[code].students[yearCode] = this.programmes[code].students[yearCode] || []
const year = `${1949 + Number(yearCode)}-${1950 + Number(yearCode)}`
this.facultyStats[yearCode] ??= {
year,
allStudents: [],
allPassed: [],
faculties: {},
allCredits: 0,
}
this.facultyStats[yearCode].faculties[facultyCode] ??= {
name: organization?.name ?? null,
students: [],
passed: [],
credits: 0,
}
this.programmes[code].students[yearCode] ??= []
this.programmes[code].students[yearCode].push(studentNumber)
if (!this.programmes[code].passed[yearCode]) {
this.programmes[code].passed[yearCode] = []
this.programmes[code].credits[yearCode] = 0
}
if (facultyCode && !this.facultyStats[yearCode].allStudents.includes(studentNumber)) {
this.programmes[code].passed[yearCode] ??= []
this.programmes[code].credits[yearCode] ??= 0
if (!this.facultyStats[yearCode].allStudents.includes(studentNumber)) {
this.facultyStats[yearCode].allStudents.push(studentNumber)
this.facultyStats[yearCode].faculties[facultyCode].students.push(studentNumber)
}
@@ -162,7 +85,7 @@ export class CourseYearlyStatsCounter {
this.programmes[code].passed[yearCode].push(studentNumber)
this.programmes[code].credits[yearCode] += credits
}
if (facultyCode && passed && !this.facultyStats[yearCode].allPassed.includes(studentNumber)) {
if (passed && !this.facultyStats[yearCode].allPassed.includes(studentNumber)) {
this.facultyStats[yearCode].allPassed.push(studentNumber)
this.facultyStats[yearCode].faculties[facultyCode].passed.push(studentNumber)
this.facultyStats[yearCode].faculties[facultyCode].credits += credits
@@ -170,18 +93,6 @@ export class CourseYearlyStatsCounter {
}
}
public markStudyProgrammes(
studentNumber: string,
programmes: FormattedProgramme[],
yearCode: number,
passed: boolean,
credits: number
) {
programmes.forEach(({ code, name, facultyCode, organization }) => {
this.markStudyProgramme(code, name, studentNumber, yearCode, passed, credits, facultyCode, organization)
})
}
public markCreditToGroup(
studentNumber: string,
passed: boolean,
@@ -191,11 +102,26 @@ export class CourseYearlyStatsCounter {
courseCode: string,
yearCode: number
) {
if (!this.groups[groupCode]) {
this.initGroup(groupCode, groupName, courseCode, yearCode)
this.initGroup(groupCode, groupName, courseCode, yearCode)
// mark credit to attempts
const { grades, categories } = this.groups[groupCode].attempts
grades[grade] ??= []
grades[grade].push(studentNumber)
if (passed) {
categories.passed.push(studentNumber)
} else {
categories.failed.push(studentNumber)
}
this.markCreditToAttempts(studentNumber, passed, grade, groupCode)
this.markBestEffortGrade(studentNumber, passed, grade, groupCode)
// mark best effort grade
const current = this.groups[groupCode].students.grades[studentNumber]
if (current?.passed && !passed) return
if (current?.passed && Number(grade) <= Number(current?.grade)) return
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
}
public markEnrollmentToGroup(
@@ -206,16 +132,15 @@ export class CourseYearlyStatsCounter {
courseCode: string,
yearCode: number
) {
if (!this.groups[groupCode]) {
this.initGroup(groupCode, groupName, courseCode, yearCode)
}
const enrollment = { studentNumber, enrollmentDateTime }
this.groups[groupCode].allEnrollments.push(enrollment)
const oldEnrollment = this.groups[groupCode].enrollments.find(
this.initGroup(groupCode, groupName, courseCode, yearCode)
const oldEnrollment = this.groups[groupCode].enrollments.some(
enrollment => enrollment.studentNumber === studentNumber
)
const enrollment = { studentNumber, enrollmentDateTime }
this.groups[groupCode].allEnrollments.push(enrollment)
if (!oldEnrollment) {
this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
this.groups[groupCode].enrollments.push(enrollment)
} else {
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
.filter(enrollment => enrollment.studentNumber !== studentNumber)
@@ -223,35 +148,6 @@ export class CourseYearlyStatsCounter {
}
}
private markCreditToAttempts(studentNumber: string, passed: boolean, grade: string, groupCode: number) {
const { attempts } = this.groups[groupCode]
const { grades, categories } = attempts
if (!grades[grade]) {
grades[grade] = []
}
grades[grade].push(studentNumber)
if (passed) {
categories.passed.push(studentNumber)
} else {
categories.failed.push(studentNumber)
}
}
private markBestEffortGrade(studentNumber: string, passed: boolean, grade: string, groupCode: number) {
const current = this.groups[groupCode].students.grades[studentNumber]
if (!current) {
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
return
}
if (current.passed && !passed) {
return
}
if (current.passed && Number(current.grade) >= Number(grade)) {
return
}
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
}
public markCreditToStudentCategories(studentNumber: string, attainmentDate: Date, groupCode: number) {
if (!this.students.has(studentNumber)) {
this.addOrUpdateStudent(studentNumber, attainmentDate, groupCode)
@@ -269,14 +165,90 @@ export class CourseYearlyStatsCounter {
private updateExistingStudent(studentNumber: string, attainmentDate: Date, groupCode: number) {
const student = this.students.get(studentNumber)
if (attainmentDate < student!.earliestAttainment) {
this.addOrUpdateStudent(studentNumber, attainmentDate, groupCode)
if (attainmentDate < student!.earliestAttainment) this.addOrUpdateStudent(studentNumber, attainmentDate, groupCode)
}
private async insertEmptyRows() {
const groups = Object.keys(this.groups).map(Number)
if (groups.length === 0) return
const first = Math.min(...groups)
const last = Math.max(...groups)
const a = first
const b = last + 1
const range = [...Array(b - a).keys()].map(v => v + a)
const diff = range.filter(v => !groups.includes(v))
const semesters = await getSemesterNamesByCode(diff)
const getNextYear = (name: string) => {
const [start, end] = name.split('-')
const startYear = parseInt(start, 10) + 1
const endYear = parseInt(end, 10) + 1
return `${startYear}-${endYear}`
}
diff.forEach(i => {
const previous = this.groups[i - 1]
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semesters[i]
this.initGroup(i, name, previous.coursecode, i)
})
}
private async parseGroupStatistics(anonymizationSalt: string | null) {
await this.insertEmptyRows()
for (const [studentNumber, data] of this.students) {
this.groups[data.code].students.studentNumbers.push(studentNumber)
}
const groupStatistics = Object.values(this.groups).map(group => {
if (anonymizationSalt && group.students.studentNumbers.length < 6) {
return {
obfuscated: true,
code: group.code,
name: group.name,
coursecode: group.coursecode,
attempts: {
categories: {
failed: [] as string[],
passed: [] as string[],
},
grades: Object.keys(group.attempts.grades).reduce<Grades>((grades, grade) => {
grades[grade] = []
return grades
}, {}),
},
yearCode: group.yearCode,
students: {
studentNumbers: [] as string[],
},
}
}
return {
...group,
students: {
...group.students,
grades: Object.entries(group.students.grades)
.map(([studentNumber, { grade, passed }]) => [studentNumber, passed ? grade : '0'])
.reduce<Grades>((grades, [studentNumber, grade]) => {
grades[grade] ??= []
grades[grade].push(studentNumber)
return grades
}, {}),
},
}
})
return groupStatistics
}
private parseProgrammeStatistics(anonymizationSalt: string | null) {
if (anonymizationSalt) {
this.programmes = {
if (anonymizationSalt)
return {
'000000': {
name: { en: '', fi: '', sv: '' },
credits: {},
@@ -284,115 +256,34 @@ export class CourseYearlyStatsCounter {
students: {},
},
}
}
return this.programmes
}
private insertEmptyRows = async () => {
const first = min(Object.keys(this.groups).map(Number))
const last = max(Object.keys(this.groups).map(Number))
if (!(first && last)) {
return
}
const getNextYear = (name: string) => {
const years = name.split('-')
const startYear = parseInt(years[0], 10) + 1
const endYear = parseInt(years[1], 10) + 1
return `${startYear}-${endYear}`
}
const groups = Object.keys(this.groups)
const a = first
const b = last + 1
const range = [...Array(b - a).keys()].map(v => v + a)
const diff = range.filter(v => !groups.includes(v.toString()))
const semesters = await getSemesterNames(diff)
diff.forEach(i => {
const previous = this.groups[i - 1]
const semester = semesters[i]
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester
this.initGroup(i, name, previous.coursecode, i)
})
}
private async parseGroupStatistics(anonymizationSalt: string | null) {
for (const [studentNumber, data] of this.students) {
this.groups[data.code].students.studentNumbers.push(studentNumber)
}
await this.insertEmptyRows()
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
const { students } = rest
const grades = {} as Grades
Object.keys(students.grades).forEach(student => {
const { grade, passed } = students.grades[student]
const parsedGrade = passed ? grade : '0'
if (!grades[parsedGrade]) {
grades[parsedGrade] = []
}
grades[parsedGrade].push(student)
})
const normalStats = {
...rest,
students: { ...students, grades },
}
if (anonymizationSalt && normalStats.students.studentNumbers.length < 6) {
this.obfuscated = true
const gradeSpread = {} as Grades
for (const grade of Object.keys(normalStats.attempts.grades)) {
gradeSpread[grade] = []
}
const obfuscatedStats = {
obfuscated: true,
code: rest.code,
name: rest.name,
coursecode: rest.coursecode,
attempts: {
categories: {
failed: [] as string[],
passed: [] as string[],
},
grades: gradeSpread,
},
yearCode: rest.yearCode,
students: {
studentNumbers: [] as string[],
},
}
return obfuscatedStats
}
return normalStats
})
return groupStatistics
}
private parseFacultyStatistics(anonymizationSalt: string | null) {
if (anonymizationSalt) {
this.facultyStats = [
{
if (anonymizationSalt)
return {
0: {
year: 'NA',
allCredits: 0,
allPassed: [],
allStudents: [],
faculties: {},
},
]
}
}
return this.facultyStats
}
public async getFinalStatistics(anonymizationSalt: string | null) {
const statistics = await this.parseGroupStatistics(anonymizationSalt)
const obfuscated = statistics.some(row => row.obfuscated)
return {
obfuscated,
statistics,
programmes: this.parseProgrammeStatistics(anonymizationSalt),
statistics: await this.parseGroupStatistics(anonymizationSalt),
facultyStats: this.parseFacultyStatistics(anonymizationSalt),
obfuscated: this.obfuscated,
}
}
}
@@ -3,7 +3,6 @@ import { Op } from 'sequelize'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import {
CreditModel,
StudentModel,
SemesterModel,
OrganizationModel,
EnrollmentModel,
@@ -12,13 +11,18 @@ import {
} from '../../models'
import { getIsOpen } from './helpers'
export const getCreditsForCourses = async (codes: string[], unification: Unification) => {
return await CreditModel.findAll({
export const getCreditsForCourses = async (codes: string[], unification: Unification) =>
CreditModel.findAll({
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
include: [
{
model: StudentModel,
attributes: ['studentnumber'],
},
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
@@ -37,7 +41,6 @@ export const getCreditsForCourses = async (codes: string[], unification: Unifica
},
order: [['attainment_date', 'ASC']],
})
}
export const getStudentNumberToSrElementsMap = async (studentNumbers: string[]) => {
const studyRights = await SISStudyRightModel.findAll({
@@ -53,39 +56,37 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
},
})
const studyRightMap = studyRights.reduce((obj, cur) => {
obj[cur.id] = cur.toJSON()
return obj
}, {})
const studyRightIds = Object.keys(studyRightMap)
const studyRightIds = studyRights.map(({ id }) => id)
const studyRightMap = new Map<
string,
{
studentNumber: string
facultyCode: string
organization: Pick<OrganizationModel, 'name' | 'code'>
}
>(studyRights.map(sr => [sr.id, sr.toJSON()]))
const studyRightElements = await SISStudyRightElementModel.findAll({
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
where: {
studyRightId: {
[Op.in]: studyRightIds,
},
studyRightId: { [Op.in]: studyRightIds },
},
raw: true,
})
const studentNumberToSrElementsMap = studyRightElements.reduce((obj, cur) => {
const studyRight = studyRightMap[cur.studyRightId]
const { studentNumber } = studyRight
return studyRightElements.reduce((obj, cur) => {
const { studentNumber, ...studyRight } = studyRightMap.get(cur.studyRightId)!
obj[studentNumber] ??= []
obj[studentNumber].push({ ...cur.toJSON(), studyRight })
obj[studentNumber].push({ ...cur, studyRight })
return obj
}, {})
return studentNumberToSrElementsMap
}
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) => {
return await EnrollmentModel.findAll({
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) =>
EnrollmentModel.findAll({
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
include: [
{
model: StudentModel,
attributes: ['studentnumber'],
},
{
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
@@ -105,4 +106,3 @@ export const getEnrollmentsForCourses = async (codes: string[], unification: Uni
is_open: getIsOpen(unification),
},
})
}
@@ -6,8 +6,8 @@ export type FormattedProgramme = {
code: string
name: Name
startDate?: Date
facultyCode: string | null
organization: OrganizationDetails | null
facultyCode: string
organization: OrganizationDetails
}
export const getIsOpen = (unification: Unification) => {
+106 -147
View File
@@ -4,7 +4,7 @@ import { Op, fn as dbFn, col as dbCol } from 'sequelize'
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
import { dateIsBetween } from '@oodikone/shared/util/datetime'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
import { isOpenUniCourseCode } from '../../util'
import { getOpenUniCourseCode } from '../../util'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
@@ -25,13 +25,8 @@ const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel):
code: studyRightElement.code,
name: studyRightElement.name,
startDate: studyRightElement.startDate,
facultyCode: studyRightElement.studyRight.facultyCode || null,
organization: studyRightElement.studyRight.organization
? {
name: studyRightElement.studyRight.organization.name,
code: studyRightElement.studyRight.organization.code,
}
: null,
facultyCode: studyRightElement.studyRight.facultyCode,
organization: studyRightElement.studyRight.organization,
})
const anonymizeStudentNumber = (studentNumber: string, anonymizationSalt: string) => {
@@ -48,16 +43,15 @@ type FormattedCredit = {
grade: string
passed: boolean
studentNumber: string
programmes: FormattedProgramme[]
programme: FormattedProgramme
credits: number
obfuscated?: boolean
}
const parseCredit = (
credit: CreditModel,
anonymizationSalt: string | null,
studentNumberToSrElementsMap: Record<string, Array<SISStudyRightElementModel>>
) => {
studyRightElements: Array<SISStudyRightElementModel>
): FormattedCredit => {
const {
semester,
grade,
@@ -69,18 +63,23 @@ const parseCredit = (
} = credit
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
const programmeOfCredit: SISStudyRightElementModel | undefined =
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
studyRightElements
.filter(studyRightElement =>
dateIsBetween(attainmentDate, studyRightElement.startDate, studyRightElement.endDate)
)
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
.filter(({ startDate, endDate }) => dateIsBetween(attainmentDate, startDate, endDate))
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.at(0) // The newest studyRightElement
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
const programme = programmeOfCredit
? formatStudyRightElement(programmeOfCredit)
: {
code: 'OTHER',
name: { en: 'Other', fi: 'Muu', sv: 'Andra' },
facultyCode: 'OTHER',
organization: { name: { en: 'Other', fi: 'Muu', sv: 'Andra' } },
}
const formattedCredit: FormattedCredit = {
return {
yearCode,
yearName,
semesterCode,
@@ -88,18 +87,11 @@ const parseCredit = (
attainmentDate,
courseCode,
grade,
passed: !CreditModel.failed(credit) || CreditModel.passed(credit) || CreditModel.improved(credit),
studentNumber,
programmes,
passed: !CreditModel.failed(credit),
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
programme,
credits,
}
if (anonymizationSalt) {
formattedCredit.obfuscated = true
formattedCredit.studentNumber = anonymizeStudentNumber(studentNumber, anonymizationSalt)
}
return formattedCredit
}
type FormattedEnrollment = {
@@ -110,15 +102,9 @@ type FormattedEnrollment = {
courseCode: string
enrollmentDateTime: Date
studentNumber: string
programmes: Array<Record<string, any>>
obfuscated?: boolean
}
const parseEnrollment = (
enrollment: EnrollmentModel,
anonymizationSalt: string | null,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
const {
studentnumber: studentNumber,
semester,
@@ -127,25 +113,15 @@ const parseEnrollment = (
} = enrollment
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
const formattedEnrollment: FormattedEnrollment = {
return {
yearCode,
yearName,
semesterCode,
semesterName,
courseCode,
enrollmentDateTime,
studentNumber,
programmes: studyRightElements.map(formatStudyRightElement),
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
}
if (anonymizationSalt) {
formattedEnrollment.obfuscated = true
formattedEnrollment.studentNumber = anonymizeStudentNumber(studentNumber, anonymizationSalt)
}
return formattedEnrollment
}
const getYearlyStatsOfNew = async (
@@ -156,27 +132,22 @@ const getYearlyStatsOfNew = async (
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
const courseForSubs = await CourseModel.findOne({
const course: Pick<CourseModel, 'code' | 'name' | 'substitutions'> | null = await CourseModel.findOne({
attributes: ['code', 'name', 'substitutions'],
where: { code: courseCode },
})
let codes =
combineSubstitutions && courseForSubs?.substitutions
? sortMainCode([...courseForSubs.substitutions, courseCode])
: [courseCode]
const substitutedCodes =
combineSubstitutions && course?.substitutions ? sortMainCode([...course.substitutions, courseCode]) : [courseCode]
if (unification === Unification.REGULAR) {
codes = codes.filter(course => !isOpenUniCourseCode(course))
}
const codes =
unification === Unification.REGULAR
? substitutedCodes.filter(course => !getOpenUniCourseCode(course))
: substitutedCodes
const [credits, enrollments, course] = await Promise.all([
const [credits, enrollments] = await Promise.all([
getCreditsForCourses(codes, unification),
getEnrollmentsForCourses(codes, unification),
CourseModel.findOne({
where: {
code: courseCode,
},
}),
])
const counter = new CourseYearlyStatsCounter()
@@ -191,70 +162,54 @@ const getYearlyStatsOfNew = async (
yearCode,
yearName,
attainmentDate,
programmes,
programme,
courseCode,
credits,
} = parseCredit(credit, anonymizationSalt, studentNumberToSrElementsMap)
} = parseCredit(credit, anonymizationSalt, studentNumberToSrElementsMap[credit.student_studentnumber] ?? [])
counter.markStudyProgramme(
studentNumber,
yearCode,
passed,
credits,
programme.code,
programme.name,
programme.facultyCode,
programme.organization
)
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
const unknownProgramme: FormattedProgramme[] = [
{
code: 'OTHER',
name: {
en: 'Other',
fi: 'Muu',
sv: 'Andra',
},
facultyCode: 'OTHER',
organization: {
name: {
en: 'Other',
fi: 'Muu',
sv: 'Andra',
},
},
},
]
counter.markStudyProgrammes(
studentNumber,
programmes.length === 0 ? unknownProgramme : programmes,
yearCode,
passed,
credits
)
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, courseCode, yearCode)
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
}
enrollments.forEach(enrollment => {
for (const enrollment of enrollments) {
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, enrollmentDateTime } =
parseEnrollment(enrollment, anonymizationSalt, studentNumberToSrElementsMap)
parseEnrollment(enrollment, anonymizationSalt)
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
})
}
const statistics = await counter.getFinalStatistics(anonymizationSalt)
let substitutionCourses: CourseModel[] | undefined
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
substitutionCourses = await CourseModel.findAll({
where: {
code: {
[Op.in]: codes,
},
},
attributes: ['code', 'name'],
})
}
const substitutionCourses: Pick<CourseModel, 'code' | 'name'>[] =
combineSubstitutions && course?.substitutions?.length
? await CourseModel.findAll({
where: {
code: { [Op.in]: codes },
},
attributes: ['code', 'name'],
})
: [{ code: course!.code, name: course!.name }]
return {
...statistics,
coursecode: courseCode,
alternatives: substitutionCourses ?? [{ code: courseForSubs!.code, name: courseForSubs!.name }],
alternatives: substitutionCourses,
name: course?.name,
}
}
@@ -274,7 +229,6 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
const attainmentsWithinThreshold = await CreditModel.count({
attributes: [[dbFn('COUNT', dbCol('id')), 'numAttainments']],
where: {
course_code: { [Op.in]: courseCodes },
attainment_date: { [Op.gt]: attainmentDateThreshold },
@@ -293,57 +247,62 @@ export const getCourseYearlyStats = async (
anonymizationSalt: string | null,
combineSubstitutions: boolean
) => {
const credits = await CreditModel.findAll({
attributes: ['student_studentnumber'],
where: { course_code: { [Op.in]: courseCodes } },
})
const enrollments = await EnrollmentModel.findAll({
attributes: ['studentnumber'],
where: {
course_code: {
[Op.in]: courseCodes,
const [credits, enrollments] = await Promise.all([
CreditModel.findAll({
attributes: ['student_studentnumber'],
where: { course_code: { [Op.in]: courseCodes } },
}),
EnrollmentModel.findAll({
attributes: ['studentnumber'],
where: {
course_code: {
[Op.in]: courseCodes,
},
state: EnrollmentState.ENROLLED,
},
state: EnrollmentState.ENROLLED,
},
})
}),
])
const studentNumbers = {}
const studentNumbers = new Set<string>()
credits.forEach(credit => {
studentNumbers[credit.student_studentnumber] = true
})
enrollments.forEach(enrollment => {
studentNumbers[enrollment.studentnumber] = true
studentNumbers.add(credit.student_studentnumber)
})
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap(Object.keys(studentNumbers))
enrollments.forEach(enrollment => {
studentNumbers.add(enrollment.studentnumber)
})
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap([...studentNumbers])
const statsRegular = await Promise.all(
courseCodes.map(async courseCode => {
const unifyStats = await getYearlyStatsOfNew(
courseCode,
separate,
Unification.UNIFY,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
)
const regularStats = await getYearlyStatsOfNew(
courseCode,
separate,
Unification.REGULAR,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
)
const openStats = await getYearlyStatsOfNew(
courseCode,
separate,
Unification.OPEN,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
)
const [openStats, regularStats, unifyStats] = await Promise.all([
getYearlyStatsOfNew(
courseCode,
separate,
Unification.OPEN,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
courseCode,
separate,
Unification.REGULAR,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
courseCode,
separate,
Unification.UNIFY,
anonymizationSalt,
combineSubstitutions,
studentNumberToSrElementsMap
),
])
return { unifyStats, regularStats, openStats }
})
@@ -1,11 +1,12 @@
import { groupBy, orderBy } from 'lodash'
import { groupBy, orderBy } from 'lodash-es'
import { QueryTypes } from 'sequelize'
import { Organization } from '@oodikone/shared/models'
import { ProgrammeModuleWithRelevantAttributes } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { dbConnections } from '../../database/connection'
import { OrganizationModel } from '../../models'
import { ApplicationError } from '../../util/customErrors'
import { CurriculumPeriods, getCurriculumPeriods } from '../curriculumPeriods'
const { sequelize } = dbConnections
@@ -55,9 +56,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
const programmesWithProgIds = programmesOfOrganization.map(programme => ({
...programme,
progId:
programme.code in programmeCodes && serviceProvider !== 'fd'
? programmeCodes[programme.code as keyof typeof programmeCodes]
: programme.code,
programme.code in programmeCodes ? programmeCodes[programme.code as keyof typeof programmeCodes] : programme.code,
}))
const programmesGroupedByCode = groupBy(orderBy(programmesWithProgIds, ['valid_from'], ['desc']), prog => prog.code)
const curriculumPeriods = await getCurriculumPeriods()
@@ -106,20 +105,15 @@ export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurr
},
})
if (!organization) {
throw new Error(`The organization with the code ${facultyCode} was not found.`)
throw new ApplicationError(`The organization with the code ${facultyCode} was not found.`)
}
return getDegreeProgrammesOfOrganization(organization.id, onlyCurrentProgrammes)
}
export const getFacultyCodeById = async (facultyId: string) => {
const organization = await OrganizationModel.findOne({
export const getFacultyCodeById = async (facultyId: string): Promise<Pick<Organization, 'code'> | null> =>
OrganizationModel.findOne({
attributes: ['code'],
where: {
id: facultyId,
},
})
if (!organization) {
throw new Error(`The organization with the id ${facultyId} was not found.`)
}
return organization.code
}
@@ -1,22 +1,21 @@
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
import { NameWithCode, DegreeProgrammeType } from '@oodikone/shared/types'
import { BasicStats } from '@oodikone/shared/types/studyProgramme'
import { getBasicStats, setBasicStats } from '../analyticsService'
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
import type { ProgrammesOfOrganization } from './faculty'
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
const calculateCombinedStats = async (
facultyProgrammes: ProgrammesOfOrganization,
isAcademicYear: boolean,
includeAllSpecials: boolean
) => {
const statsFromAllProgrammes: StudyTrackBasicStats[] = []
const statsFromAllProgrammes: BasicStats[] = []
const studentInfo = {
graphStats: [] as StudyTrackBasicStats['graphStats'],
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
tableStats: [] as StudyTrackBasicStats['tableStats'],
graphStats: [] as BasicStats['graphStats'],
programmeTableStats: {} as Record<string, BasicStats['tableStats']>,
tableStats: [] as BasicStats['tableStats'],
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
}
if (includeAllSpecials) {
@@ -1,7 +1,9 @@
import { cloneDeep, omit } from 'lodash'
import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -22,7 +24,7 @@ export const programmeTypes = {
} as const
const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
const newStats: Array<Awaited<ReturnType<typeof getGraduationStatsForStudyTrack>>> = []
const newStats: ProgrammeGraduationStats[] = []
const medians: Record<string, LevelGraduationStats[]> = {}
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
@@ -133,7 +135,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
continue
}
const statsFromRedis = await getStudyTrackStats(programme.code, '', 'GRADUATED_INCLUDED', 'SPECIAL_EXCLUDED')
const statsFromRedis = await getStudyTrackStats(programme.code, '', 'SPECIAL_EXCLUDED')
if (statsFromRedis) {
newStats.push(statsFromRedis)
continue
@@ -144,12 +146,11 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
studyProgramme: programme.code,
combinedProgramme: '',
settings: {
graduated: true,
specialGroups: false,
},
studyRightsOfProgramme,
})
await setStudyTrackStats(updatedStats, 'GRADUATED_INCLUDED', 'SPECIAL_EXCLUDED')
await setStudyTrackStats(updatedStats, 'SPECIAL_EXCLUDED')
newStats.push(updatedStats)
}
@@ -161,7 +162,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
const basicStats = statsForProgramme.medians.basic
const comboStats = statsForProgramme.medians.combo
const classSizesByYear = basicStats.reduce<Record<string, number>>(
(acc, year) => ({ ...acc, [year.name]: year.classSize }),
(acc, year) => ({ ...acc, [year.name]: year.classSize ?? 0 }),
{}
)
classSizes.programmes[code] = classSizesByYear
@@ -172,7 +173,8 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
classSizes[level] ??= {}
for (const statsForYear of basicStats) {
classSizes[level][statsForYear.name] ??= 0
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize ?? 0
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
@@ -193,7 +195,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
classSizes.bcMsCombo ??= {}
for (const statsForYear of comboStats) {
classSizes.bcMsCombo[statsForYear.name] ??= 0
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize ?? 0
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
@@ -252,22 +254,23 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
} = await getStatsByStartYear(programmesOfFaculty)
const goals = {
bachelor: 36,
bcMsCombo: faculty === 'H90' ? 72 : 60,
master: 24,
doctor: 48,
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
MH30_004: 6, // months more
'420420-ma': 6,
'420074-ma': 6,
'420119-ma': 6,
MH30_001: 48,
'320011-ma': 48,
'320001-ma': 48,
MH30_003: 42,
'320002-ma': 42,
'320009-ma': 42,
},
// Exceptions are additions to the base amount
MH30_004: GraduationTarget.HALF_YEAR,
'420420-ma': GraduationTarget.HALF_YEAR,
'420074-ma': GraduationTarget.HALF_YEAR,
'420119-ma': GraduationTarget.HALF_YEAR,
MH30_001: GraduationTarget.FOUR_YEARS,
'320011-ma': GraduationTarget.FOUR_YEARS,
'320001-ma': GraduationTarget.FOUR_YEARS,
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
} as Record<string, number>,
}
const programmeNames = programmesOfFaculty.reduce<Record<string, Name>>((acc, { code, name }) => {
@@ -1,4 +1,5 @@
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType, DegreeProgrammeType } from '@oodikone/shared/types'
import { Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
import { DegreeProgramme } from '../../routes/faculties'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -21,11 +22,11 @@ const createRedisKeyForThesiswriters = (
const createRedisKeyForGraduationTimeStats = (id: string, programmeFilter: ProgrammeFilter) => {
return `FACULTY_GRADUATION_TIME_STATS_${id}_${programmeFilter}`
}
const createRedisKeyForFacultyProgress = (id: string, specialGroups: SpecialGroups, graduated: Graduated) => {
return `FACULTY_PROGRESS_STATS_${id}_${specialGroups}_${graduated}`
const createRedisKeyForFacultyProgress = (id: string, specialGroups: SpecialGroups) => {
return `FACULTY_PROGRESS_STATS_${id}_${specialGroups}`
}
const createRedisKeyForFacultyStudents = (id: string, specialGroups: SpecialGroups, graduated: Graduated) => {
return `FACULTY_STUDENTS_STATS_${id}_${specialGroups}_${graduated}`
const createRedisKeyForFacultyStudents = (id: string, specialGroups: SpecialGroups) => {
return `FACULTY_STUDENTS_STATS_${id}_${specialGroups}`
}
/*
@@ -62,7 +63,7 @@ type StudentInfo = {
titles: string[]
}
type BasicData = {
export type BasicData = {
graduationInfo: GraduationInfo
id: string
programmeNames: ProgrammeNames
@@ -78,16 +79,9 @@ export const setBasicStats = async (
) => {
const { id } = data
const redisKey = createRedisKeyForBasicStats(id, yearType, programmeFilter, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(data))
return setOperationStatus === 'OK'
}
export const getBasicStats = async (
@@ -98,15 +92,13 @@ export const getBasicStats = async (
) => {
const redisKey = createRedisKeyForBasicStats(id, yearType, programmeFilter, specialGroups)
const dataFromRedis = await redisClient.get(redisKey)
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis) as BasicData & { status: string; lastUpdated: string }
return dataFromRedis ? (JSON.parse(dataFromRedis) as BasicData) : null
}
type ProgrammeNames = Record<string, Name & { code: string }>
type ThesisWriterData = {
export type ThesisWriterData = {
id: string
years: (string | number)[]
tableStats: (string | number)[][]
@@ -117,8 +109,6 @@ type ThesisWriterData = {
programmeTableStats: Record<string, (string | number)[][]>
titles: string[]
programmeNames: ProgrammeNames
status: string
lastUpdated: string
}
export const setThesisWritersStats = async (
@@ -129,14 +119,7 @@ export const setThesisWritersStats = async (
) => {
const { id } = data
const redisKey = createRedisKeyForThesiswriters(id, yearType, programmeFilter, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') return null
return dataToRedis
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getThesisWritersStats = async (
@@ -150,7 +133,7 @@ export const getThesisWritersStats = async (
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis) as ThesisWriterData & { status: string; lastUpdated: string }
return JSON.parse(dataFromRedis) as ThesisWriterData
}
type MediansAndProgrammes = {
@@ -160,7 +143,7 @@ type MediansAndProgrammes = {
}
}
type GraduationData = {
export type GraduationData = {
id: string
goals: {
bachelor: number
@@ -178,16 +161,7 @@ type GraduationData = {
export const setGraduationStats = async (data: GraduationData, programmeFilter: ProgrammeFilter) => {
const { id } = data
const redisKey = createRedisKeyForGraduationTimeStats(id, programmeFilter)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getGraduationStats = async (id: string, programmeFilter: ProgrammeFilter, keepGraduationTimes = false) => {
@@ -196,39 +170,24 @@ export const getGraduationStats = async (id: string, programmeFilter: ProgrammeF
if (!dataFromRedis) {
return null
}
const data = JSON.parse(dataFromRedis) as GraduationData & { status: string; lastUpdated: string }
const data = JSON.parse(dataFromRedis) as GraduationData
if (!keepGraduationTimes) {
removeGraduationTimes(data)
}
return data
}
export const setFacultyProgressStats = async (
data: FacultyProgressData,
specialGroups: SpecialGroups,
graduated: Graduated
) => {
export const setFacultyProgressStats = async (data: FacultyProgressData, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForFacultyProgress(id, specialGroups, graduated)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const redisKey = createRedisKeyForFacultyProgress(id, specialGroups)
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getFacultyProgressStats = async (id: string, specialGroups: SpecialGroups, graduated: Graduated) => {
const redisKey = createRedisKeyForFacultyProgress(id, specialGroups, graduated)
export const getFacultyProgressStats = async (id: string, specialGroups: SpecialGroups) => {
const redisKey = createRedisKeyForFacultyProgress(id, specialGroups)
const dataFromRedis = await redisClient.get(redisKey)
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis) as FacultyProgressData & { status: string; lastUpdated: string }
return dataFromRedis ? (JSON.parse(dataFromRedis) as FacultyProgressData) : null
}
type FacultyStudentsData = {
@@ -238,36 +197,18 @@ type FacultyStudentsData = {
facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>>
programmeStats: Record<string, Record<string, (string | number)[]>>
titles: string[]
programmeNames: Record<
string,
{ name: Name; code: string; degreeProgrammeType: DegreeProgrammeType | null; progId: string }
>
programmeNames: Record<string, DegreeProgramme>
}
export const setFacultyStudentStats = async (
data: FacultyStudentsData,
specialGroups: SpecialGroups,
graduated: Graduated
) => {
export const setFacultyStudentStats = async (data: FacultyStudentsData, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForFacultyStudents(id, specialGroups, graduated)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: new Date().toISOString(),
}
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
if (setOperationStatus !== 'OK') {
return null
}
return dataToRedis
const redisKey = createRedisKeyForFacultyStudents(id, specialGroups)
await redisClient.set(redisKey, JSON.stringify(data))
}
export const getFacultyStudentStats = async (id: string, specialGroups: SpecialGroups, graduated: Graduated) => {
const redisKey = createRedisKeyForFacultyStudents(id, specialGroups, graduated)
export const getFacultyStudentStats = async (id: string, specialGroups: SpecialGroups) => {
const redisKey = createRedisKeyForFacultyStudents(id, specialGroups)
const dataFromRedis = await redisClient.get(redisKey)
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis) as FacultyStudentsData & { status: string; lastUpdated: string }
return dataFromRedis ? (JSON.parse(dataFromRedis) as FacultyStudentsData) : null
}
@@ -1,7 +1,7 @@
import dayjs from 'dayjs'
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
import { Graduated, SpecialGroups, Unarray } from '@oodikone/shared/types'
import { SpecialGroups } from '@oodikone/shared/types'
import { rootOrgId } from '../../config'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
@@ -10,35 +10,38 @@ import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrac
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from './faculty'
import { programmeTypes } from './facultyGraduationTimes'
type CreditLimits = Array<[number | null, number | null]>
const createLimits = (
months: number,
creditsToAdd: number,
level: 'bachelor' | 'master' | 'doctor'
): Array<[number, number | null]> => {
startingCredits: number,
level: 'bachelor' | 'master' | 'bcMs' | 'doctor'
): CreditLimits => {
if (level === 'doctor') {
return [
[40, null],
[30, 40],
[20, 30],
[10, 20],
[0, null],
[null, 0],
]
}
return [
[Math.ceil(months * (60 / 12)) + creditsToAdd, null],
[Math.ceil(months * (45 / 12)) + creditsToAdd, Math.ceil(months * (60 / 12)) + creditsToAdd],
[Math.ceil(months * (30 / 12)) + creditsToAdd, Math.ceil(months * (45 / 12)) + creditsToAdd],
[Math.ceil(months * (15 / 12)) + creditsToAdd, Math.ceil(months * (30 / 12)) + creditsToAdd],
[creditsToAdd + 1, Math.ceil(months * (15 / 12)) + creditsToAdd],
[creditsToAdd, null],
[startingCredits + Math.ceil(months * (60 / 12)), null],
[startingCredits + Math.ceil(months * (45 / 12)), startingCredits + Math.ceil(months * (60 / 12))],
[startingCredits + Math.ceil(months * (30 / 12)), startingCredits + Math.ceil(months * (45 / 12))],
[startingCredits + Math.ceil(months * (15 / 12)), startingCredits + Math.ceil(months * (30 / 12))],
[startingCredits + 1, startingCredits + Math.ceil(months * (15 / 12))],
[null, startingCredits],
]
}
const createYearlyTitles = (limitList: ReturnType<typeof createLimits>) => {
const createYearlyTitles = (limitList: CreditLimits) => {
const titles: string[] = []
for (let i = limitList.length - 1; i >= 0; i--) {
if (limitList[i][0] === 0) {
titles.push('0 Credits')
if (limitList[i][0] === null) {
titles.push(`${limitList[i][1]} Credits`)
} else if (limitList[i][1] === null) {
titles.push(`${limitList[0][0]} ≤ Credits`)
} else {
@@ -53,18 +56,20 @@ const calculateProgressStats = (
creditCounts: Record<string, number[]>,
yearlyTitles: Record<string, string[][]>,
progressStats: Record<string, Record<string, number[][]>>,
programmeInfo: Unarray<ProgrammesOfOrganization>
progId: string
) => {
const goalMonthsForLevels = { bachelor: 36, master: 24, bcMs: 60, doctor: 48 } as const
// "bcMs" is the same as masters, as the students have graduated from bachelors.
// We need to add the 180cr to the starting point for compensation.
const goalMonthsForLevels = { bachelor: 36, master: 24, bcMs: 24, doctor: 48 } as const
const allYears = Object.keys(creditCounts)
const limitsForYears = allYears.reduce<Record<string, ReturnType<typeof createLimits>>>((acc, year) => {
const limitsForYears = allYears.reduce<Record<string, CreditLimits>>((acc, year) => {
const startDate = new Date(`${year.slice(0, 4)}-08-01`)
const lastDayOfMonth = dayjs().endOf('month')
const months = Math.round(dayjs(lastDayOfMonth).diff(startDate, 'months', true))
const goalMonths = goalMonthsForLevels[level]
acc[year] = createLimits(Math.min(months, goalMonths), 0, level === 'bcMs' ? 'master' : level)
acc[year] = createLimits(Math.min(months, goalMonths), Number(level === 'bcMs') * 180, level)
return acc
}, {})
@@ -79,29 +84,29 @@ const calculateProgressStats = (
progressStats[level] = {}
}
progressStats[level][programmeInfo.progId] = []
progressStats[level][progId] = []
for (const year of Object.keys(creditCounts)) {
const limit = limitsForYears[year]
const statsForYear: number[] = new Array(limit.length).fill(0)
for (const credits of creditCounts[year]) {
const correctIndex = limit.length - 1 - limit.findIndex(element => credits >= element[0])
const correctIndex = limit.length - 1 - limit.findIndex(element => credits >= (element[0] ?? 0))
statsForYear[correctIndex] += 1
}
progressStats[level][programmeInfo.progId].unshift(statsForYear)
progressStats[level][progId].unshift(statsForYear)
}
}
export const combineFacultyStudentProgress = async (
faculty: string,
programmes: ProgrammesOfOrganization,
specialGroups: SpecialGroups,
graduated: Graduated
specialGroups: SpecialGroups
) => {
const since = new Date('2017-08-01')
const statsOfProgrammes: Array<Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>> = []
const allDegreeProgrammes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const creditCounts: Record<string, Record<string, number[]>> = {}
const graduatedCount: Record<string, Record<string, number>> = {}
const progressStats: Record<string, Record<string, number[][]>> = {}
const yearlyTitles: Record<string, string[][]> = {}
@@ -114,7 +119,7 @@ export const combineFacultyStudentProgress = async (
) {
continue
}
const statsFromRedis = await getStudyTrackStats(studyProgramme, null, graduated, specialGroups)
const statsFromRedis = await getStudyTrackStats(studyProgramme, null, specialGroups)
if (statsFromRedis) {
statsOfProgrammes.push(statsFromRedis)
} else {
@@ -122,22 +127,23 @@ export const combineFacultyStudentProgress = async (
const updatedStats = await getStudyTrackStatsForStudyProgramme({
studyProgramme,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
studyRightsOfProgramme,
})
statsOfProgrammes.push(updatedStats)
await setStudyTrackStats(updatedStats, graduated, specialGroups)
await setStudyTrackStats(updatedStats, specialGroups)
}
}
const updateCreditCounts = (
const updateCreditAndGraduatedCount = (
level: 'bachelor' | 'bcMs' | 'master' | 'doctor',
creditCountsOfProgramme: Record<string, number[]>
creditCountsOfProgramme: Record<string, number[]>,
graduatedCountOfProgramme: Record<string, number>
) => {
if (!(level in creditCounts)) {
creditCounts[level] = cloneDeep(creditCountsOfProgramme)
graduatedCount[level] = cloneDeep(graduatedCountOfProgramme)
} else {
for (const year of Object.keys(creditCountsOfProgramme)) {
if (!(year in creditCounts[level])) {
@@ -146,27 +152,28 @@ export const combineFacultyStudentProgress = async (
creditCounts[level][year].push(...creditCountsOfProgramme[year])
}
}
for (const year of Object.keys(graduatedCountOfProgramme)) {
graduatedCount[level][year] += cloneDeep(graduatedCountOfProgramme[year])
}
}
}
for (const stats of statsOfProgrammes) {
const programmeInfo = allDegreeProgrammes.find(programme => programme.code === stats.id)
if (
!programmeInfo ||
programmeInfo.degreeProgrammeType == null ||
!(programmeInfo.degreeProgrammeType in programmeTypes)
) {
if (!programmeInfo?.degreeProgrammeType || !(programmeInfo.degreeProgrammeType in programmeTypes)) {
continue
}
const level = programmeTypes[programmeInfo.degreeProgrammeType as keyof typeof programmeTypes]
const { degreeProgrammeType, progId } = programmeInfo
const level = programmeTypes[degreeProgrammeType]
updateCreditCounts(level, stats.creditCounts)
calculateProgressStats(level, stats.creditCounts, yearlyTitles, progressStats, programmeInfo)
updateCreditAndGraduatedCount(level, stats.creditCounts, stats.graduatedCount)
calculateProgressStats(level, stats.creditCounts, yearlyTitles, progressStats, progId)
if (level === 'master' && Object.keys(stats.creditCountsCombo).length > 0) {
updateCreditCounts('bcMs', stats.creditCountsCombo)
calculateProgressStats('bcMs', stats.creditCountsCombo, yearlyTitles, progressStats, programmeInfo)
updateCreditAndGraduatedCount('bcMs', stats.creditCountsCombo, stats.graduatedCount)
calculateProgressStats('bcMs', stats.creditCountsCombo, yearlyTitles, progressStats, progId)
}
}
@@ -178,6 +185,7 @@ export const combineFacultyStudentProgress = async (
bcMsProgStats: progressStats.bcMs,
doctoralProgStats: progressStats.doctor,
creditCounts: {} as Record<string, Record<string, number[]>>,
graduatedCount: {} as Record<string, Record<string, number>>,
yearlyBachelorTitles: yearlyTitles.bachelor,
yearlyBcMsTitles: yearlyTitles.bcMs,
yearlyMasterTitles: yearlyTitles.master,
@@ -200,6 +208,14 @@ export const combineFacultyStudentProgress = async (
}
result.creditCounts[levelName][year] = creditCounts[level][year]
}
for (const year of Object.keys(graduatedCount[level]).toSorted(sortByYear)) {
const levelName = level === 'bcMs' ? 'bachelorMaster' : level
if (!(levelName in result.graduatedCount)) {
result.graduatedCount[levelName] = {}
}
result.graduatedCount[levelName][year] = graduatedCount[level][year]
}
}
return result
@@ -61,15 +61,14 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
export const combineFacultyStudents = async (
code: string,
programmes: ProgrammesOfOrganization,
specialGroups: 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED',
graduated: 'GRADUATED_INCLUDED' | 'GRADUATED_EXCLUDED'
specialGroups: 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED'
) => {
let years: string[] = []
const programmeCodes = programmes.map(programme => programme.code)
const newStats: StudyTrackStats[] = []
for (const studyProgrammeCode of programmeCodes) {
const statsFromRedis = await getStudyTrackStats(studyProgrammeCode, null, graduated, specialGroups)
const statsFromRedis = await getStudyTrackStats(studyProgrammeCode, null, specialGroups)
if (statsFromRedis) {
newStats.push(statsFromRedis)
if (!years.length) {
@@ -81,12 +80,11 @@ export const combineFacultyStudents = async (
const updatedStats = await getStudyTrackStatsForStudyProgramme({
studyProgramme: studyProgrammeCode,
settings: {
graduated: graduated === 'GRADUATED_INCLUDED',
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
},
studyRightsOfProgramme,
})
await setStudyTrackStats(updatedStats, graduated, specialGroups)
await setStudyTrackStats(updatedStats, specialGroups)
if (!years.length) {
years = updatedStats.years
}
@@ -1,17 +1,10 @@
import { cloneDeep } from 'lodash'
import { cloneDeep } from 'lodash-es'
import { DegreeProgrammeType } from '@oodikone/shared/types'
import { DegreeProgrammeType, type NameWithCode } from '@oodikone/shared/types'
import { getGraduationStats, setGraduationStats } from '../analyticsService'
import { getGraduationStatsForStudyTrack } from '../studyProgramme/studyProgrammeGraduations'
import { getDegreeProgrammesOfFaculty, ProgrammesOfOrganization } from './faculty'
type ProgrammeName = {
code: string
en?: string
fi?: string
sv?: string
}
const calculateCombinedStats = async (
isAcademicYear: boolean,
includeAllSpecials: boolean,
@@ -28,7 +21,7 @@ const calculateCombinedStats = async (
const newStats: Array<Awaited<ReturnType<typeof getGraduationStatsForStudyTrack>>> = []
const combinedTableStats: Array<Array<string | number>> = []
let years: Array<string | number> = []
const programmeNames: Record<string, ProgrammeName> = {}
const programmeNames: Record<string, NameWithCode> = {}
for (const programme of facultyProgrammes) {
if (
@@ -90,6 +83,7 @@ const calculateCombinedStats = async (
isBachelor ? wroteThesis : 0,
isMaster ? wroteThesis : 0,
])
programmeTableStats[progId] = tableStatsForProgramme
if (!combinedTableStats.length) {
@@ -135,7 +129,5 @@ export const combineFacultyThesisWriters = async (
programmeTableStats,
titles: ['Code', 'All', 'Bachelors', 'Masters'],
programmeNames,
status: 'Done',
lastUpdated: '',
}
}
@@ -18,22 +18,15 @@ import { combineFacultyThesisWriters } from './facultyThesisWriters'
const yearOptions = ['CALENDAR_YEAR', 'ACADEMIC_YEAR'] as const
const specialGroupOptions = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED'] as const
const programmeFilterOptions = ['ALL_PROGRAMMES', 'NEW_DEGREE_PROGRAMMES'] as const
const graduatedOptions = ['GRADUATED_INCLUDED', 'GRADUATED_EXCLUDED'] as const
export const updateFacultyOverview = async (facultyCode: string, statsType: StatsType) => {
const all = await getDegreeProgrammesOfFaculty(facultyCode, false)
const onlyNew = await getDegreeProgrammesOfFaculty(facultyCode, true)
for (const programmeFilter of programmeFilterOptions) {
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
for (const yearType of yearOptions) {
for (const specialGroups of specialGroupOptions) {
for (const programmeFilter of programmeFilterOptions) {
for (const yearType of yearOptions) {
for (const specialGroups of specialGroupOptions) {
if (statsType === 'ALL' || statsType === 'STUDENT') {
const updatedStudentInfo = await combineFacultyBasics(
facultyCode,
programmeFilter === 'NEW_DEGREE_PROGRAMMES' ? onlyNew : all,
yearType,
specialGroups
)
const updatedStudentInfo = await combineFacultyBasics(facultyCode, programmes, yearType, specialGroups)
await setBasicStats(updatedStudentInfo, yearType, programmeFilter, specialGroups)
}
if ((statsType === 'ALL' || statsType === 'CREDITS') && specialGroups !== 'SPECIAL_EXCLUDED') {
@@ -43,7 +36,7 @@ export const updateFacultyOverview = async (facultyCode: string, statsType: Stat
if (statsType === 'ALL' || statsType === 'THESIS') {
const updateThesisWriters = await combineFacultyThesisWriters(
facultyCode,
programmeFilter === 'NEW_DEGREE_PROGRAMMES' ? onlyNew : all,
programmes,
yearType,
specialGroups
)
@@ -51,30 +44,22 @@ export const updateFacultyOverview = async (facultyCode: string, statsType: Stat
}
}
}
const updatedTimes = await countGraduationTimes(facultyCode, programmes)
await setGraduationStats(updatedTimes, programmeFilter)
}
const updatedTimesAll = await countGraduationTimes(facultyCode, all)
await setGraduationStats(updatedTimesAll, 'ALL_PROGRAMMES')
const updatedTimesNew = await countGraduationTimes(facultyCode, onlyNew)
await setGraduationStats(updatedTimesNew, 'NEW_DEGREE_PROGRAMMES')
return 'OK'
}
export const updateFacultyProgressOverview = async (facultyCode: string) => {
const onlyNew = await getDegreeProgrammesOfFaculty(facultyCode, true)
for (const graduated of graduatedOptions) {
for (const specialGroups of specialGroupOptions) {
const updateFacultyStudentStats = await combineFacultyStudents(facultyCode, onlyNew, specialGroups, graduated)
await setFacultyStudentStats(updateFacultyStudentStats, specialGroups, graduated)
const updateFacultyProgressStats = await combineFacultyStudentProgress(
facultyCode,
onlyNew,
specialGroups,
graduated
)
await setFacultyProgressStats(updateFacultyProgressStats, specialGroups, graduated)
}
for (const specialGroups of specialGroupOptions) {
const updateFacultyStudentStats = await combineFacultyStudents(facultyCode, onlyNew, specialGroups)
await setFacultyStudentStats(updateFacultyStudentStats, specialGroups)
const updateFacultyProgressStats = await combineFacultyStudentProgress(facultyCode, onlyNew, specialGroups)
await setFacultyProgressStats(updateFacultyProgressStats, specialGroups)
}
return 'OK'
@@ -1,13 +1,27 @@
export const countTimeCategories = (times: number[], goal: number) => {
/** Map years to semesters */
export const GraduationTarget = {
HALF_YEAR: 1,
ONE_YEAR: 2,
TWO_YEARS: 4,
THREE_YEARS: 6,
THREE_POINT_FIVE_YEARS: 7,
FOUR_YEARS: 8,
FIVE_YEARS: 10,
SIX_YEARS: 12,
} as const
/** Uses semesters, not months */
export const countTimeCategories = (times: number[], target: number) => {
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
times.forEach(time => {
if (time <= goal) {
statistics.onTime += 1
} else if (time <= goal + 12) {
statistics.yearOver += 1
for (const time of times) {
if (time <= target) {
statistics.onTime++
} else if (time <= target + GraduationTarget.ONE_YEAR) {
statistics.yearOver++
} else {
statistics.wayOver += 1
statistics.wayOver++
}
})
}
return statistics
}
@@ -1,10 +1,10 @@
const { orderBy } = require('lodash')
const { Op } = require('sequelize')
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
const { CourseModel, CreditModel, EnrollmentModel, SISStudyRightModel } = require('../models')
const { redisClient } = require('./redis')
import { CourseModel, CreditModel, EnrollmentModel, SISStudyRightModel } from '../models'
import { redisClient } from './redis'
const LANGUAGE_CENTER_REDIS_KEY = 'LANGUAGE_CENTER_DATA'
export const LANGUAGE_CENTER_REDIS_KEY = 'LANGUAGE_CENTER_DATA'
const isBetween = (start, date, end) => {
return new Date(start).getTime() <= new Date(date).getTime() && new Date(date).getTime() <= new Date(end).getTime()
@@ -33,7 +33,7 @@ const getLanguageCenterCourses = async () => {
return courses
}
const createArrayOfCourses = async (attempts, courses) => {
export const createArrayOfCourses = async (attempts, courses) => {
const fields = { completions: 0, enrollments: 0, difference: 0, rejected: 0 }
const semesters = {}
const faculties = {}
@@ -88,7 +88,7 @@ const createArrayOfCourses = async (attempts, courses) => {
return courseList
}
const computeLanguageCenterData = async () => {
export const computeLanguageCenterData = async () => {
const courses = await getLanguageCenterCourses()
const autumnSemester2017 = 135
@@ -218,17 +218,10 @@ const computeLanguageCenterData = async () => {
return { tableData, faculties }
}
const getLanguageCenterData = async () => {
export const getLanguageCenterData = async () => {
const dataOnRedis = await redisClient.get(LANGUAGE_CENTER_REDIS_KEY)
if (dataOnRedis) return JSON.parse(dataOnRedis)
const freshData = await computeLanguageCenterData()
await redisClient.set(LANGUAGE_CENTER_REDIS_KEY, JSON.stringify(freshData))
return freshData
}
module.exports = {
computeLanguageCenterData,
createArrayOfCourses,
getLanguageCenterData,
LANGUAGE_CENTER_REDIS_KEY,
}
+4 -10
View File
@@ -2,7 +2,7 @@ import axios from 'axios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
@@ -22,18 +22,12 @@ const baseSettings = {
}
const sendEmail = async (options = {}) => {
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
}
export const sendFeedbackToToska = async ({
feedbackContent,
user,
}: {
feedbackContent: string
user: FormattedUser
}) => {
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
const { name, userId, email } = user
const userDetails = `Sent by ${name}, userid: ${userId}, email: ${email}`
const text = [feedbackContent, userDetails].join('<br />')
@@ -286,9 +286,14 @@ export const getCloseToGraduationData = async (studentNumbers?: string[]) => {
if (dataOnRedis) {
return JSON.parse(dataOnRedis)
}
const freshData = { ...(await findStudentsCloseToGraduation()), lastUpdated: new Date().toISOString() }
const students = await findStudentsCloseToGraduation()
const freshData = { ...students, lastUpdated: new Date().toISOString() }
await redisClient.set(CLOSE_TO_GRADUATION_REDIS_KEY, JSON.stringify(freshData))
return freshData
}
return findStudentsCloseToGraduation(studentNumbers)
}
@@ -1,21 +1,10 @@
import { SISStudyRight } from '@oodikone/shared/models'
import { FormattedStudent, CriteriaYear, Name, ProgressCriteria } from '@oodikone/shared/types'
import { FormattedStudent, Name } from '@oodikone/shared/types'
import { StudentData, StudentTags } from '@oodikone/shared/types/studentData'
import { dateYearsFromNow, dateDaysFromNow, dayInMilliseconds } from '@oodikone/shared/util/datetime'
import { CreditModel } from '../../models'
import { dateDaysFromNow, dayInMilliseconds } from '@oodikone/shared/util/datetime'
import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers'
import type { StudentStudyPlan, StudentStudyRight } from './getStudentData'
import type { StudentStudyRight } from './getStudentData'
import { getCurriculumVersion } from './shared'
import type { AnonymousCredit, AnonymousEnrollment } from './statisticsOf'
const yearMap: [string, keyof ProgressCriteria['courses']][] = [
['year1', 'yearOne'],
['year2', 'yearTwo'],
['year3', 'yearThree'],
['year4', 'yearFour'],
['year5', 'yearFive'],
['year6', 'yearSix'],
]
const getTransferSource = (code: string, studyRights: StudentStudyRight[]): [boolean, string | undefined] => {
if (code) {
@@ -48,136 +37,18 @@ const getTransferSource = (code: string, studyRights: StudentStudyRight[]): [boo
return [false, undefined]
}
const getCriteriaBase = (criteria: ProgressCriteria): [boolean, Record<string, CriteriaYear>] => {
const thereAreCriteriaCourses = !!Object.values(criteria.courses).flatMap(val => val).length
const thereAreCriteriaCredits = !!Object.values(criteria.credits).reduce((acc, cur) => acc + cur, 0)
const createEmptyCriteriaYear = (criteria: ProgressCriteria, year: keyof ProgressCriteria['courses']) => ({
credits: false,
totalSatisfied: 0,
coursesSatisfied: Object.fromEntries(criteria.courses[year].map(course => [course, null])),
})
const criteriaChecked: Record<string, CriteriaYear> = {
year1: createEmptyCriteriaYear(criteria, 'yearOne'),
year2: createEmptyCriteriaYear(criteria, 'yearTwo'),
year3: createEmptyCriteriaYear(criteria, 'yearThree'),
year4: createEmptyCriteriaYear(criteria, 'yearFour'),
year5: createEmptyCriteriaYear(criteria, 'yearFive'),
year6: createEmptyCriteriaYear(criteria, 'yearSix'),
}
return [thereAreCriteriaCourses || thereAreCriteriaCredits, criteriaChecked]
}
const getProgressCriteria = (
startDate: string,
criteria: ProgressCriteria,
credits: AnonymousCredit[],
hops: StudentStudyPlan | undefined
) => {
const startDateFromISO = new Date(startDate)
const criteriaCoursesBySubstitutions: Record<string, string> = Object.fromEntries(
Object.entries(criteria.allCourses).flatMap(([courseCode, substitutionCodes]) => [
[courseCode, courseCode],
...substitutionCodes.map(code => [code, courseCode]),
])
)
const [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
if (!thereAreCriteria) return criteriaChecked
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
const courses = credits.map(({ attainment_date, course_code, credits, credittypecode }) => ({
course_code,
credits,
credittypecode,
date:
attainment_date < startDateFromISO
? dateDaysFromNow(startDateFromISO, 1).toISOString()
: attainment_date.toISOString(),
}))
courses
.filter(({ course_code }) => !!criteriaCoursesBySubstitutions[course_code])
.filter(({ credittypecode }) => CreditModel.passed({ credittypecode }) || CreditModel.improved({ credittypecode }))
.forEach(course => {
const courseDate = new Date(course.date)
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
yearMap.forEach(([yearToAdd, criteriaYear]) => {
if (criteria.courses[criteriaYear].includes(correctCode)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
if (!currentDate || courseDate < new Date(currentDate)) {
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
}
}
})
})
courses.forEach(course => {
const courseDate = new Date(course.date)
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
if (
startDateFromISO < courseDate &&
!!hops &&
(hops.included_courses.includes(course.course_code) || hops.included_courses.includes(correctCode))
)
Object.keys(academicYears)
.filter((_, index) => courseDate < dateYearsFromNow(startDateFromISO, index + 1))
.forEach(year => (academicYears[year] += course.credits))
})
yearMap.forEach(([yearToAdd, criteriaYear]) => {
criteriaChecked[yearToAdd].totalSatisfied +=
Object.values(criteriaChecked[yearToAdd].coursesSatisfied).filter(course => !!course).length ?? 0
// UPDATE CREDIT CRITERIA
if (!!criteria.credits[criteriaYear] && criteria.credits[criteriaYear] < academicYears[yearToAdd]) {
criteriaChecked[yearToAdd].credits = true
criteriaChecked[yearToAdd].totalSatisfied += 1
}
})
return criteriaChecked
}
export const formatStudentForAPI = (
code: string,
startDate: string,
student: StudentData,
tags: StudentTags[],
credits: AnonymousCredit[],
enrollments: AnonymousEnrollment[],
optionData: Name | undefined,
criteria: ProgressCriteria
): FormattedStudent => {
optionData: Name | undefined
): Omit<FormattedStudent, 'criteriaProgress' | 'courses' | 'enrollments'> => {
const { studentnumber, studyRights, studyplans } = student
const hops = studyplans.find(plan => plan.programme_code === code)
const [transferredStudyright, transferSource] = getTransferSource(code, studyRights)
const courses = credits.map(credit => {
const attainmentDateNormalized = credit.attainment_date.toISOString()
const passed =
CreditModel.passed({ credittypecode: credit.credittypecode }) ||
CreditModel.improved({ credittypecode: credit.credittypecode })
return {
course_code: credit.course_code,
date: attainmentDateNormalized,
passed,
grade: passed ? credit.grade : 'Hyl.',
credits: credit.credits,
isStudyModuleCredit: credit.isStudyModule,
credittypecode: credit.credittypecode,
language: credit.language,
studyright_id: credit.studyright_id,
}
})
return {
firstnames: student.firstnames,
lastname: student.lastname,
@@ -197,15 +68,13 @@ export const formatStudentForAPI = (
birthdate: student.birthdate,
sis_person_id: student.sis_person_id,
citizenships: student.citizenships,
criteriaProgress: getProgressCriteria(startDate, criteria, credits, hops),
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
hasPersonalIdentityCode: student.hasPersonalIdentityCode,
tags,
transferredStudyright,
transferSource,
studyRights,
studyplans,
courses,
enrollments,
}
}
@@ -17,7 +17,7 @@ export type StudentTags = TagStudent & {
tag: Pick<Tag, 'tag_id' | 'tagname' | 'personal_user_id'>
}
export const getStudentTags = async (studyRights: string[], studentNumbers: string[], userId: string) => {
export const getStudentTagMap = async (studyRights: string[], studentNumbers: string[], userId: string) => {
const studentTags = await TagStudentModel.findAll({
attributes: ['tag_id', 'studentnumber'],
include: {
@@ -33,19 +33,24 @@ export const getStudentTags = async (studyRights: string[], studentNumbers: stri
},
})
const studentTagList: Record<string, StudentTags[]> = Object.fromEntries(studentNumbers.map(n => [n, []]))
studentTags.forEach(studentTag => studentTagList[studentTag.studentnumber].push(studentTag))
const studentTagMap = new Map<string, StudentTags[]>()
for (const studentTag of studentTags) {
const data = studentTagMap.get(studentTag.studentnumber) ?? []
return studentTagList
data.push(studentTag)
studentTagMap.set(studentTag.studentnumber, data)
}
return studentTagMap
}
export type StudentEnrollment = Pick<
Enrollment,
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber'
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber' | 'studyright_id'
>
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> =>
EnrollmentModel.findAll({
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber'],
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber', 'studyright_id'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
@@ -74,6 +79,7 @@ type StudentPersonalData = Pick<
| 'gender_code'
| 'birthdate'
| 'sis_person_id'
| 'hasPersonalIdentityCode'
>
type StudentStudyRightElement = Pick<
@@ -120,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
'gender_code',
'birthdate',
'sis_person_id',
'hasPersonalIdentityCode',
],
include: [
{
@@ -203,3 +210,22 @@ export const getCredits = async (studentNumbers: string[]): Promise<Array<Studen
},
raw: true,
})
export const getStudyRightElementsForStudyRight = async (studentNumbers: string[], code: string) =>
SISStudyRightElementModel.findAll({
attributes: [],
where: { code },
include: {
model: SISStudyRightModel,
attributes: ['studentNumber'],
where: {
studentNumber: { [Op.in]: studentNumbers },
},
include: [
{
model: SISStudyRightElementModel,
attributes: ['code', 'name', 'degreeProgrammeType', 'startDate', 'endDate'],
},
],
},
})
@@ -1,11 +1,10 @@
import { orderBy } from 'lodash'
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import type { CourseStats } from '@oodikone/shared/routes/populations'
import { Name, DegreeProgrammeType, EnrollmentState } from '@oodikone/shared/types'
import { SISStudyRightModel, SISStudyRightElementModel, CreditModel, CourseModel } from '../../models'
import { getPassingSemester, SemesterStart } from '../../util/semester'
import { StudentCredit, StudentEnrollment } from './getStudentData'
import { CourseStats } from '@oodikone/shared/routes/populations'
import { Name, DegreeProgrammeType } from '@oodikone/shared/types'
import { SISStudyRightElementModel, CourseModel } from '../../models'
import { SemesterStart } from '../../util/semester'
type QueryParams = {
semesters: string[]
@@ -47,254 +46,41 @@ export const getCurriculumVersion = (curriculumPeriodId: string | undefined) =>
return curriculumVersion
}
export const getOptionsForStudents = async (
studentNumbers: string[],
code: string,
export const getOptionsForStudents = (
studyRightElementsForStudyRight: SISStudyRightElementModel[],
degreeProgrammeType: DegreeProgrammeType | null
): Promise<Record<string, Name>> => {
if (!code || !studentNumbers.length) {
return {}
} else if (
): Map<string, Name> => {
if (
degreeProgrammeType &&
![DegreeProgrammeType.BACHELOR, DegreeProgrammeType.MASTER].includes(degreeProgrammeType)
) {
return {}
return new Map()
}
const studyRightElementsForStudyRight = await SISStudyRightElementModel.findAll({
attributes: [],
where: { code },
include: {
model: SISStudyRightModel,
attributes: ['studentNumber'],
where: {
studentNumber: { [Op.in]: studentNumbers },
},
include: [
{
model: SISStudyRightElementModel,
attributes: ['code', 'name', 'degreeProgrammeType', 'startDate', 'endDate'],
},
],
},
})
const levelIsMasters = degreeProgrammeType === DegreeProgrammeType.MASTER
const filter = levelIsMasters ? DegreeProgrammeType.BACHELOR : DegreeProgrammeType.MASTER
return Object.fromEntries(
studyRightElementsForStudyRight
.map(({ studyRight }) => {
const levelIsMasters = degreeProgrammeType === DegreeProgrammeType.MASTER
const filter = levelIsMasters ? DegreeProgrammeType.BACHELOR : DegreeProgrammeType.MASTER
const optionMap = new Map()
for (const { studyRight } of studyRightElementsForStudyRight) {
const [latestProgramme] = orderBy(
studyRight.studyRightElements.filter(element => element.degreeProgrammeType === filter),
[levelIsMasters ? 'endDate' : 'startDate'],
[levelIsMasters ? 'desc' : 'asc']
)
// NOTE: If in masters, then select latest finished bachlor studyRight otherwise select first started masters studyRight
const [latestProgramme] = orderBy(
studyRight.studyRightElements.filter(element => element.degreeProgrammeType === filter),
[levelIsMasters ? 'endDate' : 'startDate'],
[levelIsMasters ? 'desc' : 'asc']
)
if (latestProgramme?.name === undefined) continue
return [studyRight.studentNumber, latestProgramme?.name]
})
.filter(([_, name]) => !!name)
)
optionMap.set(studyRight.studentNumber, latestProgramme.name)
}
return optionMap
}
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>> =>
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitutions'],
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
where: {
code: { [Op.in]: courses },
},
raw: true,
})
const defaultCourse = {
attempts: 0,
enrollments: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
semesters: {
BEFORE: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'0-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'0-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'1-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'1-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'2-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'2-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'3-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'3-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'4-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'4-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'5-FALL': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
'5-SPRING': {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
LATER: {
[EnrollmentState.ENROLLED]: new Set<string>(),
[EnrollmentState.REJECTED]: new Set<string>(),
},
},
},
grades: {},
students: {
all: new Set<string>(),
passed: new Set<string>(),
failed: new Set<string>(),
improvedPassedGrade: new Set<string>(),
markedToSemester: new Set<string>(),
enrolledNoGrade: new Set<string>(),
},
stats: {
passingSemesters: {
BEFORE: 0,
'0-FALL': 0,
'0-SPRING': 0,
'1-FALL': 0,
'1-SPRING': 0,
'2-FALL': 0,
'2-SPRING': 0,
'3-FALL': 0,
'3-SPRING': 0,
'4-FALL': 0,
'4-SPRING': 0,
'5-FALL': 0,
'5-SPRING': 0,
LATER: 0,
},
},
}
export const parseCourseData = async (
studentStartingYears: Map<string, number>,
enrollments: StudentEnrollment[],
credits: StudentCredit[]
): Promise<CourseStats[]> => {
const getYear = (studentnumber: string) => studentStartingYears.get(studentnumber)!
const coursestats = new Map<string, typeof defaultCourse>()
for (const enrollment of enrollments) {
const { course_code, studentnumber, state, enrollment_date_time } = enrollment
// We cannot display these
if (course_code === null) continue
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
const course = coursestats.get(course_code)!
const initialDate = new Date(enrollment_date_time)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
course.students.all.add(studentnumber)
course.students.enrolledNoGrade.add(studentnumber)
course.enrollments[state].add(studentnumber)
course.enrollments.semesters[semester][state].add(studentnumber)
}
for (const credit of credits) {
const { course_code, student_studentnumber: studentnumber, grade, attainment_date: date } = credit
// We cannot display these
if (course_code === null) continue
const passingGrade = CreditModel.passed(credit)
const failingGrade = CreditModel.failed(credit)
const improvedGrade = CreditModel.improved(credit)
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
const course = coursestats.get(course_code)!
course.attempts += 1
const gradeCount = course.grades[grade]?.count ?? 0
course.grades[grade] = { count: gradeCount + 1, status: { passingGrade, failingGrade, improvedGrade } }
course.students.all.add(studentnumber)
course.students.enrolledNoGrade.delete(studentnumber)
if (passingGrade) {
if (!course.students.markedToSemester.has(studentnumber)) {
course.students.markedToSemester.add(studentnumber)
const semester = getPassingSemester(getYear(studentnumber), new Date(date))
course.stats.passingSemesters[semester]++
}
course.students.passed.add(studentnumber)
course.students.failed.delete(studentnumber)
} else if (improvedGrade) {
course.students.improvedPassedGrade.add(studentnumber)
course.students.passed.add(studentnumber)
course.students.failed.delete(studentnumber)
} else if (failingGrade && !course.students.passed.has(studentnumber)) {
course.students.failed.add(studentnumber)
}
}
const courses = await getCourses(Array.from(coursestats.keys()))
const courseMap = new Map(courses.map(({ code, name }) => [code, name]))
const substitutionMap = new Map(courses.map(({ code, substitutions }) => [code, substitutions]))
return Array.from(coursestats.entries()).map(([code, { attempts, enrollments, grades, students, stats }]) => {
return {
course: {
code,
name: courseMap.get(code) ?? { en: code },
substitutions: substitutionMap.get(code) ?? [],
},
attempts,
enrollments: {
[EnrollmentState.ENROLLED]: Array.from(enrollments[EnrollmentState.ENROLLED]),
[EnrollmentState.REJECTED]: Array.from(enrollments[EnrollmentState.REJECTED]),
semesters: Object.fromEntries(
Object.entries(enrollments.semesters).map(([key, val]) => [
key,
{
[EnrollmentState.ENROLLED]: Array.from(val[EnrollmentState.ENROLLED]),
[EnrollmentState.REJECTED]: Array.from(val[EnrollmentState.REJECTED]),
},
])
),
},
grades,
students: Object.fromEntries(
Object.entries(students).map(([key, val]) => [key, Array.from(val)])
) as CourseStats['students'],
stats,
}
})
}
@@ -1,4 +1,3 @@
import { omitKeys } from '@oodikone/shared/util'
import { getDegreeProgrammeType } from '../../util'
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
import { formatStudentForAPI } from './formatStatisticsForApi'
@@ -9,8 +8,9 @@ import {
getStudents,
getEnrollments,
getCredits,
getStudyRightElementsForStudyRight,
} from './getStudentData'
import { getOptionsForStudents, parseCourseData } from './shared'
import { getCourses, getOptionsForStudents } from './shared'
export type OptimizedStatisticsQuery = {
userId: string
@@ -29,58 +29,38 @@ export type StudentCreditObject = Map<StudentCredit['student_studentnumber'], An
export const statisticsOf = async (
studentNumbers: string[],
studyRights: string[],
tagList: Record<string, StudentTags[]>,
tagMap: Map<string, StudentTags[]>,
startDate?: string
) => {
const code = studyRights[0] ?? ''
const mockedStartDate = startDate ?? new Date(1900).toISOString()
const mockedStartDate = startDate ?? new Date(1900, 0, 1).toISOString()
const [enrollments, credits, students] = await Promise.all([
getEnrollments(studentNumbers, mockedStartDate),
getCredits(studentNumbers),
getStudents(studentNumbers),
])
const studentStartingYears = new Map(
students.map(({ studentnumber, studyRights }) => [
studentnumber,
studyRights
?.flatMap(({ studyRightElements }) => studyRightElements)
.find(element => element.code === code)
?.startDate.getFullYear() ?? +mockedStartDate,
const [students, enrollments, credits, degreeProgrammeType, criteria, studyRightElementsForStudyRight] =
await Promise.all([
getStudents(studentNumbers),
getEnrollments(studentNumbers, mockedStartDate),
getCredits(studentNumbers),
getDegreeProgrammeType(code),
getCriteria(code),
getStudyRightElementsForStudyRight(studentNumbers, code),
])
)
const formattedCoursestats = await parseCourseData(studentStartingYears, enrollments, credits)
const courseCodes = new Set<string>()
for (const { course_code } of credits) courseCodes.add(course_code)
for (const { course_code } of enrollments) courseCodes.add(course_code)
const courses = await getCourses(Array.from(courseCodes))
const creditsByStudent: StudentCreditObject = new Map<string, AnonymousCredit[]>(studentNumbers.map(n => [n, []]))
credits.forEach(credit =>
creditsByStudent.get(credit.student_studentnumber)!.push(omitKeys(credit, ['student_studentnumber']))
)
const optionData = getOptionsForStudents(studyRightElementsForStudyRight, degreeProgrammeType)
const formattedStudents = students.map(student => {
const tags = tagMap.get(student.studentnumber) ?? []
const options = optionData.get(student.studentnumber)
const enrollmentsByStudent: StudentEnrollmentObject = new Map<string, AnonymousEnrollment[]>(
studentNumbers.map(n => [n, []])
)
enrollments.forEach(enrollment =>
enrollmentsByStudent.get(enrollment.studentnumber)!.push(omitKeys(enrollment, ['studentnumber']))
)
const criteria = await getCriteria(code)
const optionData = await getOptionsForStudents(studentNumbers, code, await getDegreeProgrammeType(code))
return formatStudentForAPI(code, mockedStartDate, student, tags, options)
})
return {
coursestatistics: formattedCoursestats,
students: students.map(student =>
formatStudentForAPI(
code,
mockedStartDate,
student,
tagList[student.studentnumber],
creditsByStudent.get(student.studentnumber)!,
enrollmentsByStudent.get(student.studentnumber)!,
optionData[student.studentnumber],
criteria
)
),
students: formattedStudents,
criteria,
coursestatistics: { courses, enrollments, credits },
}
}
@@ -1,9 +1,11 @@
import { SISStudyRight } from '@oodikone/shared/models'
import { EnrollmentType, ExtentCode } from '@oodikone/shared/types'
import { CreditStats } from '@oodikone/shared/types/studyProgramme'
import { mapToProviders } from '@oodikone/shared/util'
import { getCreditStats, setCreditStats } from './analyticsService'
import { getCourseCodesOfProvider } from './providers'
import { getCreditsForProvider, getTransferredCredits } from './studyProgramme/creditGetters'
import { getStudentHetuStateMap } from './studyProgramme/studentGetters'
import { defineYear } from './studyProgramme/studyProgrammeHelpers'
import { getSISStudyRightsOfStudents } from './studyProgramme/studyRightFinders'
@@ -46,15 +48,17 @@ const basicDegreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCo
This is probably very rare to cause differences, only one such was found in MH60_001
*/
// TODO: How should licentiate and doctorate studies be categorized?
const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
const getCategory = (hasHetu: boolean, extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
if (degreeStudyRightExtentCode && basicDegreeExtentCodes.includes(degreeStudyRightExtentCode)) {
return 'basic'
}
switch (extentCode) {
case ExtentCode.OPEN_UNIVERSITY_STUDIES:
case ExtentCode.SUMMER_AND_WINTER_SCHOOL:
return 'open-uni'
if (hasHetu) {
return 'open-uni-hetu'
} else {
return 'open-uni-no-hetu'
}
case ExtentCode.MASTER:
case ExtentCode.BACHELOR_AND_MASTER:
case ExtentCode.BACHELOR:
@@ -62,6 +66,7 @@ const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: Exten
case ExtentCode.NON_DEGREE_STUDIES:
case ExtentCode.NON_DEGREE_PEGAGOGICAL_STUDIES_FOR_TEACHERS:
case ExtentCode.NON_DEGREE_PROGRAMME_FOR_SPECIAL_EDUCATION_TEACHERS:
case ExtentCode.SPECIALIZATION_STUDIES:
return 'separate'
case ExtentCode.EXCHANGE_STUDIES:
case ExtentCode.EXCHANGE_STUDIES_POSTGRADUATE:
@@ -71,9 +76,9 @@ const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: Exten
case ExtentCode.DOCTOR:
case ExtentCode.LICENTIATE:
case ExtentCode.CONTINUING_EDUCATION:
case ExtentCode.SPECIALIZATION_STUDIES:
case ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY:
case ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS:
case ExtentCode.SUMMER_AND_WINTER_SCHOOL:
case undefined:
return 'other'
default:
@@ -81,8 +86,14 @@ const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: Exten
}
}
const getBasicDegreeStudyRight = (studyRights: Array<SISStudyRight> | undefined, date: Date, semestercode: number) => {
return studyRights?.find(studyRight => {
const getBasicDegreeStudyRight = (
studyRights:
| Pick<SISStudyRight, 'id' | 'studentNumber' | 'extentCode' | 'semesterEnrollments' | 'startDate' | 'endDate'>[]
| undefined,
date: Date,
semestercode: number
) =>
studyRights?.find(studyRight => {
const rightDates =
new Date(studyRight.startDate).getTime() <= new Date(date).getTime() &&
new Date(date).getTime() <= new Date(studyRight.endDate).getTime()
@@ -91,10 +102,9 @@ const getBasicDegreeStudyRight = (studyRights: Array<SISStudyRight> | undefined,
)
return basicDegreeExtentCodes.includes(studyRight.extentCode) && rightDates && enrolledAsPresent
})
}
/* Calculates credits produced by provider (programme or faculty) */
export const computeCreditsProduced = async (providerCode: string, isAcademicYear: boolean) => {
export const computeCreditsProduced = async (providerCode: string, isAcademicYear: boolean): Promise<CreditStats> => {
const since = new Date('2017-01-01')
const rapoFormattedProviderCode = mapToProviders([providerCode])[0]
const courses = await getCourseCodesOfProvider(rapoFormattedProviderCode)
@@ -116,40 +126,43 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
const studyRights = await getSISStudyRightsOfStudents(students)
const [studyRights, studentToHetuStateMap] = await Promise.all([
getSISStudyRightsOfStudents(students),
getStudentHetuStateMap(students),
])
const studyRightIdToStudyRightMap = studyRights.reduce<Record<string, SISStudyRight>>((obj, cur) => {
obj[cur.id] = cur
return obj
}, {})
const studyRightIdToStudyRightMap = new Map(studyRights.map(sr => [sr.id, sr]))
const studentNumberToStudyRightsMap = studyRights.reduce<Record<string, Array<SISStudyRight>>>((obj, cur) => {
if (!obj[cur.studentNumber]) {
obj[cur.studentNumber] = []
const studentNumberToStudyRightsMap = studyRights.reduce((map, cur) => {
// Node does not support getOrInsert as of writing :-(
if (map.has(cur.studentNumber)) {
map.get(cur.studentNumber)!.push(cur)
} else {
map.set(cur.studentNumber, [cur])
}
obj[cur.studentNumber].push(cur)
return obj
}, {})
return map
}, new Map<string, [(typeof studyRights)[number]]>())
const stats: Record<string, Record<string, number>> = {}
for (const { attainmentDate, studentNumber, credits: numOfCredits, studyrightId, semestercode } of credits) {
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap[studyrightId]
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap.get(studyrightId)
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
const degreeStudyRight = getBasicDegreeStudyRight(
studentNumberToStudyRightsMap[studentNumber],
studentNumberToStudyRightsMap.get(studentNumber),
attainmentDate,
semestercode
)
const hasHetu = !!studentToHetuStateMap.get(studentNumber)
if (!studyRightLinkedToAttainment && !degreeStudyRight) {
continue
}
const category = getCategory(studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
const category = getCategory(hasHetu, studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
stats[attainmentYear] ??= {}
stats[attainmentYear][category] ??= 0
stats[attainmentYear][category] += numOfCredits || 0
stats[attainmentYear][category] += numOfCredits
}
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
@@ -161,18 +174,18 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const attainmentYear = defineYear(createdate, isAcademicYear)
stats[attainmentYear] ??= {}
stats[attainmentYear].transferred ??= 0
stats[attainmentYear].transferred += credits || 0
stats[attainmentYear].transferred += credits
})
return { stats, id: providerCode }
}
export const getCreditsProduced = async (providerCode: string, isAcademicYear: boolean, specialIncluded = true) => {
let data = await getCreditStats(providerCode, isAcademicYear, specialIncluded)
const data = await getCreditStats(providerCode, isAcademicYear, specialIncluded)
if (data) {
return data
}
data = await computeCreditsProduced(providerCode, isAcademicYear)
await setCreditStats(data, isAcademicYear, specialIncluded)
return data
const newData = await computeCreditsProduced(providerCode, isAcademicYear)
await setCreditStats(newData, isAcademicYear, specialIncluded)
return newData
}
+2 -2
View File
@@ -26,7 +26,7 @@ type SemestersAndYears = {
>
}
export const getSemesterNames = async (semesterCodes: number[]) => {
export const getSemesterNamesByCode = async (semesterCodes: number[]): Promise<Record<number, Name>> => {
const semesters = await SemesterModel.findAll({
attributes: ['name', 'semestercode'],
where: {
@@ -35,7 +35,7 @@ export const getSemesterNames = async (semesterCodes: number[]) => {
raw: true,
})
return Object.fromEntries(semesters?.map(({ name, semestercode }) => [semestercode, name]) ?? [])
return Object.fromEntries(semesters.map(({ name, semestercode }) => [semestercode, name]))
}
export const getCurrentSemester = async () => {
+10 -12
View File
@@ -2,7 +2,8 @@ import { Op, QueryTypes } from 'sequelize'
import type { Credit, Student } from '@oodikone/shared/models'
import { EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
import { FormattedStudentForSearch, StudentPageStudent } from '@oodikone/shared/types/studentData'
import { splitByEmptySpace } from '@oodikone/shared/util'
import { dbConnections } from '../database/connection'
import {
StudentModel,
@@ -15,7 +16,6 @@ import {
SISStudyRightElementModel,
} from '../models'
import { TagModel, TagStudentModel } from '../models/kone'
import { splitByEmptySpace } from '../util'
const { sequelize } = dbConnections
@@ -307,16 +307,14 @@ const formatStudentForSearch = ({
lastname,
studentnumber,
studyRights,
}: Student) => {
return {
activeStudyRights: studyRights || [],
credits: creditcount || 0,
firstNames: firstnames,
lastName: lastname,
studentNumber: studentnumber,
started: dateofuniversityenrollment,
}
}
}: Student): FormattedStudentForSearch => ({
activeStudyRights: studyRights || [],
credits: creditcount || 0,
firstNames: firstnames,
lastName: lastname,
studentNumber: studentnumber,
started: dateofuniversityenrollment?.toString(),
})
export const bySearchTermAndStudentNumbers = async (searchTerm: string, studentNumbers?: string[]) => {
const terms = splitByEmptySpace(searchTerm)
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash'
import { uniq } from 'lodash-es'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
import { StudyGuidanceGroupTagModel } from '../models/kone'
@@ -9,30 +9,25 @@ import logger from '../util/logger'
const importerClient = getImporterClient()
const getGroupsFromImporter = async (sisPersonId: string) => {
if (!importerClient || !sisPersonId) {
return []
}
const answerTimeout = new Promise(resolve => setTimeout(resolve, 2000))
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
if (!importerClient || !sisPersonId) return []
try {
const response = (await Promise.race([
importerClient.get(`/person-groups/person/${sisPersonId}`),
answerTimeout,
])) as { data: Record<string, StudyGuidanceGroup> }
if (!response) {
return []
}
const studyGuidanceGroups: StudyGuidanceGroup[] = Object.values(response.data)
return studyGuidanceGroups
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
`/person-groups/person/${sisPersonId}`
)
return response?.data ? Object.values(response.data) : []
} catch (error) {
logger.error("Couldn't fetch users study guidance groups")
logger.error('Could not fetch study guidance groups!')
if (error instanceof AxiosError) {
logger.error(error.stack)
logger.error(JSON.stringify(error.response?.data))
Sentry.captureException(error)
}
return []
}
return []
}
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
@@ -1,4 +1,4 @@
import { memoize } from 'lodash'
import { memoize } from 'lodash-es'
import { Op, QueryTypes } from 'sequelize'
import { Name, CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
@@ -52,15 +52,13 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
},
})
const allEnrollments = {} as Record<string, string[]>
const allEnrollments = new Map<string, Set<string>>()
for (const { studentnumber, course_code: courseCode } of enrollmentsCourses) {
const code = getCourseCode(courseCode)
if (!(code in allEnrollments)) {
allEnrollments[code] = []
}
if (!allEnrollments[code].includes(studentnumber)) {
allEnrollments[code].push(studentnumber)
if (!allEnrollments.has(code)) {
allEnrollments.set(code, new Set())
}
allEnrollments.get(code)!.add(studentnumber)
}
const credits = await CreditModel.findAll({
@@ -75,7 +73,7 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
},
})
const courseNames = (
const courseCodeToName = (
await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
@@ -87,63 +85,50 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
return acc
}, new Map())
const creditCourses = credits.map(credit => {
return {
code: getCourseCode(credit.course_code),
studentNumber: credit.student_studentnumber,
creditTypeCode: credit.credittypecode,
courseName: courseNames.get(credit.course_code)!,
isStudyModule: credit.isStudyModule,
}
})
const creditCourses = credits.map(credit => ({
code: getCourseCode(credit.course_code),
studentNumber: credit.student_studentnumber,
creditTypeCode: credit.credittypecode,
courseName: courseCodeToName.get(credit.course_code)!,
isStudyModule: credit.isStudyModule,
}))
const passedByCourseCodes = {} as Record<string, string[]>
const notCompletedByCourseCodes = {} as Record<string, string[]>
const courses: Record<string, { code: string; name: Name; isStudyModule: boolean }> = {}
for (const course of creditCourses) {
if (!(course.code in courses)) {
courses[course.code] = {
code: course.code,
name: course.courseName,
isStudyModule: course.isStudyModule,
}
passedByCourseCodes[course.code] = []
notCompletedByCourseCodes[course.code] = []
const passedByCourseCodes = new Map<string, Set<string>>()
const notCompletedByCourseCodes = new Map<string, Set<string>>()
const courses = new Map<string, { name: Name; isStudyModule: boolean }>()
for (const { code, courseName, creditTypeCode, isStudyModule, studentNumber } of creditCourses) {
if (!courses.has(code)) {
courses.set(code, {
isStudyModule,
name: courseName,
})
passedByCourseCodes.set(code, new Set())
notCompletedByCourseCodes.set(code, new Set())
}
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(course.creditTypeCode)) {
passedByCourseCodes[course.code].push(course.studentNumber)
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(creditTypeCode)) {
passedByCourseCodes.get(code)!.add(studentNumber)
}
if (
course.creditTypeCode === CreditTypeCode.FAILED &&
!passedByCourseCodes[course.code].includes(course.studentNumber)
) {
notCompletedByCourseCodes[course.code].push(course.studentNumber)
if (creditTypeCode === CreditTypeCode.FAILED && !passedByCourseCodes.get(code)!.has(studentNumber)) {
notCompletedByCourseCodes.get(code)!.add(studentNumber)
}
}
// If student has enrollments, but no attainment for a particular course, they have no credit info.
programmeCourses.forEach(courseCode => {
if (allEnrollments[courseCode]) {
allEnrollments[courseCode].forEach(studentnumber => {
if (passedByCourseCodes[courseCode] && !passedByCourseCodes[courseCode].includes(studentnumber)) {
notCompletedByCourseCodes[courseCode].push(studentnumber)
}
})
}
allEnrollments.get(courseCode)?.forEach(studentnumber => {
if (passedByCourseCodes.get(courseCode)?.has(studentnumber)) {
notCompletedByCourseCodes.get(courseCode)?.add(studentnumber)
}
})
})
return Object.keys(courses)
.reduce<Array<{ code: string; name: Name; isStudyModule: boolean }>>(
(acc, val) => [...acc, { ...courses[val] }],
[]
)
.map(course => ({
code: course.code,
name: course.name,
type: 'notCompleted',
isStudyModule: course.isStudyModule,
totalNotCompleted: [...new Set(notCompletedByCourseCodes[course.code])].length,
}))
return [...courses.entries()].map(([code, { isStudyModule, name }]) => ({
code,
name,
isStudyModule,
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
}))
} catch (error) {
logger.error(`getNotCompletedForProgrammeCourses failed ${error}`)
return []
@@ -1,348 +1,261 @@
import { QueryTypes } from 'sequelize'
import { Op, QueryTypes } from 'sequelize'
import { Name, ExtentCode } from '@oodikone/shared/types'
import { CreditTypeCode, ExtentCode, Name } from '@oodikone/shared/types'
import { dbConnections } from '../../database/connection'
import { StudentModel } from '../../models'
import logger from '../../util/logger'
const { sequelize } = dbConnections
// Categories here are as per Linjaukset.md in internal docs.
// Refer to them before making changes
const degreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCode.BACHELOR_AND_MASTER]
type QueryResult = {
totalStudents: string
totalCredits: number
code: string
const exchangeStudentExtentCodes = [ExtentCode.EXCHANGE_STUDIES, ExtentCode.EXCHANGE_STUDIES_POSTGRADUATE]
const otherUniversityExtentCodes = [ExtentCode.CONTRACT_TRAINING]
const separateStudyExtentCodes = [
ExtentCode.NON_DEGREE_STUDIES,
ExtentCode.NON_DEGREE_PEGAGOGICAL_STUDIES_FOR_TEACHERS,
ExtentCode.NON_DEGREE_PROGRAMME_FOR_SPECIAL_EDUCATION_TEACHERS,
ExtentCode.SPECIALIZATION_STUDIES,
]
const otherExtentCodes = [
ExtentCode.CONTINUING_EDUCATION,
ExtentCode.DOCTOR,
ExtentCode.LICENTIATE,
ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY, // Includes veterinary
ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS,
ExtentCode.SUMMER_AND_WINTER_SCHOOL,
]
const openUniversityExtentCodes = [ExtentCode.OPEN_UNIVERSITY_STUDIES]
export type ProgrammeCourseCreditRow = {
courseCode: string
courseName: Name
studentNumber: string
attainmentDate: string
credits: number
variant: 'degree' | 'exchange' | 'separate' | 'otherUniversity' | 'other' | 'openUni' | 'none'
isStudyModule: boolean
}
export const getStudentsForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
dist."courseName",
dist."isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'passed',
isStudyModule: course.isStudyModule,
totalPassed: parseInt(course.totalStudents, 10),
totalAllCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getStudentsForProgrammeCourses() function failed ${error}`)
return []
}
export type TransferCourseCreditRow = {
courseCode: string
courseName: Name
studentNumber: string
attainmentDate: string
credits: number
isStudyModule: boolean
}
export const getOwnStudentsForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[],
studyProgramme?: string
) => {
if (!programmeCourses.length) {
const { sequelize } = dbConnections
/**
NOTE:
1. There are some attainments that are linked to a valid study right, but the attainmentdate
is outside the study right's validity period. (maybe human error when the credits were registered)
2. There are some attainments with no attached study right at all.
3. Possibly some other edge cases.
Such cases get variant "none" and are not included in any category
Order of cases is somewhat important. refer to linjaukset.md, but TLDR;
degree student -> has any degree (bsc / msc) study right in the university
openuni -> only if no other study rights
**/
export const getProgrammeCourseAggregates = async (params: {
courseCodes: string[]
from: Date
to: Date
}): Promise<ProgrammeCourseCreditRow[]> => {
const { courseCodes, from, to } = params
if (courseCodes.length === 0) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
return await sequelize.query<ProgrammeCourseCreditRow>(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
sis_study_rights sr
ON sr.student_number = cr.student_studentnumber
INNER JOIN
sis_study_right_elements se
ON se.study_right_id = sr.id
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND (
se.code = :studyProgramme
AND cr.attainment_date BETWEEN se.start_date AND se.end_date
WITH filtered_credits AS (
SELECT
cr.course_code AS "courseCode",
co.name AS "courseName",
cr.student_studentnumber AS "studentNumber",
cr.attainment_date AS "attainmentDate",
cr.credits AS credits,
cr."isStudyModule" AS "isStudyModule"
FROM
credit cr
INNER JOIN course co ON co.code = cr.course_code
WHERE
cr.course_code IN (:courseCodes)
AND cr.attainment_date BETWEEN :from AND :to
AND cr.credittypecode = :creditType
)
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses, studyProgramme },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'ownProgramme',
totalProgrammeStudents: parseInt(course.totalStudents, 10),
totalProgrammeCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getOwnStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getOtherStudentsForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[],
studyProgramme?: string
) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
SELECT
fc."courseCode",
fc."courseName",
fc."studentNumber",
fc."attainmentDate",
fc.credits,
fc."isStudyModule",
CASE
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:degreeExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'degree'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:exchangeStudentExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'exchange'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:separateStudyExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'separate'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:otherUniversityExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'otherUniversity'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:otherExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'other'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:openUniversityExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'openUni'
ELSE 'none'
END AS variant
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND cr.student_studentnumber NOT IN (
SELECT
sr.student_number
FROM
sis_study_rights sr
INNER JOIN
sis_study_right_elements sre
ON sre.study_right_id = sr.id
AND sre.code = :studyProgramme
AND cr.attainment_date BETWEEN sre.start_date AND sre.end_date
WHERE
sr.student_number = cr.student_studentnumber
)
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses, studyProgramme },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'otherProgramme',
isStudyModule: course.isStudyModule,
totalOtherProgrammeStudents: parseInt(course.totalStudents, 10),
totalOtherProgrammeCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getOtherStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getTransferStudentsForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 9
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'transfer',
isStudyModule: course.isStudyModule,
totalTransferStudents: parseInt(course.totalStudents, 10),
totalTransferCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getTransferStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getStudentsWithoutStudyRightForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[]
) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
LEFT JOIN
sis_study_rights sr
ON sr.student_number = cr.student_studentnumber
AND sr.extent_code IN (:degreeExtentCodes)
AND cr.attainment_date BETWEEN sr.start_date AND sr.end_date
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND sr.student_number IS NULL
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
filtered_credits fc
`,
{
type: QueryTypes.SELECT,
replacements: {
courseCodes,
from,
to,
programmeCourses,
degreeExtentCodes: [
ExtentCode.BACHELOR,
ExtentCode.MASTER,
ExtentCode.BACHELOR_AND_MASTER,
ExtentCode.DOCTOR,
ExtentCode.LICENTIATE,
ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY,
],
creditType: CreditTypeCode.PASSED,
degreeExtentCodes,
otherUniversityExtentCodes,
separateStudyExtentCodes,
exchangeStudentExtentCodes,
openUniversityExtentCodes,
otherExtentCodes,
},
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'noStudyright',
totalWithoutStudyRightStudents: parseInt(course.totalStudents, 10),
totalWithoutStudyRightCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getStudentsWithoutStudyRightForProgrammeCourses() function failed ${error}`)
logger.error('getProgrammeCourseAggregates failed', { error })
return []
}
}
export const getTransferCourseAggregates = async (params: {
courseCodes: string[]
from: Date
to: Date
}): Promise<TransferCourseCreditRow[]> => {
const { courseCodes, from, to } = params
if (courseCodes.length === 0) {
return []
}
try {
return await sequelize.query<TransferCourseCreditRow>(
`
SELECT
cr.course_code AS "courseCode",
co.name AS "courseName",
cr.student_studentnumber AS "studentNumber",
cr.attainment_date AS "attainmentDate",
cr.credits AS credits,
cr."isStudyModule" AS "isStudyModule"
FROM
credit cr
INNER JOIN course co ON co.code = cr.course_code
WHERE
cr.course_code IN (:courseCodes)
AND cr.attainment_date BETWEEN :from AND :to
AND cr.credittypecode = :creditType
`,
{
type: QueryTypes.SELECT,
replacements: {
courseCodes,
from,
to,
creditType: CreditTypeCode.APPROVED,
},
}
)
} catch (error) {
logger.error('getTransferCourseAggregates failed', { error })
return []
}
}
export const getStudentHetuStateMap = async (studentNumbers: string[]): Promise<Map<string, boolean>> => {
if (!studentNumbers.length) return new Map()
try {
return new Map(
(
await StudentModel.findAll({
attributes: ['studentnumber', 'hasPersonalIdentityCode'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
},
raw: true,
})
).map(student => [student.studentnumber, student.hasPersonalIdentityCode])
)
} catch (error) {
logger.error('getOpenUniHetutonSplit failed', { error })
return new Map()
}
}
@@ -1,6 +1,7 @@
import { indexOf } from 'lodash'
import { indexOf } from 'lodash-es'
import { Name, EnrollmentType, SemesterEnrollment } from '@oodikone/shared/types'
import { BasicStats } from '@oodikone/shared/types/studyProgramme'
import { getSemestersAndYears } from '../semesters'
import {
defineYear,
@@ -278,7 +279,7 @@ export const getBasicStatsForStudytrack = async ({
isAcademicYear: boolean
includeAllSpecials: boolean
}
}) => {
}): Promise<BasicStats> => {
const { includeAllSpecials, isAcademicYear } = settings
const since = getStartDate(isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear) as number[]
@@ -1,390 +1,293 @@
import { orderBy, range } from 'lodash'
import { Op } from 'sequelize'
import { range } from 'lodash-es'
import { Name, StudyProgrammeCourse, CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { Name, StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { CreditModel, EnrollmentModel } from '../../models'
import { isOpenUniCourseCode } from '../../util'
import { createArrayOfCourses } from '../languageCenterData'
import { getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses, getAllProgrammeCourses } from '.'
import {
getOtherStudentsForProgrammeCourses,
getOwnStudentsForProgrammeCourses,
getStudentsForProgrammeCourses,
getStudentsWithoutStudyRightForProgrammeCourses,
getTransferStudentsForProgrammeCourses,
} from './studentGetters'
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
const getCurrentYearStartDate = () => {
return new Date(new Date().getFullYear(), 0, 1)
}
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
const START_YEAR = 2017
const JULY = 6
const AUGUST = 7
const getCurrentYearStartDate = () => new Date(new Date().getFullYear(), 0, 1)
const getFrom = (yearType: YearType, year: number) =>
yearType === 'ACADEMIC_YEAR' ? new Date(year, AUGUST, 1, 0, 0, 0) : new Date(year, 0, 1, 0, 0, 0)
const getTo = (yearType: YearType, year: number) =>
yearType === 'ACADEMIC_YEAR' ? new Date(year + 1, JULY, 31, 23, 59, 59) : new Date(year, 11, 31, 23, 59, 59)
const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
const providerCode = mapToProviders([studyProgramme])[0]
if (!providerCode) return []
const normalCourses = await getAllProgrammeCourses(providerCode)
return normalCourses.reduce((acc, curr) => {
acc.push(curr.code)
if (curr.substitutions?.includes(`AY${curr.code}`)) {
acc.push(`AY${curr.code}`)
const courseCodes = new Set<string>()
for (const course of normalCourses) {
courseCodes.add(course.code)
if (course.substitutions?.includes(`AY${course.code}`)) {
courseCodes.add(`AY${course.code}`)
}
return acc
}, [] as string[])
}
return [...courseCodes]
}
const getFrom = (academicYear: string, year: number) => {
return academicYear === 'ACADEMIC_YEAR' ? new Date(year, 7, 1, 0, 0, 0) : new Date(year, 0, 1, 0, 0, 0)
const getYearKey = (date: Date, isAcademicYear: boolean) => {
const year = date.getFullYear()
if (!isAcademicYear) return year
return date.getMonth() >= 7 ? year : year - 1
}
const getTo = (academicYear: string, year: number) => {
return academicYear === 'ACADEMIC_YEAR' ? new Date(year + 1, 6, 31, 23, 59, 59) : new Date(year, 11, 31, 23, 59, 59)
type YearAccumulator = {
stats: YearStats
allPassed: Set<string>
degreeStudents: Set<string>
exchangeStudents: Set<string>
otherUniversityStudents: Set<string>
separateStudents: Set<string>
otherStudents: Set<string>
openStudentsHasHetu: Set<string>
openStudentsNoHetu: Set<string>
transferStudents: Set<string>
}
// * Combined return type, should be split into smaller types
type Students = {
type CourseAccumulator = {
code: string
name: Name
type: string
isStudyModule?: boolean
year?: number
totalPassed?: number
totalAllCredits?: number
totalNotCompleted?: number
totalProgrammeStudents?: number
totalProgrammeCredits?: number
totalWithoutStudyRightStudents?: number
totalWithoutStudyRightCredits?: number
totalOtherProgrammeStudents?: number
totalOtherProgrammeCredits?: number
totalTransferStudents?: number
totalTransferCredits?: number
isStudyModule: boolean
years: Map<number, YearAccumulator>
}
// TODO: It would be a good idea to split this into smaller functions
// The amount of different return types makes this very messy
const makeYearlyPromises = (
years: number[],
academicYear: string,
type: 'passed' | 'notCompleted' | 'ownStudents' | 'withoutStudyRight' | 'otherStudents' | 'transfer',
programmeCourses: string[],
studyProgramme?: string
) => {
return years.map(async year => {
const from = getFrom(academicYear, year)
const to = getTo(academicYear, year)
let result: Students[] | null = null
const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
stats: createEmptyStats(isStudyModule),
allPassed: new Set(),
degreeStudents: new Set(),
exchangeStudents: new Set(),
otherUniversityStudents: new Set(),
separateStudents: new Set(),
otherStudents: new Set(),
switch (type) {
case 'passed':
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
break
case 'notCompleted':
result = await getNotCompletedForProgrammeCourses(from, to, programmeCourses)
break
case 'ownStudents':
result = await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'withoutStudyRight':
result = await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)
break
case 'otherStudents':
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
break
case 'transfer':
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
break
default:
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
}
openStudentsHasHetu: new Set(),
openStudentsNoHetu: new Set(),
transferStudents: new Set(),
})
return result.map(course => {
course.year = year
return course
const ensureCourse = (
courseMap: Map<string, CourseAccumulator>,
code: string,
name: Name,
isStudyModule: boolean
): CourseAccumulator => {
if (!courseMap.has(code)) {
courseMap.set(code, {
code,
name,
isStudyModule,
years: new Map(),
})
})
}
const course = courseMap.get(code)!
course.isStudyModule = course.isStudyModule || isStudyModule
course.name = course.name ?? name
return course
}
type Attempt = {
studentNumber: string
courseCode: string
completed: boolean
date: Date
semestercode: number
enrolled?: boolean
const ensureYear = (course: CourseAccumulator, year: number, isStudyModule: boolean) => {
if (!course.years.has(year)) {
course.years.set(year, createYearAccumulator(isStudyModule))
}
const yearAccumulator = course.years.get(year)!
yearAccumulator.stats.isStudyModule ??= isStudyModule
return yearAccumulator
}
export const getStudyProgrammeStatsForColorizedCoursesTable = async (studyProgramme: string) => {
const courses = await getAllProgrammeCourses(mapToProviders([studyProgramme])[0])
const autumnSemester2017 = 135
const courseCodes = courses.map(course => course.code)
const finalizeYearStats = (yearAccumulator: YearAccumulator): YearStats => {
const { stats } = yearAccumulator
const credits = await CreditModel.findAll({
attributes: ['course_code', 'student_studentnumber', 'semestercode', 'attainment_date'],
where: {
course_code: { [Op.in]: courseCodes },
semestercode: { [Op.gte]: autumnSemester2017 },
credittypecode: CreditTypeCode.PASSED,
},
raw: true,
})
stats.allPassed = yearAccumulator.allPassed.size
const enrollments = await EnrollmentModel.findAll({
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'state'],
where: {
course_code: { [Op.in]: courseCodes },
semestercode: { [Op.gte]: autumnSemester2017 },
state: { [Op.in]: [EnrollmentState.ENROLLED, EnrollmentState.REJECTED] },
},
raw: true,
})
stats.degreeStudents = yearAccumulator.degreeStudents.size
stats.exchangeStudents = yearAccumulator.exchangeStudents.size
stats.otherUniversityStudents = yearAccumulator.otherUniversityStudents.size
stats.separateStudents = yearAccumulator.separateStudents.size
stats.otherStudents = yearAccumulator.otherStudents.size
const studentList = new Set<string>()
const attemptsByStudents = {} as Record<string, Attempt[]>
stats.openStudentsHasHetu = yearAccumulator.openStudentsHasHetu.size
stats.openStudentsNoHetu = yearAccumulator.openStudentsNoHetu.size
stats.transferStudents = yearAccumulator.transferStudents.size
credits.forEach(credit => {
const studentNumber = credit.student_studentnumber
studentList.add(studentNumber)
if (!attemptsByStudents[studentNumber]) {
attemptsByStudents[studentNumber] = []
}
attemptsByStudents[studentNumber].push({
studentNumber,
courseCode: credit.course_code,
completed: true,
date: credit.attainment_date,
semestercode: credit.semestercode,
})
})
stats.allStudents = stats.allPassed + stats.allNotPassed
enrollments.forEach(enrollment => {
const studentNumber = enrollment.studentnumber
if (!attemptsByStudents[studentNumber]) {
attemptsByStudents[studentNumber] = []
}
studentList.add(studentNumber)
if (
attemptsByStudents[studentNumber].find(
attempt =>
!attempt.completed &&
attempt.semestercode === enrollment.semestercode &&
attempt.courseCode === enrollment.course_code
)
) {
return
}
attemptsByStudents[studentNumber].push({
studentNumber,
courseCode: enrollment.course_code,
completed: false,
date: enrollment.enrollment_date_time,
semestercode: enrollment.semestercode,
enrolled: enrollment.state === EnrollmentState.ENROLLED,
})
})
const attemptsArray = [] as Attempt[]
studentList.forEach(studentNumber => attemptsArray.push(...attemptsByStudents[studentNumber]))
const unorderedTableData = await createArrayOfCourses(attemptsArray, courses)
const tableData = orderBy(unorderedTableData, 'code')
return { tableData }
return stats
}
const getYearRange = async (unixMillis: number, isAcademicYear: boolean) => {
const startDate = isAcademicYear ? await getCurrentStudyYearStartDate(unixMillis) : getCurrentYearStartDate()
const lastYear = startDate.getFullYear()
return range(START_YEAR, lastYear + 1)
}
/**
* @endpoint "programme courses"
*/
export const getStudyProgrammeCoursesForStudyTrack = async (
unixMillis: number,
studyProgramme: string,
academicYear: string,
academicYear: YearType,
combinedProgramme: string
) => {
const startDate =
academicYear === 'ACADEMIC_YEAR' ? await getCurrentStudyYearStartDate(unixMillis) : getCurrentYearStartDate()
const startYear = startDate.getFullYear()
const yearRange = range(2017, startYear + 1)
const isAcademicYear = academicYear === 'ACADEMIC_YEAR'
const yearRange = await getYearRange(unixMillis, isAcademicYear)
if (yearRange.length === 0) {
return []
}
const mainProgrammeCourses = await getAllStudyProgrammeCourses(studyProgramme)
const secondProgrammeCourses = combinedProgramme ? await getAllStudyProgrammeCourses(combinedProgramme) : []
const programmeCourses = [...mainProgrammeCourses, ...secondProgrammeCourses]
const programmeCourses = [...new Set([...mainProgrammeCourses, ...secondProgrammeCourses])]
const yearlyPassedStudentByCoursePromises = makeYearlyPromises(yearRange, academicYear, 'passed', programmeCourses)
const yearlyNotCompletedStudentByCoursePromises = makeYearlyPromises(
yearRange,
academicYear,
'notCompleted',
programmeCourses
)
const yearlyProgrammeStudentsPromises = makeYearlyPromises(
yearRange,
academicYear,
'ownStudents',
programmeCourses,
studyProgramme
)
const yearlyStudentsWithoutStudyRightPromises = makeYearlyPromises(
yearRange,
academicYear,
'withoutStudyRight',
programmeCourses
)
const yearlyOtherProgrammeStudentsPromises = makeYearlyPromises(
yearRange,
academicYear,
'otherStudents',
programmeCourses,
studyProgramme
)
const yearlyTransferStudentsPromises = makeYearlyPromises(
yearRange,
academicYear,
'transfer',
programmeCourses,
studyProgramme
)
if (programmeCourses.length === 0) {
return []
}
const [
yearlyPassedStudentByCourse,
yearlyNotCompletedStudentByCourse,
yearlyProgrammeStudents,
yearlyStudentsWithoutStudyRight,
yearlyOtherProgrammeStudents,
yearlyTransferStudents,
] = await Promise.all([
Promise.all(yearlyPassedStudentByCoursePromises),
Promise.all(yearlyNotCompletedStudentByCoursePromises),
Promise.all(yearlyProgrammeStudentsPromises),
Promise.all(yearlyStudentsWithoutStudyRightPromises),
Promise.all(yearlyOtherProgrammeStudentsPromises),
Promise.all(yearlyTransferStudentsPromises),
const firstYear = yearRange[0]
const lastYear = yearRange[yearRange.length - 1]
const from = getFrom(academicYear, firstYear)
const to = getTo(academicYear, lastYear)
const [programmeCredits, transferCredits] = await Promise.all([
getProgrammeCourseAggregates({
courseCodes: programmeCourses,
from,
to,
}),
getTransferCourseAggregates({
courseCodes: programmeCourses,
from,
to,
}),
])
let maxYear = 0
const allCourses = [
...yearlyPassedStudentByCourse.flat(),
...yearlyNotCompletedStudentByCourse.flat(),
...yearlyProgrammeStudents.flat(),
...yearlyStudentsWithoutStudyRight.flat(),
...yearlyOtherProgrammeStudents.flat(),
...yearlyTransferStudents.flat(),
].reduce(
(acc, curr) => {
if (curr.year! > maxYear) {
maxYear = curr.year!
}
if (!acc[curr.code]) {
acc[curr.code] = {
code: curr.code,
name: curr.name,
isStudyModule: curr.isStudyModule ?? false,
years: {},
}
}
if (!acc[curr.code].years[curr.year!]) {
acc[curr.code].years[curr.year!] = {
isStudyModule: curr.isStudyModule ?? false,
totalAllStudents: 0,
totalPassed: 0,
totalNotCompleted: 0,
totalAllCredits: 0,
totalProgrammeStudents: 0,
totalProgrammeCredits: 0,
totalOtherProgrammeStudents: 0,
totalOtherProgrammeCredits: 0,
totalWithoutStudyRightStudents: 0,
totalWithoutStudyRightCredits: 0,
totalTransferStudents: 0,
totalTransferCredits: 0,
}
}
switch (curr.type) {
case 'passed':
acc[curr.code].years[curr.year!].totalPassed += curr.totalPassed ?? 0
acc[curr.code].years[curr.year!].totalAllStudents += acc[curr.code].years[curr.year!].totalPassed
acc[curr.code].years[curr.year!].totalAllCredits += curr.totalAllCredits ?? 0
break
case 'notCompleted':
acc[curr.code].years[curr.year!].totalNotCompleted += curr.totalNotCompleted ?? 0
acc[curr.code].years[curr.year!].totalAllStudents += acc[curr.code].years[curr.year!].totalNotCompleted
break
case 'ownProgramme':
acc[curr.code].years[curr.year!].totalProgrammeStudents += curr.totalProgrammeStudents ?? 0
acc[curr.code].years[curr.year!].totalProgrammeCredits += curr.totalProgrammeCredits ?? 0
break
case 'otherProgramme':
acc[curr.code].years[curr.year!].totalOtherProgrammeStudents += curr.totalOtherProgrammeStudents ?? 0
acc[curr.code].years[curr.year!].totalOtherProgrammeCredits += curr.totalOtherProgrammeCredits ?? 0
break
case 'noStudyright':
acc[curr.code].years[curr.year!].totalWithoutStudyRightStudents += curr.totalWithoutStudyRightStudents ?? 0
acc[curr.code].years[curr.year!].totalWithoutStudyRightCredits += curr.totalWithoutStudyRightCredits ?? 0
break
case 'transfer':
acc[curr.code].years[curr.year!].totalTransferStudents += curr.totalTransferStudents ?? 0
acc[curr.code].years[curr.year!].totalTransferCredits += curr.totalTransferCredits ?? 0
break
default:
break
}
return acc
},
{} as Record<string, StudyProgrammeCourse>
const openUniStudentHetuMap = await getStudentHetuStateMap(
programmeCredits.filter(c => c.variant === 'openUni').map(c => c.studentNumber)
)
const ayCourses = Object.keys(allCourses).filter(courseCode => courseCode.startsWith('AY'))
const properties = [
'totalAllStudents',
'totalPassed',
'totalNotCompleted',
'totalAllCredits',
'totalProgrammeStudents',
'totalProgrammeCredits',
'totalOtherProgrammeStudents',
'totalOtherProgrammeCredits',
'totalWithoutStudyrightStudents',
'totalWithoutStudyrightCredits',
'totalTransferCredits',
'totalTransferStudents',
]
ayCourses.forEach(ayCourse => {
const openUniCourseCode = isOpenUniCourseCode(ayCourse)
if (!openUniCourseCode) {
return
const courseMap = new Map<string, CourseAccumulator>()
let maxYear = 0
for (const row of programmeCredits) {
const attainmentDate = new Date(row.attainmentDate)
const year = getYearKey(attainmentDate, isAcademicYear)
if (!yearRange.includes(year)) continue
maxYear = Math.max(maxYear, year)
const course = ensureCourse(courseMap, row.courseCode, row.courseName, row.isStudyModule)
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
const { stats } = yearAccumulator
stats.allCredits += row.credits
yearAccumulator.allPassed.add(row.studentNumber)
switch (row.variant) {
case 'degree':
yearAccumulator.degreeStudents.add(row.studentNumber)
stats.degreeStudentsCredits += row.credits
break
case 'exchange':
yearAccumulator.exchangeStudents.add(row.studentNumber)
stats.exchangeStudentsCredits += row.credits
break
case 'separate':
yearAccumulator.separateStudents.add(row.studentNumber)
stats.separateStudentsCredits += row.credits
break
case 'otherUniversity':
yearAccumulator.otherUniversityStudents.add(row.studentNumber)
stats.otherUniversityCredits += row.credits
break
case 'other':
yearAccumulator.otherStudents.add(row.studentNumber)
stats.otherStudentsCredits += row.credits
break
case 'openUni':
if (openUniStudentHetuMap.get(row.studentNumber)) {
stats.openStudentsHasHetuCredits += row.credits
yearAccumulator.openStudentsHasHetu.add(row.studentNumber)
} else {
stats.openStudentsNoHetuCredits += row.credits
yearAccumulator.openStudentsNoHetu.add(row.studentNumber)
}
break
default:
break
}
const normCode = openUniCourseCode[1]
}
if (allCourses[normCode]) {
const mergedCourse = {} as StudyProgrammeCourse
mergedCourse.code = allCourses[normCode].code
mergedCourse.name = allCourses[normCode].name
mergedCourse.years = {}
for (const row of transferCredits) {
const attainmentDate = new Date(row.attainmentDate)
const year = getYearKey(attainmentDate, isAcademicYear)
if (!yearRange.includes(year)) continue
maxYear = Math.max(maxYear, year)
yearRange
.filter(year => year <= maxYear)
.forEach(year => {
if (!allCourses[normCode].years[year]) {
mergedCourse.years[year] = {
totalAllStudents: 0,
totalPassed: 0,
totalNotCompleted: 0,
totalAllCredits: 0,
totalProgrammeStudents: 0,
totalProgrammeCredits: 0,
totalOtherProgrammeStudents: 0,
totalOtherProgrammeCredits: 0,
totalWithoutStudyRightStudents: 0,
totalWithoutStudyRightCredits: 0,
totalTransferCredits: 0,
totalTransferStudents: 0,
}
} else {
mergedCourse.years[year] = { ...allCourses[normCode].years[year] }
}
if (allCourses[ayCourse].years[year]) {
properties.forEach(prop => {
mergedCourse.years[year][prop] += allCourses[ayCourse].years[year][prop]
})
}
})
allCourses[normCode] = mergedCourse
delete allCourses[ayCourse]
const course = ensureCourse(courseMap, row.courseCode, row.courseName, row.isStudyModule)
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
yearAccumulator.transferStudents.add(row.studentNumber)
yearAccumulator.stats.transferStudentsCredits += row.credits
}
const notCompletedResults = await Promise.all(
yearRange.map(year => {
const yearFrom = getFrom(academicYear, year)
const yearTo = getTo(academicYear, year)
return getNotCompletedForProgrammeCourses(yearFrom, yearTo, programmeCourses)
})
)
notCompletedResults.forEach((results, index) => {
const year = yearRange[index]
if (!results) return
if (results.length > 0) {
maxYear = Math.max(maxYear, year)
}
for (const result of results) {
const course = ensureCourse(courseMap, result.code, result.name, result.isStudyModule ?? false)
const yearAccumulator = ensureYear(course, year, result.isStudyModule ?? false)
yearAccumulator.stats.allNotPassed += result.allNotPassed
}
})
return Object.values(allCourses)
if (maxYear === 0) {
maxYear = lastYear
}
const coursesRecord: Record<string, StudyProgrammeCourse> = {}
for (const [code, course] of courseMap.entries()) {
const years: Record<number, YearStats> = {}
for (const [year, yearAccumulator] of course.years.entries()) {
if (year > maxYear) continue
years[year] = finalizeYearStats(yearAccumulator)
}
if (Object.keys(years).length === 0) {
continue
}
coursesRecord[code] = {
code,
name: course.name,
isStudyModule: course.isStudyModule,
years,
}
}
return Object.values(coursesRecord)
}

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