Compare commits

...
400 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
432 changed files with 11761 additions and 14192 deletions
+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@v5
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@v5
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@v6
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@v6
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
+16 -25
View File
@@ -1,26 +1,19 @@
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/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -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@v5
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@v5
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@v6
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"
}
-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
},
+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')
+1 -1
View File
@@ -12,7 +12,7 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.cs('history-search').click().type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
+124 -96
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,7 +269,7 @@ 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()
@@ -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],
@@ -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')
})
+4 -4
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()
+1 -1
View File
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
+3 -3
View File
@@ -114,16 +114,16 @@ describe('Study guidance group tests', () => {
})
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
cy.cs('Credit accumulation (for 3 students)').click()
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
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('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
})
it.skip('students table has the correct tabs', () => {
-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 -13
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
+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'),
}
+30 -3966
View File
File diff suppressed because it is too large Load Diff
+7 -11
View File
@@ -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"]
+889 -901
View File
File diff suppressed because it is too large Load Diff
+12 -8
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,18 +19,17 @@
"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",
"vite": "^7.1.12",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
@@ -36,9 +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": "^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'
+5 -37
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 } from '../types'
import { requiredGroup } from '../config'
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -25,7 +24,7 @@ type Headers = {
'x-show-as-user'?: string
}
const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction) => {
export default async (req: Request, _: Response, next: NextFunction) => {
const {
'shib-session-id': sessionId,
'x-show-as-user': showAsUser,
@@ -61,7 +60,7 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
const user =
showAsUser && specialGroup?.superAdmin
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
: await getUserToska({
: await getUser({
username,
name: name!,
email: email!,
@@ -80,34 +79,3 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
next()
}
const fdUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
const { remote_user: remoteUser, 'x-show-as-user': showAsUser } = req.headers
if (!remoteUser) {
throw new ApplicationError('Not enough data in request headers, remote_user was missing', 403, { configLogoutUrl })
}
let user: FormattedUser | null
// getMockedUser in production requires the superAdmin role, which is only available
// via iamGroups, so it's now only implemented for the dev environment
if (showAsUser && isDev) {
user = await getMockedUserFd({ userToMock: showAsUser as string, mockedBy: remoteUser as string })
} else {
user = await getUserFd({ username: remoteUser as string })
}
if (!user) {
throw new ApplicationError(`Could not grant access with the eppn ${remoteUser}.`, 403, { configLogoutUrl })
}
req.user = user
req.logoutUrl = configLogoutUrl
next()
}
const currentUserMiddleware = serviceProvider === 'toska' ? toskaUserMiddleware : fdUserMiddleware
export default currentUserMiddleware
+1 -7
View File
@@ -1,10 +1,4 @@
import headersMiddleware from 'unfuck-utf8-headers-middleware'
import { serviceProvider } from '../config'
const headersToska = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
const headersOther = ['remote_user']
const headers = serviceProvider === 'toska' ? headersToska : headersOther
const headers = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
export default headersMiddleware(headers)
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
@Column(DataType.JSONB)
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@Column(DataType.JSONB)
declare transferInfo: SISStudyRight['transferInfo']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
declare credits: SISStudyRight['credits']
+3
View File
@@ -73,6 +73,9 @@ export class CourseModel extends Model implements Course {
@Column(DataType.JSONB)
declare substitutions: Course['substitutions']
@Column(DataType.JSONB)
declare substitution_groups: Course['substitution_groups']
@Column(DataType.STRING)
declare course_unit_type: Course['course_unit_type']
+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()
+12 -32
View File
@@ -10,9 +10,8 @@ import {
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { uniq } from '@oodikone/shared/util'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
@@ -20,49 +19,30 @@ const router = Router()
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 = uniq(
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
)
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
const mergedCourses = new Map<string, CourseWithSubsId>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
if (!groupId) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Object.values(mergedCourses) })
res.json({ courses: Array.from(mergedCourses.values()) })
}
)
+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,
})
+127 -101
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 {
@@ -16,28 +15,101 @@ import {
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 { 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],
@@ -84,32 +152,18 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
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,58 +198,26 @@ router.get<
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
const studentsUserCanAccess = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
? new Set(studentNumbers)
: new Set(allStudentsUserCanAccess)
const studyRights = []
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
// FIXME no any type
const result: any = await statisticsOf(studentNumbers, studyRights, tagMap)
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
? await getCourseProvidersForCourses(coursecodes)
: []
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
const processed = obfuscateStuff({
result,
const studentsUserCanAccess =
hasFullAccessToStudentData(roles) || found
? new Set(studentNumbers)
: new Set(intersection(studentNumbers, 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,
studyplans: [],
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)
})
// Used in custom population and single study guidance groups
@@ -233,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,
})
}
)
+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'
+2 -4
View File
@@ -1,7 +1,6 @@
import { Request, Response, Router } from 'express'
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
@@ -12,8 +11,7 @@ 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: {
@@ -58,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 { 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) => {
+105 -146
View File
@@ -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 => !getOpenUniCourseCode(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,138 +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 [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
if (!thereAreCriteria) return criteriaChecked
const startDateFromISO = new Date(startDate)
const criteriaCoursesBySubstitutionMap = new Map<string, string>()
for (const [courseCode, substitutionCodes] of Object.entries(criteria.allCourses)) {
criteriaCoursesBySubstitutionMap.set(courseCode, courseCode)
for (const substitutionCode of substitutionCodes) {
criteriaCoursesBySubstitutionMap.set(substitutionCode, courseCode)
}
}
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 }) => !!criteriaCoursesBySubstitutionMap.get(course_code))
.filter(({ credittypecode }) => CreditModel.passed({ credittypecode }) || CreditModel.improved({ credittypecode }))
.forEach(course => {
const courseDate = new Date(course.date)
const correctCode = criteriaCoursesBySubstitutionMap.get(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 = criteriaCoursesBySubstitutionMap.get(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,
@@ -199,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,
}
}
@@ -79,6 +79,7 @@ type StudentPersonalData = Pick<
| 'gender_code'
| 'birthdate'
| 'sis_person_id'
| 'hasPersonalIdentityCode'
>
type StudentStudyRightElement = Pick<
@@ -125,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
'gender_code',
'birthdate',
'sis_person_id',
'hasPersonalIdentityCode',
],
include: [
{
@@ -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 { 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[]
@@ -77,203 +76,11 @@ export const getOptionsForStudents = (
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 courseSubMap = new Map(courses.map(({ code, name, substitutions }) => [code, { name, substitutions }]))
return Array.from(coursestats.entries()).map(([code, { attempts, enrollments, grades, students, stats }]) => {
const courseMapObj = courseSubMap.get(code)
return {
course: {
code,
name: courseMapObj?.name ?? { en: code },
substitutions: courseMapObj?.substitutions ?? [],
},
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,
}
})
}
@@ -10,7 +10,7 @@ import {
getCredits,
getStudyRightElementsForStudyRight,
} from './getStudentData'
import { getOptionsForStudents, parseCourseData } from './shared'
import { getCourses, getOptionsForStudents } from './shared'
export type OptimizedStatisticsQuery = {
userId: string
@@ -45,43 +45,22 @@ export const statisticsOf = async (
getStudyRightElementsForStudyRight(studentNumbers, code),
])
const studentStartingYears = new Map(
students.map(({ studentnumber, studyRights }) => [
studentnumber,
studyRights
?.flatMap(({ studyRightElements }) => studyRightElements)
.find(element => element.code === code)
?.startDate.getFullYear() ?? +mockedStartDate,
])
)
const creditsAndEnrollmentsByStudent = new Map<string, [AnonymousCredit[], AnonymousEnrollment[]]>(
studentNumbers.map(n => [n, [[], []]])
)
for (const credit of credits) {
const { student_studentnumber, ...rest } = credit
creditsAndEnrollmentsByStudent.get(student_studentnumber)![0].push(rest)
}
for (const enrollment of enrollments) {
const { studentnumber, ...rest } = enrollment
creditsAndEnrollmentsByStudent.get(studentnumber)![1].push(rest)
}
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 optionData = getOptionsForStudents(studyRightElementsForStudyRight, degreeProgrammeType)
const formattedStudents = students.map(student => {
const tags = tagMap.get(student.studentnumber) ?? []
const options = optionData.get(student.studentnumber)
const [credits, enrollments] = creditsAndEnrollmentsByStudent.get(student.studentnumber)!
return formatStudentForAPI(code, mockedStartDate, student, tags, credits, enrollments, options, criteria)
return formatStudentForAPI(code, mockedStartDate, student, tags, options)
})
return {
coursestatistics: formattedCoursestats,
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 () => {
@@ -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 { getOpenUniCourseCode } 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 = getOpenUniCourseCode(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)
}
@@ -0,0 +1,95 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { CreditModel, EnrollmentModel } from '../../models'
import { createArrayOfCourses } from '../languageCenterData'
import { getAllProgrammeCourses } from '.'
type Attempt = {
studentNumber: string
courseCode: string
completed: boolean
date: Date
semestercode: number
enrolled?: boolean
}
export const getStudyProgrammeStatsForColorizedCoursesTable = async (studyProgramme: string) => {
const courses = await getAllProgrammeCourses(mapToProviders([studyProgramme])[0])
const autumnSemester2017 = 135
const courseCodes = courses.map(course => course.code)
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,
})
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,
})
const studentList = new Set<string>()
const attemptsByStudents = {} as Record<string, Attempt[]>
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,
})
})
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 }
}
@@ -1,5 +1,6 @@
import dayjs from 'dayjs'
import { indexOf, orderBy } from 'lodash'
import dayjs, { Dayjs } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { indexOf, orderBy } from 'lodash-es'
import {
GraduationStatistics,
@@ -9,10 +10,15 @@ import {
DegreeProgrammeType,
ExtentCode,
Phase,
EnrollmentType,
TransferInfo,
} from '@oodikone/shared/types'
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { mapToProviders } from '@oodikone/shared/util'
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import logger from '../../util/logger'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
@@ -31,6 +37,8 @@ import {
} from './studyProgrammeHelpers'
import { getStudyRightsInProgramme } from './studyRightFinders'
dayjs.extend(isSameOrAfter)
/**
* For bachelor + master study right combo. This rules out master's programmes that don't
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
@@ -44,33 +52,65 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
return minimumCredits !== null && minimumCredits <= 180
}
const calculateAbsenceInMonths = (
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
startDate: Date,
endDate: Date
) => {
if (endDate < absence.startdate || absence.enddate < startDate) {
return 0
}
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
}
/**
* Absence rules:
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
* 2a. Student can have 2 non-statutory absences
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
*
* Additional _non-statutory_ absences will consume semesters
*
* @returns duration of studies in SEMESTERS (not months!)
*/
export const calculateDurationOfStudies = (
startDate: Date,
graduationDate: Date,
semesterEnrollments: SemesterEnrollment[],
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
transferInfo?: TransferInfo | null
) => {
const semestersWithStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
const monthsToSubtract = semestersWithStatutoryAbsence.reduce(
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
0
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
dayjs('2015-08-01'),
'day'
)
? 4
: 2
/** A semester ending and the next one starting overlap, this takes care of that */
const getCorrectSemester = (date: Dayjs) =>
Object.values(semesters).find(
({ startdate: semesterStart, enddate: semesterEnd }) =>
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
)?.semestercode
const startSemester = getCorrectSemester(dayjs(startDate))
const endSemester = getCorrectSemester(dayjs(graduationDate))
if (!startSemester || !endSemester) {
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
return -1
}
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
const semestersWithStatutoryAbsence =
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
const semestersWithNonStatutoryAbsence = Math.min(
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.length + (transferInfo?.usedAbsenceTerms ?? 0),
allowedNonStatutoryAbsences
)
return (
(transferInfo?.usedTerms ?? 0) +
endSemester -
startSemester -
semestersWithStatutoryAbsence -
semestersWithNonStatutoryAbsence +
1
)
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
}
export type GraduationTimes = {
@@ -169,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
const graduationDate = correctStudyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
const graduationYear = defineYear(graduationDate, isAcademicYear)
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
@@ -184,8 +230,8 @@ const getGraduationTimeAndThesisWriterStats = async ({
}
const goal = await getGoal(studyProgramme)
const times: GraduationTimes = { medians: [], goal }
const comboTimes: GraduationTimes = { medians: [], goal: goal + 36 }
const times: ChartGraduationTimes = { medians: [], goal }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
for (const year of years.toReversed()) {
const median = getMedian(graduationTimes[year])
@@ -193,18 +239,18 @@ const getGraduationTimeAndThesisWriterStats = async ({
times.medians.push({
y: median,
amount: graduationTimes[year].length,
name: year,
name: year.toString(),
statistics,
times: [...graduationTimes[year]],
})
if (doCombo) {
const median = getMedian(graduationTimesCombo[year])
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
comboTimes.medians.push({
y: median,
amount: graduationTimesCombo[year].length,
name: year,
name: year.toString(),
statistics,
times: [...graduationTimesCombo[year]],
})
@@ -217,12 +263,16 @@ const formatStats = (stats: Record<string, ProgrammeWithYears>, years: Array<str
const tableStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => [p.code, getId(p.code), p.name, ...years.map(year => p[year])])
.sort((a, b) => sortByProgrammeCode(a[0] as string, b[0] as string))
.sort((a, b) =>
sortByProgrammeCode(a[0] as string, b[0] as string)
) as ProgrammeGraduationStats['programmesBeforeOrAfterTableStats']
const graphStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => ({ name: p.name, code: p.code, data: years.map(year => p[year]) }))
.sort((a, b) => sortByProgrammeCode(a.code, b.code))
.sort((a, b) =>
sortByProgrammeCode(a.code, b.code)
) as ProgrammeGraduationStats['programmesBeforeOrAfterGraphStats']
return { tableStats, graphStats }
}
@@ -267,7 +317,9 @@ const getProgrammesBeforeStarting = async ({
if (!includeAllSpecials && hasTransferred.some(fromOrTo => fromOrTo === true)) return acc
const startDateInProgramme = phase2Programmes.find(elem => elem.code === studyProgramme)?.startDate
if (!startDateInProgramme) return acc
if (!startDateInProgramme) {
return acc
}
;(acc[latestPhase1Programme.code][defineYear(startDateInProgramme, isAcademicYear)] as number) += 1
return acc
}, {})
@@ -331,7 +383,7 @@ export const getGraduationStatsForStudyTrack = async ({
studyProgramme: string
combinedProgramme?: string
settings: { isAcademicYear: boolean; includeAllSpecials: boolean }
}) => {
}): Promise<ProgrammeGraduationStats> => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear) as number[]
@@ -355,7 +407,7 @@ export const getGraduationStatsForStudyTrack = async ({
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles =
studyProgramme.includes('LIS') || studyProgramme.includes('T')
studyProgramme.startsWith('LIS') || studyProgramme.startsWith('T')
? ['', 'Graduated']
: ['', 'Graduated', 'Wrote thesis']
const combinedTitles = [
@@ -365,6 +417,7 @@ export const getGraduationStatsForStudyTrack = async ({
'Graduated licentiate',
'Wrote thesis licentiate',
]
const programmesBeforeOrAfterTitles = ['Code', 'Id', 'Programme', ...years]
const tableStatsDefault = combinedProgramme
? reversedYears.map(year => [
@@ -376,10 +429,9 @@ export const getGraduationStatsForStudyTrack = async ({
])
: reversedYears.map(year => [year, graduated.tableStats[year], graduationTimeStats.thesisTableStats[year]])
const tableStats =
studyProgramme.includes('LIS') || studyProgramme.includes('T')
? reversedYears.map(year => [year, graduated.tableStats[year]])
: tableStatsDefault
const tableStats = studyProgramme.startsWith('LIS')
? reversedYears.map(year => [year, graduated.tableStats[year]])
: tableStatsDefault
const graphStats = combinedProgramme
? [
@@ -399,7 +451,7 @@ export const getGraduationStatsForStudyTrack = async ({
tableStats,
titles: combinedProgramme ? combinedTitles : titles,
graphStats:
studyProgramme.includes('LIS') || studyProgramme.includes('T')
studyProgramme.startsWith('LIS') || studyProgramme.startsWith('T')
? [{ name: 'Graduated students', data: graduated.graphStats }]
: graphStats,
graduationTimes: graduationTimeStats.times,
@@ -1,12 +1,12 @@
import { orderBy } from 'lodash'
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel } from '../../models'
import { getDegreeProgrammeType } from '../../util'
import { GraduationTarget } from '../graduationHelpers'
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
@@ -45,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
}
export const getMedian = (values: number[]) => {
if (values.length === 0) {
return 0
if (!values.length) return 0
// compareFn mandatory
const sorted = values.toSorted((a, b) => a - b)
const half = Math.floor(sorted.length / 2)
if (sorted.length % 2) {
return sorted[half]
}
values.sort((a, b) => a - b)
const half = Math.floor(values.length / 2)
if (values.length % 2) {
return values[half]
}
return (values[half - 1] + values[half]) / 2.0
return (sorted[half - 1] + sorted[half]) / 2.0
}
export function defineYear(date: Date, isAcademicYear: true): string
@@ -97,17 +98,11 @@ export const getThesisType = async (studyProgramme: string) => {
const degreeProgrammeType = await getDegreeProgrammeType(studyProgramme)
if (degreeProgrammeType === DegreeProgrammeType.MASTER) {
const mastersThesisTypes = ['urn:code:course-unit-type:masters-thesis']
if (serviceProvider !== 'toska') {
mastersThesisTypes.push('urn:code:course-unit-type:amk-masters-thesis')
}
return mastersThesisTypes
}
if (degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
const bachelorsThesisTypes = ['urn:code:course-unit-type:bachelors-thesis']
if (serviceProvider !== 'toska') {
bachelorsThesisTypes.push('urn:code:course-unit-type:amk-bachelors-thesis')
}
return bachelorsThesisTypes
}
@@ -157,7 +152,7 @@ export const tableTitles = {
'Transferred credits',
],
},
studytracksStart: ['', 'All', 'Started\nstudying', 'Present', 'Absent', 'Inactive'],
studytracksStart: ['', 'All', 'Started\nstudying', 'Present', 'Absent', 'Passive'],
studytracksBasic: ['Graduated'],
studytracksCombined: {
licentiate: ['Graduated bachelor', 'Graduated licentiate'],
@@ -167,8 +162,7 @@ export const tableTitles = {
} as const
export const getId = (code: string) => {
if (serviceProvider !== 'fd') return code in programmeCodes ? programmeCodes[code as keyof typeof programmeCodes] : ''
return code
return code in programmeCodes ? programmeCodes[code as keyof typeof programmeCodes] : ''
}
export const getGoal = async (programme?: string) => {
@@ -191,9 +185,9 @@ export const getGoal = async (programme?: string) => {
return 0
}
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
return 48
return GraduationTarget.FOUR_YEARS
}
return (minimumCredits / 60) * 12
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
}
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
@@ -42,23 +42,19 @@ export const updateBasicView = async (code: string, combinedProgramme: string) =
}
export const updateStudyTrackView = async (code: string, combinedProgramme: string) => {
const graduatedOptions = ['GRADUATED_INCLUDED', 'GRADUATED_EXCLUDED'] as const
const specialGroupOptions = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED'] as const
const studyRightsOfProgramme = await getStudyRightsInProgramme(code, false, true)
for (const graduated of graduatedOptions) {
for (const specialGroup of specialGroupOptions) {
const stats = await getStudyTrackStatsForStudyProgramme({
studyProgramme: code,
combinedProgramme,
settings: {
specialGroups: specialGroup === 'SPECIAL_INCLUDED',
graduated: graduated === 'GRADUATED_INCLUDED',
},
studyRightsOfProgramme,
})
await setStudyTrackStats(stats, graduated, specialGroup)
}
for (const specialGroup of specialGroupOptions) {
const stats = await getStudyTrackStatsForStudyProgramme({
studyProgramme: code,
combinedProgramme,
settings: {
specialGroups: specialGroup === 'SPECIAL_INCLUDED',
},
studyRightsOfProgramme,
})
await setStudyTrackStats(stats, specialGroup)
}
return 'OK'
@@ -1,5 +1,6 @@
import { col, fn, Includeable, Op } from 'sequelize'
import { SISStudyRight } from '@oodikone/shared/models'
import { Name, CreditTypeCode } from '@oodikone/shared/types'
import { StudentModel, SISStudyRightModel, SISStudyRightElementModel, CreditModel } from '../../models'
@@ -13,15 +14,18 @@ export const getStudyRightsInProgramme = async (
where.graduated = true
}
const studyRights = await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
})
const studyRightIds = (
await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
raw: true,
})
).map(sr => sr.id)
const include: Includeable[] = [
{
@@ -53,11 +57,11 @@ export const getStudyRightsInProgramme = async (
return (
await SISStudyRightModel.findAll({
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
include,
where: {
id: {
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
[Op.in]: studyRightIds,
},
},
})
@@ -85,8 +89,12 @@ export const getStudyTracksForProgramme = async (studyProgramme: string) => {
)
}
export const getSISStudyRightsOfStudents = async (studentNumbers: string[]) => {
return (
export const getSISStudyRightsOfStudents = async (
studentNumbers: string[]
): Promise<
Pick<SISStudyRight, 'id' | 'studentNumber' | 'extentCode' | 'semesterEnrollments' | 'startDate' | 'endDate'>[]
> =>
(
await SISStudyRightModel.findAll({
where: {
studentNumber: {
@@ -96,4 +104,3 @@ export const getSISStudyRightsOfStudents = async (studentNumbers: string[]) => {
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
})
).map(studyRight => studyRight.toJSON())
}
@@ -10,7 +10,7 @@ import {
SemesterEnrollment,
} from '@oodikone/shared/types'
import { createLocaleComparator, keysOf } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
combinedProgramme?: string
) => {
const goal = await getGoal(combinedProgramme ?? studyProgramme)
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
const calculateGraduationTimes = (
graduationTimes: Record<string, Record<string, number[]>>,
@@ -82,7 +82,7 @@ const getEmptyYear = () => ({
started: 0,
present: 0,
absent: 0,
inactive: 0,
passive: 0,
graduated: 0,
graduatedCombinedProgramme: 0,
male: 0,
@@ -128,8 +128,8 @@ const combineStats = (
getPercentage(yearStats.present, yearStats.all),
yearStats.absent,
getPercentage(yearStats.absent, yearStats.all),
yearStats.inactive,
getPercentage(yearStats.inactive, yearStats.all),
yearStats.passive,
getPercentage(yearStats.passive, yearStats.all),
yearStats.graduated,
getPercentage(yearStats.graduated, yearStats.all),
yearStats.male,
@@ -171,7 +171,6 @@ const combineStats = (
const getMainStatsByTrackAndYear = async (
years: string[],
studyProgramme: string,
includeGraduated: boolean,
includeAllSpecials: boolean,
doCombo: boolean,
studyRightsOfProgramme: Array<SISStudyRight>,
@@ -244,7 +243,7 @@ const getMainStatsByTrackAndYear = async (
} else if (currentSemesterStatus === EnrollmentType.ABSENT) {
yearlyStats[year][programmeOrStudyTrack].absent += 1
} else {
yearlyStats[year][programmeOrStudyTrack].inactive += 1
yearlyStats[year][programmeOrStudyTrack].passive += 1
}
}
@@ -277,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
const graduationDate = studyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
if (countAsBachelorMaster) {
if (!graduationTimesCombo[programmeOrStudyTrack]) {
@@ -314,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
bachelorStartDate,
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
studyRight.semesterEnrollments,
semesters
semesters,
studyRight.transferInfo
)
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
@@ -338,9 +344,6 @@ const getMainStatsByTrackAndYear = async (
if (!studyRightElement) {
continue
}
if (!includeGraduated && studyRightElement.graduated) {
continue
}
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
studyRight,
@@ -378,15 +381,18 @@ const getMainStatsByTrackAndYear = async (
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
)
// TODO: FIXME: This could be written better
if (!studyRightElement.graduated) {
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
creditCountsCombo[defineYear(startedInBachelor, true)].push(
getCreditCount(studyRight.student.credits, startedInBachelor)
)
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
}
} else {
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
}
}
@@ -410,6 +416,11 @@ const getMainStatsByTrackAndYear = async (
}
}
const graduatedCount: Record<string, number> = {}
years.forEach(year => {
graduatedCount[year] = yearlyStats[year] ? yearlyStats[year][studyProgramme].graduated : 0
})
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
years,
yearlyStats,
@@ -438,6 +449,7 @@ const getMainStatsByTrackAndYear = async (
otherCountriesCount,
creditCounts,
creditCountsCombo,
graduatedCount,
graduationTimes: finalGraduationTimes,
graduationTimesSecondProg: finalCombinedGraduationTimes,
}
@@ -453,7 +465,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
}: {
studyProgramme: string
combinedProgramme?: string
settings: { graduated: boolean; specialGroups: boolean }
settings: { specialGroups: boolean }
studyRightsOfProgramme: SISStudyRight[]
}) => {
const isAcademicYear = true
@@ -467,7 +479,6 @@ export const getStudyTrackStatsForStudyProgramme = async ({
const stats = await getMainStatsByTrackAndYear(
years,
studyProgramme,
settings.graduated,
settings.specialGroups,
doCombo,
studyRightsOfProgramme,
@@ -484,10 +495,10 @@ export const getStudyTrackStatsForStudyProgramme = async ({
creditCounts: stats.creditCounts,
creditCountsCombo: stats.creditCountsCombo,
doCombo,
graduatedCount: stats.graduatedCount,
graduationTimes: stats.graduationTimes,
graduationTimesSecondProg: stats.graduationTimesSecondProg,
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
includeGraduated: settings.graduated,
mainStatsByTrack: stats.mainStatsByTrack,
mainStatsByYear: stats.mainStatsByYear,
otherCountriesCount: stats.otherCountriesCount,
+6 -38
View File
@@ -1,9 +1,8 @@
import { isEqual, keyBy, omit, uniq } from 'lodash'
import { isEqual, keyBy, omit, uniq } from 'lodash-es'
import { LRUCache } from 'lru-cache'
import { User } from '@oodikone/shared/models/user'
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { UserModel } from '../models/user'
@@ -11,13 +10,12 @@ import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
import mami from '../util/mami'
import { sendNotificationAboutNewUser } from './mailService'
import { getSisuAuthData, personSearchQuery, getGraphqlData } from './oriProvider'
import { getStudentnumbersByElementdetails } from './students'
import { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } from './studyGuidanceGroups'
const userAccessUtils = serviceProvider === 'toska' ? jami : mami
const userAccessUtils = jami
const { getAllUserAccess, getUserIams, getUserIamAccess } = userAccessUtils
const courseStatisticsGroup = 'grp-oodikone-basic-users'
@@ -68,7 +66,7 @@ export const updateUser = async (username: string, fields: Partial<User>) => {
userDataCache.delete(username)
}
const getIamBasedRights = (iamAccess: IamAccess): DetailedProgrammeRights[] => {
const getIamBasedRights = (iamAccess: IamAccess | undefined): DetailedProgrammeRights[] => {
if (!iamAccess) {
return []
}
@@ -89,8 +87,8 @@ const getUserProgrammesRights = (userProgrammes: string[]): DetailedProgrammeRig
}
const getStudyProgrammeRights = (
iamAccess: IamAccess,
specialGroup: Record<string, boolean>,
iamAccess: IamAccess | undefined,
specialGroup: Record<string, boolean> | undefined,
userProgrammes: string[]
): DetailedProgrammeRights[] => {
const hasFullSisuAccess = specialGroup?.fullSisuAccess
@@ -233,15 +231,7 @@ export const getMockedUser = async ({ userToMock, mockedBy }: { userToMock: stri
return mockedUser
}
export const getMockedUserFd = async ({ userToMock, mockedBy }: { userToMock: string; mockedBy: string }) => {
const mockedByFromDb = (await UserModel.findOne({ where: { username: mockedBy } }))?.toJSON()
if (!mockedByFromDb) {
return null
}
return await getMockedUser({ userToMock, mockedBy })
}
export const getUserToska = async ({
export const getUser = async ({
username,
name,
email,
@@ -283,28 +273,6 @@ export const getUserToska = async ({
return user
}
export const getUserFd = async ({ username }: { username: string }) => {
if (userDataCache.has(username)) {
return userDataCache.get(username) as FormattedUser
}
const userFromDbOrm = await UserModel.findOne({ where: { username } })
if (!userFromDbOrm) {
return null
}
userFromDbOrm.lastLogin = new Date()
await userFromDbOrm.save()
const userFromDb = userFromDbOrm.toJSON()
const detailedProgrammeRights = getStudyProgrammeRights({}, {}, userFromDb.programmeRights)
const user = await formatUser({ ...userFromDb, iamGroups: [], detailedProgrammeRights })
userDataCache.set(username, user)
return user
}
export const addNewUser = async user => {
const name = user.first_name.concat(' ', user.last_name)
try {
+5 -4
View File
@@ -1,9 +1,10 @@
import type { GenericApplicationError } from '@oodikone/shared/routes'
export class ApplicationError extends Error {
status: number
extra: Record<string, unknown>
constructor(message = 'Something went wrong. Please try again.', status = 500, extra = {}) {
constructor(message, status = 500, extra = {}) {
super(message)
Error.captureStackTrace(this, this.constructor)
@@ -13,9 +14,9 @@ export class ApplicationError extends Error {
this.extra = extra
}
toJSON() {
toJSON(): GenericApplicationError {
return {
message: this.message,
error: this.message,
...this.extra,
}
}
+5 -29
View File
@@ -1,42 +1,18 @@
import axios from 'axios'
import { importerDbApiPassword, importerDbApiUser, importerToken, importerUrl, serviceProvider } from '../config'
import { importerToken, importerUrl } from '../config'
import logger from './logger'
const createBasicAuthHeader = () => {
const auth = `${importerDbApiUser}:${importerDbApiPassword}`
const token = Buffer.from(auth).toString('base64')
return {
Authorization: `Basic ${token}`,
}
}
const toskaImporterClient = axios.create({
const importerClient = axios.create({
headers: { token: importerToken },
baseURL: importerUrl,
timeout: 10000,
})
const fdImporterClient = axios.create({
headers: createBasicAuthHeader(),
baseURL: importerUrl,
})
const missingBasicAuthCredentials = !importerDbApiUser || !importerDbApiPassword
const getFdImporterClient = () => {
if (missingBasicAuthCredentials) {
logger.error("Basic auth credentials not set, can't return client!")
return null
}
return fdImporterClient
}
const getToskaImporterClient = () => {
export const getImporterClient = () => {
if (!importerToken) {
logger.error("Importer token not set, can't return client!")
return null
}
return toskaImporterClient
return importerClient
}
export const getImporterClient = serviceProvider === 'toska' ? getToskaImporterClient : getFdImporterClient

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