Compare commits

...
224 Commits
Author SHA1 Message Date
Santeri Rajamäki 7531a9def4 [Frontend] Fix curriculumPeriod filter 2025-06-17 10:16:47 +03:00
Santeri Rajamäki f5b81d73ac [Frontend] Fix CourseStatistics resetState on empty URL params 2025-06-13 17:08:07 +03:00
Santeri Rajamäki 4544d1cdec [Frontend] Small optimizations to course filter 2025-06-12 22:23:02 +03:00
Santeri Rajamäki 072360763f Update cypress a bit more 2025-06-12 19:44:10 +03:00
Santeri Rajamäki 639858f874 Update cypress to the new reality 2025-06-12 19:27:11 +03:00
Santeri Rajamäki 92f3e67293 [Frontend] Make filters render faster
Huge performance gains!?
2025-06-12 18:26:39 +03:00
Santeri Rajamäki 983390040e [Frontend] Massively improve render times for some filter components
This tweak improves the render time of FilterTray by 5 fold.
2025-06-12 17:05:40 +03:00
Santeri Rajamäki 277b97327e Update CI serve version to 14.x
This hasn't been updated and was hardcoded to 12.0.0.
The is no comment on why it *shouldn't* be updated.
2025-06-12 15:16:47 +03:00
Santeri Rajamäki 3dab357396 Fix skill issue 2025-06-12 15:15:25 +03:00
Santeri Rajamäki a39648f380 [Frontend] Offload cheap calculatoin to child components
This avoids some prop drilling and has positive performance impact at least in development.
2025-06-12 15:03:16 +03:00
Santeri Rajamäki ccb1865853 Update sentry to version 9.28
Closes #4830
2025-06-11 14:00:01 +03:00
Santeri Rajamäki 81da089798 Update scheduler to version 0.26
Closes #4725
2025-06-11 13:56:41 +03:00
Santeri Rajamäki 785b7d5d74 Update minor version of dependencies
Closes #4762
Closes #4783
2025-06-10 17:51:07 +03:00
Santeri Rajamäki aca70d5442 Revert optimistic eslint fixes 2025-06-10 15:29:47 +03:00
Santeri Rajamäki 41d93c9b43 Fix lint errors triggered by linter updates 2025-06-10 14:33:03 +03:00
Santeri Rajamäki 835c9e580b Update lint tools 2025-06-09 16:38:39 +03:00
Santeri Rajamäki 9c9f317ecb Update eslint-import-resolver-typescript to version 4.4
Cloess #4769
2025-06-09 16:19:08 +03:00
Santeri Rajamäki 3ee6758c72 Update eslint-plugin-cypress to version 5.x
Closes #4826
2025-06-09 16:16:49 +03:00
Santeri Rajamäki 88764f83de Update react-router to version 7.5
Closes #4794
2025-06-09 14:54:57 +03:00
Santeri Rajamäki e8908d28fc Update axios to version 1.9
Closes #4791
2025-06-09 14:54:02 +03:00
Santeri Rajamäki a6d7da315f Update sentry monorepo to versoin 9.x
Closes #4736
2025-06-09 14:45:04 +03:00
santeri0200andGitHub e72e0bb5ad Merge pull request #4821 from UniversityOfHelsinkiCS/filtering
[Frontend] Filtering cleanup
2025-06-09 13:43:22 +03:00
Santeri Rajamäki 15b0286eda [Frontend] Force failure-state instead of crash with faulty courses 2025-06-09 13:32:55 +03:00
Santeri Rajamäki 1104483cac [Frontend] Simplified filter action and selector structure 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 604bcf4998 [Frontend] Remove actions and selectors from filters
Actions and selectors are still attached to the filter factories
2025-06-09 13:32:42 +03:00
Santeri Rajamäki cc839a9e14 [Frontend] Removed extra parameter from filter calls 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 2a076ef6e8 [Frontend] Täsäfy rest of the FilterView 2025-06-09 13:32:42 +03:00
Santeri Rajamäki b05e1c22e5 [Frontend] Minor improvments to filters and filter context 2025-06-09 13:32:26 +03:00
Santeri Rajamäki b9800c857f [Frontend] Converted all filter components to render functions
This should be non-breaking as all the components were functions anyways
2025-06-09 13:31:33 +03:00
Santeri Rajamäki 6d535bf4bb [Frontend] Täsäfy filters
Added FilterTrayProps to FilterTray.tsx
Fixed Filter context typings
Reworked createFilter types
Also reword createFilter to work a little better
2025-06-09 13:30:55 +03:00
joonas-a 714f85d5c0 [Updater-worker] fix winston-sentry 2025-06-07 01:38:04 +03:00
joonas-a 01af4cfd2c [Updater-worker] ES6 2025-06-07 01:02:11 +03:00
Santeri Rajamäki fe55b770e9 [Updater] Update umzug to v3
Closes #4610
2025-06-06 15:25:17 +03:00
Santeri Rajamäki 43e4e605d6 [Cypress] Make OpenUni test use pathname and params instead of hardcoded path 2025-06-06 15:09:52 +03:00
Santeri Rajamäki 15a54f1e26 [Frontend] Replace query-string dependency with two typed utility functions 2025-06-06 14:35:00 +03:00
Santeri Rajamäki 631b5be00a Update shared model definition for SISStudyRight model 2025-06-06 12:01:53 +03:00
joonas-a b8d9648b8c [Student statistics] Infer studyright state from validity period if applicable 2025-06-06 01:22:30 +03:00
joonas-a d52562330b [Backend] Add studyright expiration urns to db model 2025-06-06 00:18:51 +03:00
joonas-a f4c9137a05 [Updater] Add study right expiration rule urns 2025-06-05 23:48:46 +03:00
santeri0200andGitHub 9a1802b6b4 [Frontend] Populus optimus (#4824)
* [Frontend] Täsäfy PopulationStatistics

* [Frontend] Reorganized PopulationStatistics

* [Redux] Removed dangling reducer actions left back in 51e5846 and further in history
This commit is 4 years due...

* Convert to RTKQuery for populationStatistics

* Removed useSelector for state.population

* Reset PopulationsStatistics params based on the new query param formatting

* Format semesters to array instead of spreading strings

* Force array formating with single key values

* Stop lying to the users
Progressbar is no more
2025-06-05 16:04:40 +03:00
Santeri Rajamäki fd2d95a832 [Frontend] Format RTKApi query params in Express friendly manner 2025-06-04 19:27:10 +03:00
Joonas Ahovalli dfb557d4e1 hotfix: undefined studyright.... 2025-05-30 16:37:11 +03:00
Joonas Ahovalli 22e2ccf562 [MRT] Adjust default col width + minor changes
Have admissiontype be either value or null, no boolean
Hide email and phone from table, show only in excel
2025-05-30 15:52:57 +03:00
Joonas Ahovalli 68480a7211 [General tab] Add new column for TVEX bitti 2025-05-30 15:25:13 +03:00
Joonas Ahovalli bb4edef070 [Backend] Fetch tvex info along with studyrights 2025-05-30 15:20:04 +03:00
Joonas Ahovalli 6877716468 [Updater] Add tvex info to sis_study_rights 2025-05-30 13:18:47 +03:00
Santeri Rajamäki a21e91fbaa [Updater] added loki 2025-05-30 12:03:36 +03:00
Santeri Rajamäki 62e7de5fef [Backend] added loki 2025-05-28 13:05:33 +03:00
santeri0200andGitHub 785b84b14c Merge pull request #4820 from UniversityOfHelsinkiCS/OOD-89
Edited shared mounting in fd compose
2025-05-28 11:00:06 +03:00
Matti Räsänen e488327fd4 Edited shared mounting in fd compose 2025-05-26 15:09:25 +03:00
Santeri Rajamäki 2642e15479 Fk go back 2025-05-21 19:00:01 +03:00
Santeri Rajamäki 547d1c7d35 [Frontend] Prepare PopulationStatistics for changes 2025-05-21 18:36:38 +03:00
Santeri Rajamäki d57ecb2aa9 [Frontend] MUI the right way
According to MUI minimizing bundle size. Closes #4816
2025-05-21 18:19:04 +03:00
Santeri Rajamäki ef76b0f350 [Frontend] Preliminary FilterView täsäfication
This commit includes the most rudimentary implementation of TS types for the view.
We need more types from backend to advance these files.
2025-05-21 16:08:25 +03:00
Joonas Ahovalli 3ba2bd8d5b [Custom population] indicate if graduated or inactive primary study programme 2025-05-21 13:18:35 +03:00
Santeri Rajamäki 12976a961b [Frontend] Fix deepCopy issue 2025-05-19 22:59:54 +03:00
Santeri Rajamäki 01c6159ffa [Frontend] Make FilterView great again 2025-05-19 22:32:13 +03:00
Santeri Rajamäki a6c1e3891d [Frontend] Remove unused directive to make lint happy again 2025-05-19 22:28:19 +03:00
Santeri Rajamäki 6c4b42c98d [Shared] Add keyBy function to utils 2025-05-19 22:13:34 +03:00
Joonas Ahovalli 10941259a2 [Redux] Implement hooks useAppDispatch and useAppSelector
Allows for more streamlined typing
2025-05-19 22:08:44 +03:00
Joonas Ahovalli b7563cd4ec [Modules tab] Improve typing 2025-05-19 17:11:58 +03:00
Joonas Ahovalli 8d261aae51 Fix typo 2025-05-19 17:10:03 +03:00
Joonas Ahovalli ccf70ed420 [General tab] improve typing 2025-05-19 16:23:36 +03:00
Joonas Ahovalli 20f4bd32f8 [General tab] Fix tags broken 2025-05-16 11:45:55 +03:00
Joonas Ahovalli d62140e97d Add scripts to clean install local/docker/both 2025-05-16 11:43:55 +03:00
Joonas AhovalliandGitHub 0e510f039f Material UI: General tab (#4797)
* [General tab] Create skeleton with MUI

* [General tab] Add logic for columns based on view

* [General tab] Add semesters present column

* [General tab] Fix email column missing data

* [General tab] Fix credit columns

* [Utils] rename and use new date formatter fn

* [Utils] Add improved dateformatter

* [General tab] Fix admin columns and date formatting

* [Utils] Use local time instead of UTC to mimic moment behaviour

* [General tab] Other programmes column visual upgrade

* [General tab] Add study track column

* [General tab] add studyguidancegroups support

* [General tab] Fix credits since x column text with studyguidancegroups

* [General tab] Ditch redundant query result

* [General tab] Fix option col to consider if viewing bsc/msc programme

* [General tab] Fix undefined sisu id

* [General tab] custom population implementation

* [General tab] show more cols in custom population if programme chosen

* [General tab] Hide studytrack if no one has any

* [General tab] fix edge case when no creditssince filter is set

* [General tab] Show more fields on custom population with no associated programme

* [General tab] infobox betternment for custom pop

* [General tab] Course population implementation

* [General tab] Add tooltips and tweak custom pop primary programme col

* [General tab] Add admissiontype to custom pop, add phone number col

* [General tab] Make it work with combined programmes

* [General tab] Cleanup and small qol

* Chore: delete unused components

* [Cypress] fix population statistics test

* [Cypress] fix studyguidancegroup tests

* [General tab] Make tests happy
2025-05-15 13:45:21 +03:00
santeri0200andGitHub 893d3847e2 [Backend] Rework backend models (#4805)
Models are now typed in `shared` and build in `backend` making it possible to use the model types in `frontend` or `shared` definitions.
2025-05-13 14:01:53 +03:00
Santeri Rajamäki 0c46fcdd09 [Backend] Update route typings for courseExclusion 2025-05-10 14:41:18 +03:00
Santeri Rajamäki 4397a542c2 [Backend] Update route typings for completedCoursesSearch 2025-05-10 14:21:14 +03:00
Santeri Rajamäki 25a4975265 [Backend] Updated route typings for changelog 2025-05-10 13:09:49 +03:00
Santeri Rajamäki b89967fb17 [Backend] Updated route typings for tags 2025-05-10 11:29:45 +03:00
Santeri Rajamäki 36ce4f1405 [Backend] Moved CanError to shared 2025-05-10 11:02:05 +03:00
Santeri Rajamäki 71d5849360 Add shared install to "npm run install:local" 2025-05-09 19:32:56 +03:00
Santeri Rajamäki 6f335a3555 Reword .github/actions lint to be ran on all packages instead of named ones 2025-05-09 19:19:58 +03:00
Santeri Rajamäki e7eab4741e [Cleanup] apply app-root changes to files not caught by tests 2025-05-09 19:14:22 +03:00
Santeri Rajamäki 64a67151d6 Avoid using "cd" in install scripts, use "--prefix" instead 2025-05-09 18:41:31 +03:00
santeri0200andGitHub 229d155b7c Promote shared to a package (#4799)
* Promote shared to a package

* Add build script to shared

* Rework env values in Dockerfile

* Rework Dockerfiles

* Remove symlinks from the project
2025-05-09 18:31:33 +03:00
Santeri Rajamäki 42ef2b0245 [Config] Setup stricter isolation between frontend and backend tsconfigs 2025-04-30 02:10:41 +03:00
Santeri Rajamäki 79bfb690b7 [Backend] Further declutter formatStatisticsForApi.ts 2025-04-28 18:01:11 +03:00
Santeri Rajamäki 1b8b71075a [Backend] Add omitKey'd types to optimizedStatisticsOf and formatStudentForAPI 2025-04-28 14:25:12 +03:00
Santeri Rajamäki a77bba0881 [Shared] Added omitKeys shared utility function 2025-04-28 14:25:12 +03:00
Santeri Rajamäki eda8dcac97 [Backend] Optimized *ByStudent iterators 2025-04-28 14:25:12 +03:00
Santeri Rajamäki b0e0bae727 [Backend] renamed getStudentNumbersWithAllStudyRightElements to getStudentNumbersWithStudyRights 2025-04-28 14:25:12 +03:00
Santeri Rajamäki 47d5faec81 [Backend] Displace formatStudentsForApi 2025-04-28 14:25:07 +03:00
Santeri Rajamäki 8f90824d8b [Backend] moved getStudentTags back to getStudentData 2025-04-28 13:58:59 +03:00
Santeri Rajamäki e16f7ddf85 [Backend] removed getStudentsIncludeCoursesBetween function 2025-04-28 13:58:59 +03:00
Santeri Rajamäki 0c02d25257 [Backend] Moved getStudentsIncludeCoursesBetween to getStudentData 2025-04-28 13:58:59 +03:00
Santeri Rajamäki 68484728d2 [Backend] Move around tag logic 2025-04-28 13:58:59 +03:00
joonas-a 1d3fbdf869 Revert change to workflow 2025-04-17 11:10:54 +03:00
Santeri Rajamäki c22b415b85 [Backend] Refactor studentNumbersWithAllElements 2025-04-15 18:46:06 +03:00
Santeri Rajamäki 920c1aac5f [Backend] Move population tag filtering to the query 2025-04-15 17:53:07 +03:00
Joonas Ahovalli 31787e6df0 [Updater] Add default for REDIS_HOST 2025-04-15 16:14:56 +03:00
Joonas Ahovalli 82a4ea9465 [Updater] Try to fix image 2025-04-15 15:41:20 +03:00
santeri0200andGitHub bb7f965299 Merge pull request #4777 from UniversityOfHelsinkiCS/OOD-84-updater-failing-on-programme-module-children
OOD-84: using try catch logic when inserting programme module children [UPDATER-WORKER]
2025-04-15 14:14:09 +03:00
Jan Rundt ff5fac8f0d OOD-84: modern things such as ternary 2025-04-15 13:35:22 +03:00
Santeri Rajamäki cede895135 [Backend] refactor completedCoursesSearch to use a single tryCatch wrapper 2025-04-14 22:09:56 +03:00
Santeri Rajamäki 0ebff9dd39 [Backend] Ignore importerClient call if importerClient is null 2025-04-14 22:00:55 +03:00
Santeri Rajamäki ce94943d54 [Backend] Refactor completedCoursesSearch routes 2025-04-14 17:31:57 +03:00
Santeri Rajamäki 98f388a8a3 [Backend] Refactor completedCoursesSearch route types 2025-04-14 17:19:58 +03:00
Joonas Ahovalli 9b366e2974 [Student matrix] Display latest active study programme over newer inactive one 2025-04-14 16:19:20 +03:00
Joonas Ahovalli 452fb93b2c [Backend] Early return to avoid useless db query 2025-04-14 16:15:27 +03:00
santeri0200andGitHub b9cd6aab7f Merge pull request #4778 from UniversityOfHelsinkiCS/OOD-84-getting-latest-active-organisation
OOD-84: getting latest active organisation [UPDATER-WORKER]
2025-04-14 16:08:58 +03:00
Joonas Ahovalli 42e76e25e6 [Updater] Remove unused code 2025-04-08 15:42:51 +03:00
Joonas Ahovalli 5ae4f80fab [Updater] Fix includedModules of some students
If a student has graduated and moved to a new programme, the study
modules in their previous hops were overwritten by those in the new
programme #4787
2025-04-08 15:34:54 +03:00
Joonas Ahovalli d441f3a6b3 [Updater] Fix all study modules getting labeled as unknown 2025-04-04 17:12:54 +03:00
Joonas Ahovalli d22e746e02 [Updater] Rename module to studyModule to avoid shadowing 2025-04-03 14:03:27 +03:00
Joonas Ahovalli 5b5292d53a [Modules tab] Unify font size 2025-04-03 13:45:53 +03:00
Joonas Ahovalli 12f90192c6 [Modules tab] Make summary row visually consistent if showing student names 2025-04-02 12:35:27 +03:00
Joonas Ahovalli 94d38fecc1 [Class statistics] Add summary rows to modules tab 2025-04-01 16:04:08 +03:00
Santeri Rajamäki 6a649b5f00 [Backend] Refactor customOpenUniSearch route 2025-03-24 22:18:30 +02:00
Jan Rundt ab391472a6 OOD-84: parameter for findBy property 2025-03-24 15:20:18 +02:00
Santeri Rajamäki 267067e83a [Backend] Introduced CanError type for routes 2025-03-24 12:26:42 +02:00
Santeri Rajamäki 17f376a681 [Backend] Refactor users route to use tryCatch 2025-03-24 11:38:47 +02:00
Santeri Rajamäki 9d83a06377 [Backend] Refactor safeJSONParse to use tryCatch 2025-03-24 11:35:10 +02:00
Santeri Rajamäki 0f5c2e3160 [Shared] Introduce error as value tryCatch function 2025-03-24 11:13:43 +02:00
Jan Rundt 364ba0bca3 OOD-84: fixing divergence from master 2025-03-24 10:38:03 +02:00
Jan Rundt 9d5f2a51e3 OOD-84: fixing divergence from master 2025-03-24 10:34:11 +02:00
Jan Rundt d9260c17f8 OOD-84: get latest *active* organisation 2025-03-24 09:07:50 +02:00
Jan Rundt 612cb08689 merge master 2025-03-21 16:21:06 +02:00
Jan Rundt ae90dfa006 OOD-84: using try catch logic when inserting programme module children 2025-03-21 15:11:39 +02:00
Santeri Rajamäki a4635d8d5b [Backend] Refactor getOptionsForStudents 2025-03-21 11:43:42 +02:00
Santeri Rajamäki 2a48dc5a77 [Backend] Move parseQueryParams out of shared 2025-03-21 11:32:19 +02:00
Santeri Rajamäki 778807ecd0 [Backend] Redefine populationstatistics studyRights 2025-03-20 16:22:37 +02:00
Santeri Rajamäki eca6b643bd [Backend] Refactored criteria and options of formatStatisticsForApi 2025-03-20 15:44:39 +02:00
Santeri Rajamäki cde59177ed [Chore] Use corresponding tye for MedianTimeBarChart data 2025-03-20 14:02:22 +02:00
Santeri Rajamäki c229e4f030 [Refactor] Seed graduationStatsResponse to a root type 2025-03-20 12:57:24 +02:00
Riku Rauhala cff0cbef04 [PopulationStudents] Fix an awkward typo 2025-03-20 12:37:50 +02:00
Riku RauhalaandGitHub c0a4e9d8e5 Merge pull request #4774 from UniversityOfHelsinkiCS/OOD-88-only-disable-role-alternatives-for-hy
OOD-88 & OOD-87: disable role alternatives only for HY & avoid stack exceeded in updater
2025-03-20 11:48:59 +02:00
Santeri Rajamäki 3cd2bcefea [Backend] Move formatStudentsForApi into a self-contained file 2025-03-19 22:37:59 +02:00
Riku RauhalaandGitHub 4b40011f6b Merge pull request #4773 from UniversityOfHelsinkiCS/material-ui-study-programme-programme-courses-by-credit-type
Material UI: Study programme overview - Programme courses tab - By credit type
2025-03-19 20:18:00 +02:00
Riku Rauhala b8c147ceec [Study programme] Update tests 2025-03-19 19:50:24 +02:00
Santeri Rajamäki 5d169b3321 [Backend] Added rest of the types for getStudentsIncludeCoursesBetween 2025-03-19 19:01:24 +02:00
Jan Rundt 1b86aada0e OOD-87: avoiding call stack exceeded by avoiding loop in data 2025-03-19 16:56:14 +02:00
Jan Rundt f3b027a609 OOD-88: lint fix 2025-03-19 14:51:27 +02:00
Riku Rauhala 3797fed1a9 [Study programme] Extract BySemesterTab 2025-03-19 14:40:16 +02:00
Jan Rundt ec3f36a9e4 OOD-88: check for default service provider before disabling role alternatives 2025-03-19 14:40:02 +02:00
Riku Rauhala ed7677d126 [Study programme] Improve API typing 2025-03-19 14:33:05 +02:00
Riku Rauhala bcbeeaceb5 [Study programme] Refactor columns 2025-03-19 14:33:05 +02:00
Riku Rauhala f80200d481 [Study programme] Rewrite OverallStatsTable with MRT 2025-03-19 14:33:05 +02:00
Riku Rauhala e64e0d04fd [Study programme] Extract component OverallStatsTable
Let's rewrite this separately
2025-03-19 14:33:05 +02:00
Riku Rauhala 85432092fa [Study programme] Rename OverallStatsTable to ByCreditTab
This component contains everything on the tab, the table is only one part of it
2025-03-19 14:33:05 +02:00
Riku Rauhala b6a39c829e [Study programme] Rewrite CourseYearFilter with MUI 2025-03-19 14:33:05 +02:00
Riku Rauhala 0a6edf6638 [Study programme] Delete component Toggle as unused 2025-03-19 14:33:05 +02:00
Riku Rauhala 2d44506c55 [Study programme] Type API request for programme course stats 2025-03-19 14:33:05 +02:00
Riku Rauhala d5dc511b39 [Study programme] Use MUI tabs for 'By credit type' / 'By semester' 2025-03-19 14:33:05 +02:00
Riku Rauhala 1e9c2857c5 [Hooks] Allow an optional prefix for tabs
Some views may contain multiple tabs, in that case the different tabs have to be identified
2025-03-19 14:33:05 +02:00
Riku Rauhala 58190f8757 [Study programme] Convert ProgrammeCoursesTab into TypeScript 2025-03-19 14:33:05 +02:00
Santeri Rajamäki caa715677b [Backend] Add acual typing to getStudents function of getStudentsIncludeCoursesBetween 2025-03-19 13:45:51 +02:00
Santeri Rajamäki 94c34a10eb [Backend] Refactor optimizedStatisticsOf chain 2025-03-19 12:21:52 +02:00
Joonas Ahovalli f86c90ff8c [Class statistics] Add completed study modules to modules tab 2025-03-17 16:26:23 +02:00
Riku Rauhala 617379e63f [GitHub Actions] Fix study programme overview name 2025-03-17 12:31:29 +02:00
Riku RauhalaandGitHub cb1b44d4a3 Merge pull request #4770 from UniversityOfHelsinkiCS/material-ui-study-programme-degree-courses
Material UI: Study programme overview - Degree courses tab
2025-03-17 12:17:39 +02:00
Riku Rauhala 2ae9d662dd [Study programme] Update tests 2025-03-17 12:07:34 +02:00
Riku Rauhala cd73900749 [Study programme] Return only status code for some requests
This data is not used in the frontend so there is no need to return it
2025-03-17 10:44:08 +02:00
Riku Rauhala 60a03b8643 [Study programme] Implement criterion label selection menu 2025-03-17 10:26:27 +02:00
Riku Rauhala 35ffc21b91 [Study programme] Rewrite visibility toggle with MUI 2025-03-17 10:26:26 +02:00
Riku Rauhala e2886c9e35 [Study programme] Create component VisibilityChip 2025-03-17 10:26:26 +02:00
Riku Rauhala c50d365dcb [Course exclusions] Type API
The modified data is not used in the frontend so we can simply return a status code
2025-03-17 10:26:26 +02:00
Riku Rauhala 6843b587cd [StudyProgramme] Extract util isMedicalProgramme 2025-03-17 10:26:26 +02:00
Riku Rauhala d1d4e3105e [Study programme] Extract util isBachelorOrLicentiateProgramme 2025-03-17 10:26:26 +02:00
Riku Rauhala 0fe018bb96 [Types] Rename Curriculum to CurriculumOption
This type is only used for a less detailed version of the Curriculum object to display available options
2025-03-17 10:26:26 +02:00
Riku Rauhala d2685b13d7 [Curriculum] Format for frontend 2025-03-17 10:26:26 +02:00
Riku Rauhala ec2bdcc8ea [Study programme] Improve curriculum typing 2025-03-17 10:26:26 +02:00
Riku Rauhala 37518f554a [Study programme] Rename DegreeCoursesTable 2025-03-17 10:26:26 +02:00
Riku Rauhala 5b33803d9f [InfoBox] Add tooltips for credit criteria 2025-03-17 10:26:26 +02:00
Riku Rauhala 5dd534e4d9 [Redux] Move curriculum to separate file 2025-03-17 10:26:26 +02:00
Riku Rauhala 589bcb6128 [Study programme] Rewrite Degree courses tab Credit criteria section with MUI 2025-03-17 10:26:26 +02:00
Riku Rauhala a5af6ebd47 [StatusNotification] Increase autoHideDuration and move to top right 2025-03-17 10:26:26 +02:00
Riku Rauhala 08b3d29b91 [CurriculumPicker] Extract component 2025-03-17 10:26:26 +02:00
Riku Rauhala 8ee1cac66f [Criteria] Extract shared type ProgressCriteria 2025-03-17 10:26:26 +02:00
Riku Rauhala 32a9cdbea6 [Redux] Convert progressCriteria into TypeScript 2025-03-17 10:18:35 +02:00
Riku Rauhala 372dbd4769 [Study programme] Convert DegreeCoursesTab into TypeScript 2025-03-17 10:18:35 +02:00
Santeri Rajamäki 49089975db [Backend] Remove moment from populations and related files 2025-03-14 16:47:42 +02:00
Santeri Rajamäki e4ff103cb1 [Backend] Remove traces of moment from getStudentsIncludeCoursesBetween 2025-03-14 16:47:42 +02:00
Joonas Ahovalli ef683f8fe1 [MRT Options] Switch to using shorthands for colors
This might help when spreading the config to override some settings
later
2025-03-14 15:19:46 +02:00
Joonas Ahovalli 6e3da7e1af [Class statistics] Add vertical columns to modules tab 2025-03-14 15:05:23 +02:00
Santeri Rajamäki c9fe222dd3 [Backend] Fix LSP crash in getStudentsIncludeCoursesBetween 2025-03-14 14:15:57 +02:00
Santeri Rajamäki 084be9df1b [Backend] Removed unused tag query from populationstatistics 2025-03-13 18:07:33 +02:00
Santeri Rajamäki 0b86f82eb7 [Backend] Making code climate less unhappy with populations/shared 2025-03-13 17:19:00 +02:00
Santeri Rajamäki 993d5e842e [Class statistics] Consolidated parts of value/type checking 2025-03-13 16:27:35 +02:00
Santeri Rajamäki 6ab7b9bbc4 [Class statistics] Moved error return values from "*statisticsOf" to the caller 2025-03-13 16:25:07 +02:00
Riku RauhalaandGitHub 0ccb1e79c8 Merge pull request #4768 from UniversityOfHelsinkiCS/study-programme-year-selector
[Study programme overview] Add an option to select a subset of years
2025-03-12 22:03:24 +02:00
Riku Rauhala 24ade5b222 [Study programme] Add placeholder for testing the year selector 2025-03-12 21:50:12 +02:00
Riku Rauhala ae9fbf182c [Study programme] Handle study tracks for the new year selector 2025-03-12 21:48:48 +02:00
Riku Rauhala 4be53e7707 [PopulationLink] Create a new button variant instead 2025-03-12 21:40:59 +02:00
Riku Rauhala c980560f53 [Study programme] Limit year selector visibility 2025-03-12 21:32:26 +02:00
Riku Rauhala b1928a7843 [Study programme] Implement a custom year selector 2025-03-12 21:26:46 +02:00
Riku Rauhala 2e60df5804 [PopulationLink] Move functionality to util 2025-03-12 21:12:41 +02:00
Riku Rauhala 2ccd526805 [Study programme] Update tests 2025-03-12 19:16:40 +02:00
Riku Rauhala 92b6b3cb60 [Study programme] Improve styling 2025-03-12 18:49:43 +02:00
Riku Rauhala fbc842cac5 [Study programme] Extract access checking for population links 2025-03-12 18:37:35 +02:00
Riku RauhalaandGitHub cb334e2865 Merge pull request #4767 from UniversityOfHelsinkiCS/refactor-population-link
[PopulationLink] Refactor to eliminate copy-paste
2025-03-12 17:29:40 +02:00
Riku Rauhala 24bd750763 [PopulationLink] Refactor to eliminate copy-paste 2025-03-12 16:16:59 +02:00
Riku Rauhala eb07173f36 [Study programme] Test info boxes 2025-03-12 15:25:05 +02:00
Riku RauhalaandGitHub 815d85d8e2 Merge pull request #4763 from UniversityOfHelsinkiCS/material-ui-study-programme-study-tracks
Material UI: Study programme - Study tracks and class statistics tab
2025-03-12 15:06:12 +02:00
Riku Rauhala a4a3c0f2ca [Study programme] Update tests 2025-03-12 14:41:08 +02:00
Santeri Rajamäki 20c3632972 Fix missing/shadowed variable in populations 2025-03-11 20:02:44 +02:00
santeri0200andGitHub 7af6374e7b Populus optimus (#4766)
- Reworked `populationstatistics/courses`
  - `/coursesbytag` and `coursesbystudentnumberlist` now use the same API
    - Updated frontend to use `/courses` instead
  - Updated cypress to call the new `v4/populationstatistics/courses` instead of `v2`
- Added types to request queries, request- and response bodies for multiple `populationstatistics/` routes
- Simplified parts of `optimizedStatisticsOf` call and its callers
- Cleaned up `bottlenecksOf`
- Made `studentNumbersWithAllElements` more verbose
- Simplified `CourseStatsCounter`
  - Moved functions that were only used for course statistics from `utils` to `courseStatsCounter`
- Moved `populationCourseStatMerger` into the only function it is ever called from
- Made `hasFullAccessToStudentData` explicit boolean return value
- Added `safeJSONParse` function to server utils to stop server from throwing (still needs to be implemented more widely)
- Added `formatToArray` and made `Array.isArray?val:[val]` disappear
- Made `CurriculumPicker` easier to read and more type safe
- Forced `formattedQueryParams` to be passed into `fetchPopulations` to avoid duplicate processing
2025-03-11 17:52:59 +02:00
Riku Rauhala 9accbc299a [GraduationTimes] Remove unused component 2025-03-11 13:53:39 +02:00
Riku Rauhala 7a03455c8e [Study programme] Include programme name in page title 2025-03-11 13:46:13 +02:00
Riku Rauhala a72c5eeaea [Study programme] Update loading/error status 2025-03-11 13:46:13 +02:00
Riku Rauhala 1f2d381039 [StudyTrackSelector] Disable selector if there are no study tracks 2025-03-11 13:46:13 +02:00
Riku Rauhala a31ef9af07 [Study programme] Improve typing 2025-03-11 13:46:13 +02:00
Riku Rauhala fcd855a3e2 [Shared] Extract type GraduationStatistics 2025-03-11 13:46:13 +02:00
Riku Rauhala 6a31bcf4d7 [Study programme] Rewrite average graduation times seciton with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala d3de30bdec [GraduationTimes] Move to components/material for reuse in Study programme 2025-03-11 13:46:13 +02:00
Riku Rauhala 1ee4fb8bcf [Study programme] Rewrite progress section with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala 3b58c13f4a [Faculties] Make the ProgressBarChart reusable for study programmes 2025-03-11 13:46:13 +02:00
Riku Rauhala 9ca46021aa [Util] Extract function generateGradientColors 2025-03-11 13:46:13 +02:00
Riku Rauhala 87ade36e4b [DataTable] Update styling 2025-03-11 13:46:13 +02:00
Riku Rauhala 32a670a8d1 [Study programme] Rewrite StudyTrackDataTable with MUI 2025-03-11 13:46:13 +02:00
Riku Rauhala f2478fbe24 [InfoBox] Set mini variant color to info
The mini variant should have the same color
2025-03-11 13:46:13 +02:00
Riku Rauhala f81a5ee1a4 [Util] Extract getCalendarYears 2025-03-11 13:46:13 +02:00
Riku Rauhala bf780d09d7 [Backend] Extract types for StudyTrackStats 2025-03-11 13:46:13 +02:00
Riku Rauhala 3bbee5c0b3 [Study programme] Delete css 2025-03-11 13:46:12 +02:00
Riku Rauhala 880f5997c1 [Study programme] Rewrite StudyProgrammeSelector with MUI 2025-03-11 13:46:12 +02:00
Riku Rauhala 44e2bad801 [Study programme] Convert StudyTracksAndClassStatisticsTab into TypeScript 2025-03-11 13:46:12 +02:00
Valtteri KantanenandGitHub 18a5eadbcc Merge pull request #4757 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-03-10 16:30:26 +02:00
renovate[bot]andGitHub e871b77192 Update all non-major dependencies 2025-03-10 14:14:39 +00:00
Valtteri KantanenandGitHub 4772ca99c2 Merge pull request #4760 from UniversityOfHelsinkiCS/renovate/npm-axios-vulnerability
Update dependency axios to v1.8.2 [SECURITY]
2025-03-10 15:48:35 +02:00
Valtteri Kantanen 6f6b059d83 [Documentation] Change NATS to BullMQ in architecture graph 2025-03-10 15:04:30 +02:00
Valtteri Kantanen 2cc016e439 [Updater] Fix bug preventing insertion of attainments referencing non-existent modules into the db (#4761) 2025-03-10 13:39:08 +02:00
renovate[bot]andGitHub 7670dbd50f Update dependency axios to v1.8.2 [SECURITY] 2025-03-10 08:03:05 +00:00
Rajamäki Santeri J 86a3081a1c [Population statistics] Fixed return data encryption step for multi year populations 2025-03-09 13:33:23 +02:00
558 changed files with 13753 additions and 11053 deletions
+1 -4
View File
@@ -9,10 +9,7 @@ runs:
node-version: 20
- name: 'Install dependencies'
shell: bash
run: npm ci && cd services/frontend && npm ci && cd ../backend && npm ci
- name: 'Create symlink for shared'
shell: bash
run: ln -s $PWD/services/shared $PWD/services/backend/src/shared && ln -s $PWD/services/shared $PWD/services/frontend/src/shared
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
- name: 'Run Stylelint for CSS files'
shell: bash
run: npm run stylelint "services/frontend/src/**/*.css"
+3 -3
View File
@@ -95,7 +95,7 @@ jobs:
run: docker create --name frontend quay.io/toska/oodikone-frontend:production
- name: Copy assets from the frontend container
run: docker cp frontend:/opt/app-root/src/build ./build
run: docker cp frontend:/opt/app-root/frontend/build ./build
- name: Create Sentry release
uses: getsentry/action-release@v3
@@ -129,7 +129,7 @@ jobs:
run: docker create --name backend quay.io/toska/oodikone-backend:production
- name: Copy assets from the backend container
run: docker cp backend:/opt/app-root/src/dist ./dist
run: docker cp backend:/opt/app-root/backend/dist ./dist
- name: Create Sentry release
uses: getsentry/action-release@v3
@@ -142,7 +142,7 @@ jobs:
environment: production
set_commits: 'skip'
sourcemaps: './dist'
url_prefix: '~/opt/app-root/src/dist/'
url_prefix: '~/opt/app-root/backend/dist/'
version: ${{ steps.extract_version.outputs.VERSION }}
report_success:
+4 -4
View File
@@ -13,18 +13,18 @@ on:
- 'package*.json'
jobs:
lint_backend_and_frontend:
name: 'Lint backend and frontend'
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint backend and frontend
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_end_to_end:
runs-on: ubuntu-latest
needs:
- lint_backend_and_frontend
- lint_packages
strategy:
fail-fast: false
matrix:
+4 -4
View File
@@ -12,12 +12,12 @@ on:
jobs:
# === Static tests / linting ===
lint_backend_and_frontend:
name: 'Lint backend and frontend'
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint backend and frontend
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
# === Build database images ===
@@ -199,7 +199,7 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test study_programme overview'
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test teachers and users'
spec: |
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
with:
node-version: 18
- name: 'Install dependencies'
run: npm ci && cd updater/sis-updater-scheduler && npm ci && cd ../sis-updater-worker && npm ci
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
run: npm run eslint -- updater
+2 -5
View File
@@ -70,12 +70,9 @@ graph TB
end
server -->|Read| sisdb[(sis-db)]
subgraph Updater
updaterscheduler[Scheduler] --> |Push jobs| nats
nats(NATS server)
updaterworker --> |Fetch jobs| nats
updaterscheduler[Scheduler] --> |Push BullMQ jobs| redis(Redis)
updaterworker[Worker] -->|Write| sisdb
updaterscheduler --> redis(Redis)
updaterworker --> redis
updaterworker --> |Fetch BullMQ jobs| redis
end
end
+3 -3
View File
@@ -351,8 +351,8 @@ describe('Course Statistics tests', () => {
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(2).contains('3')
cy.contains('td', '497388').siblings().eq(2).contains('2')
cy.contains('td', '394776').siblings().eq(1).contains('3')
cy.contains('td', '497388').siblings().eq(1).contains('2')
})
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
@@ -365,7 +365,7 @@ describe('Course Statistics tests', () => {
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.get('table tbody td').filter(':contains("hidden")').should('have.length', 9)
cy.get('table tbody td').filter(':contains("Hidden")').should('have.length', 9)
})
it('Language distribution is correct', () => {
+2 -2
View File
@@ -194,7 +194,7 @@ describe('Population Statistics', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(21)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
@@ -249,7 +249,7 @@ describe('Population Statistics', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('StartYearAtUni-header').click()
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
checkFilteringResult(8)
cy.cs('reset-all-filters').click()
checkFilteringResult(defaultAmountOfStudents)
+10 -4
View File
@@ -43,10 +43,16 @@ describe('Open uni population tests', () => {
cy.contains('01.01.2015')
cy.contains('End of the search for enrollments:')
cy.contains('01.01.2020')
cy.url().should(
'include',
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
)
cy.location('pathname').should('eq', '/openunipopulation')
cy.location('search').then(search => {
const params = new URLSearchParams(search)
const courseCodes = params.getAll('courseCode')
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
expect(params.get('startdate')).to.eq('01-01-2015')
expect(params.get('enddate')).to.eq('01-01-2020')
})
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
+5 -3
View File
@@ -264,7 +264,7 @@ describe('Population statistics tests', () => {
it('New fetch of courses data is done when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2020–2023').click({ force: true })
@@ -287,7 +287,7 @@ describe('Population statistics tests', () => {
it('New fetch of courses data is done when filtered students change', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
@@ -320,8 +320,10 @@ describe('Population statistics tests', () => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
cy.contains('Teoreettisten ja laskennallisten menetelmien maisteriohjelma')
cy.contains('Taloustieteen maisteriohjelma')
cy.contains('Todistusvalinta')
cy.contains('Saksa')
cy.contains('Female')
})
})
+16 -17
View File
@@ -79,14 +79,17 @@ describe('Study guidance group tests', () => {
})
it('general tab of the students table has the correct columns', () => {
const firstRowHeadings = ['Student number', 'Credits', 'Start year at uni', 'Study programmes', 'Tags']
const secondRowHeadings = ['All', 'Since 1.1.1970']
const colHeaders = [
'Student number',
'All credits',
'Credits since 1.1.1970',
'Start year at uni',
'Study programmes',
'Tags',
]
cy.cs('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
@@ -129,9 +132,11 @@ describe('Study guidance group tests', () => {
})
it('general tab of the students table has the correct columns', () => {
const firstRowHeadings = [
const colHeaders = [
'Student number',
'Credits',
'All credits',
'Credits in HOPS',
'Credits since 1.8.2020',
'Start of study right',
'Started in programme',
'Semesters present',
@@ -146,15 +151,9 @@ describe('Study guidance group tests', () => {
'Latest attainment date',
'Tags',
]
const secondRowHeadings = ['All', 'HOPS', 'Since 1.8.2020']
cy.cs('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replaceAll('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children)
.filter(elem => elem.style.display !== 'none')
.map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
+245 -238
View File
@@ -277,11 +277,28 @@ describe('Study programme overview', () => {
cy.cs('study-tracks-and-class-statistics-tab').click()
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Study tracks and class statistics', () => {
cy.cs('Section-studyTrackOverview')
cy.cs('Section-studyTrackProgress')
cy.cs('Section-averageGraduationTimesStudyTracks')
describe('Info boxes', () => {
it('Study track overview section', () => {
cy.cs('study-track-overview-info-box-button').click()
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
})
it('Progress of students section', () => {
cy.cs('progress-of-students-info-box-button').click()
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
})
it('Average graduation times section', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
})
})
it('All sections are visible', () => {
cy.cs('study-track-selector-section')
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it('Students of the study programme are shown correctly', () => {
@@ -300,7 +317,8 @@ describe('Study programme overview', () => {
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it('Years in the students table can be expanded and study track data will be shown', () => {
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
// TODO: Fix this test
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
@@ -335,45 +353,30 @@ describe('Study programme overview', () => {
})
})
it('Links to class statistics page work', () => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(1)
.within(() => {
cy.get('td i.level.up.alternate.icon').click()
})
describe('Population link button works for', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
cy.contains('class size 8 students')
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('class size 26 students')
})
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('td.total-row-cell a').click()
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 227 students')
})
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 227 students')
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('show-study-tracks-button').first().click()
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
cy.get('a').click()
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.right.icon').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 26 students')
cy.contains('3 students out of 26 shown')
})
cy.contains('td', 'Matematiikka, MAT-MAT').within(() => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('div.sub.header', 'studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
})
it('Student progress data is shown correctly', () => {
@@ -391,27 +394,27 @@ describe('Study programme overview', () => {
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
]
cy.checkTableStats(tableContents, 'study-track-progress-basic')
cy.checkTableStats(tableContents, 'study-programme-progress')
})
it('Study track overview graphs render', () => {
cy.cs('study-track-progress-bar-chart')
it('Progress section', () => {
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
cy.cs('study-track-progress-bar-chart').contains('49.8%').trigger('mouseover', { force: true })
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
})
cy.cs('KH50_001-section').within(() => {
cy.contains('Start year')
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.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("Click a bar to view that year's study track level breakdown").should('not.exist')
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
@@ -421,8 +424,7 @@ describe('Study programme overview', () => {
cy.cs('graduation-time-toggle').click()
cy.cs('KH50_001-section').within(() => {
cy.contains('Start year')
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
@@ -435,12 +437,16 @@ describe('Study programme overview', () => {
describe('Study track can be changed', () => {
beforeEach(() => {
cy.get('.studytrack-selector').contains('All students of the programme, KH50_001').click()
cy.get('.studytrack-selector .visible.menu').contains('Matematiikka, MAT-MAT').click()
cy.cs('study-track-select').click()
cy.contains('All students of the programme')
cy.contains('Ekonometria')
cy.contains('Tietojenkäsittelyteoria')
cy.contains('Tilastotiede')
cy.contains('Matematiikka').click()
})
it('Students of the study track are shown correctly', () => {
cy.cs('Section-studyTrackOverview').contains('Students of the study track MAT-MAT by starting year')
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]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
@@ -455,26 +461,17 @@ describe('Study programme overview', () => {
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.regular-row')
.eq(2)
.within(() => {
cy.get('a').click()
})
})
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('div.sub.header', 'studytrack MAT-MAT')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
})
it('Info message about missing progress stats is displayed', () => {
cy.contains('.divider', 'Progress of students of the study track MAT-MAT by starting year')
cy.contains(
'.message',
'Currently progress data is only available for all students of the study programme. Please select ”All students of the programme” to view the progress data.'
)
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the study programme.')
})
it.skip('Average graduation times are displayed correctly', () => {
@@ -506,184 +503,170 @@ describe('Study programme overview', () => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('programme-courses-tab').click()
cy.cs('by-credit-type-section')
cy.cs('by-credit-type-tab')
cy.cs('by-semester-tab')
})
it('content loads', () => {
cy.cs('CoursesYearFilter')
cy.cs('CourseTabs')
})
it('time range selection works', () => {
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
})
cy.cs('fromYear').click().contains('2018').click()
cy.cs('toYear').click().contains('2019').click()
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('15')
})
})
})
it("'Calendar year/Academic year' toggle works", () => {
cy.cs('calendarAcademicYearToggle').click()
cy.cs('fromYear').contains('2017-2018')
cy.cs('toYear').contains('2023-2024')
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
})
})
it('contains correct courses in alphabetical order', () => {
cy.cs('CoursesSortableTable').within(() => {
cy.get('tr').eq(1).contains('Advanced calculus')
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
})
})
it('different sorting options work', () => {
cy.cs('CoursesSortableTable').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('odgi-zef0')
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
})
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT-yht')
cy.get('td').eq(1).contains('Muut opinnot')
})
// Course name
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20003')
cy.get('td').eq(1).contains('Äidinkielen opinnot')
})
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
})
// Total credits
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT110')
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
})
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20001')
cy.get('td').eq(1).contains('Kypsyysnäyte')
})
})
})
it("'Show credits/Show students' toggle works", () => {
cy.get('[data-cy=CoursesSortableTable] thead tr').within(() => {
cy.get('th').should('have.length', 8)
const headers = [
'Code',
'Name',
'Total credits',
'Major credits',
'Non-major credits',
'Non-degree credits',
'Transferred credits',
'Type',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
describe('By credit type tab', () => {
beforeEach(() => {
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.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('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('75')
})
it('time range selection works', () => {
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2021').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2022').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.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.cs('creditsStudentsToggle').click()
it('year toggle works', () => {
cy.cs('year-toggle').click()
cy.get('[data-cy=CoursesSortableTable] thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Code',
'Name',
'Total',
'Breakdown of total',
'Breakdown of passed students',
'Not included in total nor passed',
'Type',
]
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2017').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2023').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.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')
})
})
})
// TODO: Fix sorting / filters
it.skip('different sorting options work', () => {
cy.cs('CoursesSortableTable').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('odgi-zef0')
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
})
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT-yht')
cy.get('td').eq(1).contains('Muut opinnot')
})
// Course name
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20003')
cy.get('td').eq(1).contains('Äidinkielen opinnot')
})
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
})
// Total credits
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT110')
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
})
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20001')
cy.get('td').eq(1).contains('Kypsyysnäyte')
})
})
})
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('[data-cy=CoursesSortableTable] thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 6)
const headers = [
'Passed',
'Not completed',
'Major students',
'Non-major students',
'Non-degree students',
'Transferred students',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.cs('show-credits-students-toggle').click()
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
.eq(0)
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
cy.get('td').eq(2).contains('23')
cy.get('th').should('have.length', 10)
const headers = [
'Type',
'Code',
'Name',
'Total',
'Passed',
'Not',
'Major',
'Non-major',
'Non-degree',
'Transferred',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.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')
})
})
})
})
describe('By semester tab', () => {
beforeEach(() => {
cy.cs('by-semester-tab').click()
})
it('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
})
})
@@ -695,17 +678,32 @@ describe('Study programme overview', () => {
cy.cs('degree-courses-tab').click()
})
it('content loads', () => {
cy.contains('h3', 'Select curriculum to edit:')
it('Curriculum section', () => {
cy.cs('curriculum-section')
cy.contains('Select curriculum to edit:')
cy.cs('curriculum-picker').contains('2023–2026')
cy.contains('Change visibility of degree courses and select criteria for academic years')
cy.contains('form', 'First year (12 months) last set: 0')
cy.get('table').within(() => {
cy.contains('Muut opinnot')
cy.contains('Matematiikka, perusopinnot')
cy.contains('div.green.label', 'visible')
cy.contains('button', 'Set hidden')
// TODO: Test changing the curriculum
})
describe('Credit criteria section', () => {
it('info box', () => {
cy.cs('credit-criteria-section')
cy.cs('credit-criteria-info-box-button').click()
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
})
it.skip('changing the credit criteria works', () => {
// TODO: Test using the inputs
// TODO: Button status
// TODO: Test "previously set to _" text changing when saving
})
})
it('Degree course table', () => {
cy.cs('degree-course-table')
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
})
})
@@ -822,6 +820,15 @@ describe('Study programme overview', () => {
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
})
it.skip('year selector', () => {
// TODO: Implement this test
// Things to test:
// - Clicking the button works
// - Selecting a study track works
// - The button is disabled if only one year is selected
// - The slider is not visible if the user does not have correct programme rights
})
it('can access programme and correct tabs are visible', () => {
cy.contains('Basic information')
cy.contains('Study tracks and class statistics')
@@ -843,9 +850,9 @@ describe('Study programme overview', () => {
it('can access study tracks', () => {
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('Section-studyTrackOverview')
cy.cs('Section-studyTrackProgress')
cy.cs('Section-averageGraduationTimesStudyTracks')
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it("doesn't see other tabs", () => {
+2 -2
View File
@@ -7,7 +7,7 @@ const checkProgressBarCharts = () => {
}
const checkProgressTables = () => {
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-table`))
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
@@ -53,7 +53,7 @@ describe('University view', () => {
it('years in the tables can be clicked to show faculty level breakdown', () => {
cy.cs('study-right-toggle').click()
cy.cs('faculty-bachelors-progress-table-show-button3').click()
cy.cs('bachelors-faculty-progress-table-show-button3').click()
cy.contains('29.5%').trigger('mouseover', { force: true })
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
+9 -5
View File
@@ -49,9 +49,10 @@ services:
ports:
- 8080:8080
volumes:
- ./environments/ha:/opt/app-root/src/src/environment
- ./services/backend/src:/opt/app-root/src/src
- ./services/shared/:/opt/app-root/src/src/shared
- 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
@@ -66,8 +67,9 @@ services:
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
volumes:
- ./services/frontend/src/:/opt/app-root/src/src
- ./services/shared/:/opt/app-root/src/src/shared
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
@@ -171,6 +173,8 @@ services:
- 12347:5432
volumes:
backend-nodemod:
frontend-nodemod:
kone-db-data:
redis-data:
sis-db-data:
+2 -2
View File
@@ -1,5 +1,5 @@
services:
frontend:
volumes:
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/instrumented:/opt/app-root/src/src
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/instrumented:/opt/app-root/frontend/src
+6 -6
View File
@@ -44,9 +44,9 @@ services:
profiles:
- oodikone
volumes:
- backend-nodemod:/opt/app-root/src/node_modules
- ./services/backend/:/opt/app-root/src
- ./services/shared/:/opt/app-root/src/src/shared
- backend-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
@@ -59,9 +59,9 @@ services:
profiles:
- oodikone
volumes:
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/:/opt/app-root/src
- ./services/shared/:/opt/app-root/src/src/shared
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
+2 -1
View File
@@ -83,6 +83,7 @@ export default [
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'react/display-name': 'off', // TODO: Delete this override
'react/function-component-definition': [
@@ -163,7 +164,7 @@ export default [
},
{
settings: {
'import/internal-regex': '^@/shared/',
'import/internal-regex': '^@oodikone/shared/',
'import/resolver': {
typescript: {
project: [
+817 -454
View File
File diff suppressed because it is too large Load Diff
+20 -17
View File
@@ -23,10 +23,13 @@
"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/src/node_modules/.bin/tsx /opt/app-root/src/src/rapodiff/rapodiff.js",
"install:local": "npm i && cd services/frontend && npm i && cd ../backend && npm i && cd ../../updater/sis-updater-scheduler && npm i && cd ../sis-updater-worker && npm i && cd ../..",
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
"install:both": "npm run install:local && npm run install:docker"
"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:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
"type": "git",
@@ -38,27 +41,27 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.12",
"@eslint/js": "^9.21.0",
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^14.1.0",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-cypress": "^4.1.0",
"cypress": "^14.4.1",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.0.0",
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"lint-staged": "^16.1.0",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.5.3",
"stylelint": "^16.15.0",
"stylelint-config-standard": "^37.0.0",
"typescript-eslint": "^8.26.0"
"stylelint": "^16.20.0",
"stylelint-config-standard": "^38.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21",
-15
View File
@@ -225,21 +225,6 @@ set_up_oodikone_from_scratch() {
infomsg "Building images"
"$PROJECT_ROOT"/run.sh both anon build
TARGET_PATH="$PROJECT_ROOT/services/shared"
LINK_PATHS=(
"$PROJECT_ROOT/services/backend/src/shared"
"$PROJECT_ROOT/services/frontend/src/shared"
)
for SYMLINK_PATH in "${LINK_PATHS[@]}"; do
infomsg "Linking $TARGET_PATH to $SYMLINK_PATH"
if [ ! -L "$SYMLINK_PATH" ]; then
ln -s "$TARGET_PATH" "$SYMLINK_PATH"
else
infomsg "Symbolic link $SYMLINK_PATH already exists. Skipping creation."
fi
done
successmsg "Setup ready, Oodikone can be started! See README for more info."
}
+15 -13
View File
@@ -2,8 +2,6 @@ FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=4096
@@ -19,23 +17,22 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY ./backend/package*.json ./
RUN npm ci --include=dev
COPY ./shared ./src/shared
WORKDIR /opt/app-root/shared
COPY ./shared .
WORKDIR /opt/app-root/backend
COPY ./backend .
RUN npm run build
WORKDIR /opt/app-root
RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backend
RUN npm run build --prefix shared && npm run build --prefix backend
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=4096
@@ -51,9 +48,14 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY --from=build /opt/app-root/src/package*.json ./
COPY --from=build /opt/app-root/src/dist ./dist
WORKDIR /opt/app-root/shared
COPY --from=build /opt/app-root/shared/dist .
RUN npm ci
WORKDIR /opt/app-root/backend
COPY --from=build /opt/app-root/backend/package*.json .
COPY --from=build /opt/app-root/backend/dist ./dist
RUN npm ci --omit=dev
EXPOSE 8080
CMD ["node", "dist/index.js"]
+3 -6
View File
@@ -2,15 +2,12 @@ FROM node:22-alpine
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
COPY ./package* ./
RUN npm ci
WORKDIR /opt/app-root/backend
COPY . .
RUN npm ci
EXPOSE 8080
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
+740 -428
View File
File diff suppressed because it is too large Load Diff
+22 -20
View File
@@ -5,40 +5,42 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.55.0",
"@sentry/profiling-node": "^8.55.0",
"axios": "^1.8.1",
"bullmq": "^5.41.7",
"@oodikone/shared": "file:../shared",
"@sentry/node": "^9.28.1",
"@sentry/profiling-node": "^9.28.1",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.1.0",
"cron": "^4.3.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"lru-cache": "^11.1.0",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"papaparse": "^5.5.2",
"pg": "^8.13.3",
"redis": "^4.7.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.6",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2"
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.16",
"@types/morgan": "^1.9.9",
"@types/node": "^22.13.9",
"@types/validator": "^13.12.2",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.17",
"@types/morgan": "^1.9.10",
"@types/node": "^22.15.31",
"@types/validator": "^13.15.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"overrides": {
"winston-gelf-transporter": {
+54 -54
View File
@@ -4,36 +4,36 @@ import { SequelizeStorage, Umzug } from 'umzug'
import conf from '../config'
import {
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
CourseModel,
CourseProviderModel,
CourseTypeModel,
CreditModel,
CreditTeacherModel,
CreditTypeModel,
CurriculumPeriodModel,
EnrollmentModel,
OrganizationModel,
ProgrammeModuleModel,
ProgrammeModuleChildModel,
SemesterModel,
SISStudyRightModel,
SISStudyRightElementModel,
StudentModel,
StudyplanModel,
StudyrightExtentModel,
TeacherModel,
} from '../models'
import {
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
ProgressCriteriaModel,
StudyGuidanceGroupTagModel,
StudyProgrammePinModel,
TagModel,
TagStudentModel,
} from '../models/kone'
import { User } from '../models/user'
import { UserModel } from '../models/user'
import logger from '../util/logger'
@@ -60,24 +60,24 @@ class DbConnection extends EventEmitter {
logging: false,
password: conf.SIS_PASSWORD,
models: [
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
CourseModel,
CourseProviderModel,
CourseTypeModel,
CreditModel,
CreditTeacherModel,
CreditTypeModel,
CurriculumPeriodModel,
EnrollmentModel,
OrganizationModel,
ProgrammeModuleModel,
ProgrammeModuleChildModel,
SemesterModel,
SISStudyRightModel,
SISStudyRightElementModel,
StudentModel,
StudyplanModel,
StudyrightExtentModel,
TeacherModel,
],
})
}
@@ -107,14 +107,14 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
logging: false,
password: conf.KONE_PASSWORD,
models: [
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
ProgressCriteriaModel,
StudyGuidanceGroupTagModel,
StudyProgrammePinModel,
TagModel,
TagStudentModel,
],
})
@@ -123,7 +123,7 @@ void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
logging: false,
password: conf.USER_PASSWORD,
models: [User],
models: [UserModel],
})
const initializeDatabaseConnection = async () => {
+1 -1
View File
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
export const refreshProgramme = async (code: string) => {
await updateBasicView(code, '')
await updateStudyTrackView(code, '')
const combinedProgramme = combinedStudyProgrammes[code] || ''
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
await updateBasicView(code, combinedProgramme)
await updateStudyTrackView(code, combinedProgramme)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from 'express'
import { Role } from '../shared/types'
import { Role } from '@oodikone/shared/types'
export const roles =
(requiredRoles: Role[], requiredIamGroups?: string[]) => (req: Request, res: Response, next: NextFunction) => {
+33 -12
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -12,29 +11,45 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { ExtentCode, SemesterEnrollment } from '../types'
import { Credit, Enrollment, Organization, SISStudyRightElement, Student, Studyplan, StudyrightExtent } from '.'
import type {
Credit,
Enrollment,
Organization,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
} from '@oodikone/shared/models'
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { OrganizationModel } from './organization'
import { SISStudyRightElementModel } from './SISStudyRightElement'
import { StudentModel } from './student'
import { StudyplanModel } from './studyplan'
import { StudyrightExtentModel } from './studyrightExtent'
@Table({
underscored: true,
tableName: 'sis_study_rights',
})
export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
facultyCode!: string
@BelongsTo(() => Organization, { foreignKey: 'facultyCode', targetKey: 'code' })
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
organization!: Organization
@HasMany(() => Studyplan)
@HasMany(() => StudyplanModel)
studyPlans!: Studyplan[]
@HasMany(() => SISStudyRightElement, { foreignKey: 'studyRightId' })
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
studyRightElements!: SISStudyRightElement[]
@Column(DataType.DATE)
@@ -52,10 +67,10 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
@Column(DataType.STRING)
studentNumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
student!: Student
@ForeignKey(() => StudyrightExtent)
@ForeignKey(() => StudyrightExtentModel)
@Column(DataType.INTEGER)
extentCode!: ExtentCode
@@ -65,12 +80,18 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
@Column(DataType.JSONB)
semesterEnrollments!: SemesterEnrollment[] | null
@HasMany(() => Credit, { foreignKey: 'studyright_id', sourceKey: 'id' })
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
credits!: Credit[]
@HasMany(() => Enrollment, { foreignKey: 'studyright_id', sourceKey: 'id' })
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
enrollments!: Enrollment[]
@Column(DataType.BOOLEAN)
tvex!: boolean
@Column(DataType.ARRAY(DataType.STRING))
expirationRuleUrns!: string[]
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,15 +10,17 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { DegreeProgrammeType, Phase, StudyTrack } from '../types'
import { SISStudyRight } from './SISStudyRight'
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import type { Name, StudyTrack } from '@oodikone/shared/types'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
tableName: 'sis_study_right_elements',
})
export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightElement>> {
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -36,11 +37,11 @@ export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightEle
@Column(DataType.INTEGER)
phase!: Phase
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyRightId!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyRightId' })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
studyRight!: SISStudyRight
@Column(DataType.STRING)
+18 -26
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -13,41 +12,43 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { CourseProvider } from './courseProvider'
import { CourseType } from './courseType'
import { Credit } from './credit'
import { Enrollment } from './enrollment'
import { Organization } from './organization'
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { CourseProviderModel } from './courseProvider'
import { CourseTypeModel } from './courseType'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { OrganizationModel } from './organization'
@Table({
underscored: false,
modelName: 'course',
tableName: 'course',
})
export class Course extends Model<InferAttributes<Course>> {
export class CourseModel extends Model<Course> implements Course {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
code!: string
@ForeignKey(() => CourseType)
@ForeignKey(() => CourseTypeModel)
@Column(DataType.STRING)
coursetypecode!: string
@BelongsTo(() => CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
courseType!: CourseType
@HasMany(() => Credit, { foreignKey: 'course_id', sourceKey: 'id' })
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
credits!: Credit[]
@HasMany(() => Enrollment, { foreignKey: 'course_id' })
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
enrollments!: Enrollment[]
@BelongsToMany(() => Organization, () => CourseProvider, 'coursecode')
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
organizations!: Organization[]
@Column(DataType.BOOLEAN)
@@ -63,17 +64,11 @@ export class Course extends Model<InferAttributes<Course>> {
min_attainment_date!: Date
@CreatedAt
@Column({
field: 'created_at',
type: DataType.DATE,
})
@Column({ field: 'created_at', type: DataType.DATE })
createdAt!: Date
@UpdatedAt
@Column({
field: 'updated_at',
type: DataType.DATE,
})
@Column({ field: 'updated_at', type: DataType.DATE })
updatedAt!: Date
@Column(DataType.JSONB)
@@ -82,9 +77,6 @@ export class Course extends Model<InferAttributes<Course>> {
@Column(DataType.STRING)
course_unit_type!: string
@Column({
field: 'main_course_code',
type: DataType.STRING,
})
@Column({ field: 'main_course_code', type: DataType.STRING })
mainCourseCode!: string
}
@@ -1,23 +1,24 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Course } from './course'
import { Organization } from './organization'
import type { CourseProvider } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { OrganizationModel } from './organization'
@Table({
underscored: true,
modelName: 'course_provider',
tableName: 'course_providers',
})
export class CourseProvider extends Model<InferAttributes<CourseProvider>> {
@ForeignKey(() => Course)
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
coursecode!: string
@Column(DataType.JSONB)
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organizationcode!: string
+6 -5
View File
@@ -1,20 +1,21 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { Course } from './course'
import type { Course, CourseType } from '@oodikone/shared/models'
import { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
@Table({
underscored: true,
modelName: 'course_type',
tableName: 'course_types',
})
export class CourseType extends Model<InferAttributes<CourseType>> {
export class CourseTypeModel extends Model<CourseType> implements CourseType {
@PrimaryKey
@Column(DataType.STRING)
coursetypecode!: string
@HasMany(() => Course)
@HasMany(() => CourseModel)
courses!: Course[]
@Column(DataType.JSONB)
+26 -14
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -12,15 +11,23 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { CreditTypeCode } from '../types'
import { Course, CreditTeacher, CreditType, Semester, SISStudyRight, Student, Teacher } from '.'
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
import { CreditTypeCode } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { CreditTeacherModel } from './creditTeacher'
import { CreditTypeModel } from './creditType'
import { SemesterModel } from './semester'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
import { TeacherModel } from './teacher'
@Table({
underscored: false,
modelName: 'credit',
tableName: 'credit',
})
export class Credit extends Model<InferAttributes<Credit>> {
export class CreditModel extends Model<Credit> implements Credit {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -28,24 +35,24 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.STRING)
grade!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
student_studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
student!: Student
@BelongsTo(() => Semester, { foreignKey: 'semester_composite' })
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
semester!: Semester
@Column(DataType.DOUBLE)
credits!: number
@ForeignKey(() => CreditType)
@ForeignKey(() => CreditTypeModel)
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
@BelongsTo(() => CreditType, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
creditType!: CreditType
@Column(DataType.DATE)
@@ -54,16 +61,16 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.DATE)
attainment_date!: Date
@BelongsToMany(() => Teacher, () => CreditTeacher, 'credit_id')
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
teachers!: Teacher[]
@Column(DataType.STRING)
course_code!: string
@BelongsTo(() => Course, { foreignKey: 'course_id' })
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
course!: Course
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
@@ -85,11 +92,11 @@ export class Credit extends Model<InferAttributes<Credit>> {
@Column(DataType.BOOLEAN)
is_open!: boolean
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyright_id!: string
@BelongsTo(() => SISStudyRight)
@BelongsTo(() => SISStudyRightModel)
studyright!: SISStudyRight
@CreatedAt
@@ -108,3 +115,8 @@ export class Credit extends Model<InferAttributes<Credit>> {
static notUnnecessary: ({ credits }: { credits: number }) => boolean
}
CreditModel.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
CreditModel.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
CreditModel.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
CreditModel.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
+7 -6
View File
@@ -1,20 +1,21 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Credit } from './credit'
import { Teacher } from './teacher'
import type { CreditTeacher } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { TeacherModel } from './teacher'
@Table({
underscored: false,
modelName: 'credit_teacher',
tableName: 'credit_teachers',
})
export class CreditTeacher extends Model<InferAttributes<CreditTeacher>> {
@ForeignKey(() => Credit)
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
@ForeignKey(() => CreditModel)
@Column(DataType.STRING)
credit_id!: string
@ForeignKey(() => Teacher)
@ForeignKey(() => TeacherModel)
@Column(DataType.STRING)
teacher_id!: string
+3 -4
View File
@@ -1,15 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { CreditTypeCode } from '../types'
import type { CreditType } from '@oodikone/shared/models'
import { CreditTypeCode, Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'credit_type',
tableName: 'credit_types',
})
export class CreditType extends Model<InferAttributes<CreditType>> {
export class CreditTypeModel extends Model<CreditType> implements CreditType {
@PrimaryKey
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import type { CurriculumPeriod } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'curriculum_period',
tableName: 'curriculum_periods',
})
export class CurriculumPeriod extends Model<InferAttributes<CurriculumPeriod>> {
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
@Column({ type: DataType.STRING, primaryKey: true })
id!: string
+17 -13
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,24 +10,29 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { EnrollmentState } from '../types'
import { Course, Semester, SISStudyRight, Student } from '.'
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
import { EnrollmentState } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { SemesterModel } from './semester'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@Table({
underscored: false,
modelName: 'enrollment',
tableName: 'enrollment',
})
export class Enrollment extends Model<InferAttributes<Enrollment>> {
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
@Column(DataType.STRING)
@@ -40,15 +44,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
@Column(DataType.DATE)
enrollment_date_time!: Date
@ForeignKey(() => Course)
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
@ForeignKey(() => Semester)
@ForeignKey(() => SemesterModel)
@Column(DataType.STRING)
semester_composite!: string
@BelongsTo(() => Semester, { foreignKey: 'semester_composite', targetKey: 'composite' })
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
semester!: Semester
@Column(DataType.INTEGER)
@@ -57,15 +61,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
@Column(DataType.BOOLEAN)
is_open!: boolean
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column({ type: DataType.STRING, allowNull: true })
studyright_id!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
studyright!: SISStudyRight
@BelongsTo(() => Course, { foreignKey: 'course_id', targetKey: 'id' })
course!: Course
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
course!: CourseModel
@CreatedAt
@Column(DataType.DATE)
+18 -45
View File
@@ -1,45 +1,18 @@
import { CreditTypeCode } from '../types'
import { Course } from './course'
import { CourseProvider } from './courseProvider'
import { CourseType } from './courseType'
import { Credit } from './credit'
import { CreditTeacher } from './creditTeacher'
import { CreditType } from './creditType'
import { CurriculumPeriod } from './curriculumPeriod'
import { Enrollment } from './enrollment'
import { Organization } from './organization'
import { ProgrammeModule } from './programmeModule'
import { ProgrammeModuleChild } from './programmeModuleChild'
import { Semester } from './semester'
import { SISStudyRight } from './SISStudyRight'
import { SISStudyRightElement } from './SISStudyRightElement'
import { Student } from './student'
import { Studyplan } from './studyplan'
import { StudyrightExtent } from './studyrightExtent'
import { Teacher } from './teacher'
Credit.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
Credit.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
Credit.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
Credit.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
export {
Course,
CourseProvider,
CourseType,
Credit,
CreditTeacher,
CreditType,
CurriculumPeriod,
Enrollment,
Organization,
ProgrammeModule,
ProgrammeModuleChild,
Semester,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
StudyrightExtent,
Teacher,
}
export { CourseModel } from './course'
export { CourseProviderModel } from './courseProvider'
export { CourseTypeModel } from './courseType'
export { CreditModel } from './credit'
export { CreditTeacherModel } from './creditTeacher'
export { CreditTypeModel } from './creditType'
export { CurriculumPeriodModel } from './curriculumPeriod'
export { EnrollmentModel } from './enrollment'
export { OrganizationModel } from './organization'
export { ProgrammeModuleModel } from './programmeModule'
export { ProgrammeModuleChildModel } from './programmeModuleChild'
export { SemesterModel } from './semester'
export { SISStudyRightModel } from './SISStudyRight'
export { SISStudyRightElementModel } from './SISStudyRightElement'
export { StudentModel } from './student'
export { StudyplanModel } from './studyplan'
export { StudyrightExtentModel } from './studyrightExtent'
export { TeacherModel } from './teacher'
@@ -1,15 +1,17 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: false,
modelName: 'custom_population_search',
tableName: 'custom_population_searches',
})
export class CustomPopulationSearch extends Model<
InferAttributes<CustomPopulationSearch>,
InferCreationAttributes<CustomPopulationSearch>
> {
export class CustomPopulationSearchModel
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
implements CustomPopulationSearch
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,12 +1,14 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'excluded_course',
tableName: 'excluded_courses',
})
export class ExcludedCourse extends Model<InferAttributes<ExcludedCourse>, InferCreationAttributes<ExcludedCourse>> {
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
+8 -19
View File
@@ -1,19 +1,8 @@
import { CustomPopulationSearch } from './customPopulationSearch'
import { ExcludedCourse } from './excludedCourse'
import { OpenUniPopulationSearch } from './openUniPopulationSearch'
import { ProgressCriteria } from './progressCriteria'
import { StudyGuidanceGroupTag } from './studyGuidanceGroupTag'
import { StudyProgrammePin } from './studyProgrammePin'
import { Tag } from './tag'
import { TagStudent } from './tagStudent'
export {
CustomPopulationSearch,
ExcludedCourse,
OpenUniPopulationSearch,
ProgressCriteria,
StudyGuidanceGroupTag,
StudyProgrammePin,
Tag,
TagStudent,
}
export { CustomPopulationSearchModel } from './customPopulationSearch'
export { ExcludedCourseModel } from './excludedCourse'
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
export { ProgressCriteriaModel } from './progressCriteria'
export { StudyGuidanceGroupTagModel } from './studyGuidanceGroupTag'
export { StudyProgrammePinModel } from './studyProgrammePin'
export { TagModel } from './tag'
export { TagStudentModel } from './tagStudent'
@@ -1,16 +1,18 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'open_uni_population_search',
tableName: 'open_uni_population_searches',
})
export class OpenUniPopulationSearch extends Model<
InferAttributes<OpenUniPopulationSearch>,
InferCreationAttributes<OpenUniPopulationSearch>
> {
export class OpenUniPopulationSearchModel
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
implements OpenUniPopulationSearch
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,16 +1,18 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'progress_criteria',
tableName: 'progress_criteria',
})
export class ProgressCriteria extends Model<
InferAttributes<ProgressCriteria>,
InferCreationAttributes<ProgressCriteria>
> {
export class ProgressCriteriaModel
extends Model<ProgressCriteria, ProgressCriteriaCreation>
implements ProgressCriteria
{
@PrimaryKey
@Column(DataType.STRING)
code!: string
@@ -1,13 +1,18 @@
import { CreationOptional, InferAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: true,
modelName: 'study_guidance_group_tag',
tableName: 'study_guidance_group_tags',
})
export class StudyGuidanceGroupTag extends Model<InferAttributes<StudyGuidanceGroupTag>> {
export class StudyGuidanceGroupTagModel
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
implements StudyGuidanceGroupTag
{
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
@@ -1,13 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
timestamps: false,
modelName: 'study_programme_pin',
tableName: 'study_programme_pins',
})
export class StudyProgrammePin extends Model<InferAttributes<StudyProgrammePin>> {
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
@PrimaryKey
@Column(DataType.INTEGER)
userId!: number
+7 -5
View File
@@ -1,20 +1,22 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import { TagStudent } from './tagStudent'
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
import { TagStudentModel } from './tagStudent'
@Table({
modelName: 'tag',
tableName: 'tag',
})
export class Tag extends Model<InferAttributes<Tag>, InferCreationAttributes<Tag>> {
export class TagModel extends Model<Tag, TagCreation> implements Tag {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagStudent)
@ForeignKey(() => TagStudentModel)
@Column(DataType.BIGINT)
tag_id!: CreationOptional<string>
@BelongsTo(() => TagStudent)
@BelongsTo(() => TagStudentModel)
tagStudent!: CreationOptional<TagStudent>
@Column(DataType.STRING)
@@ -1,25 +1,27 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import { Student } from '../student'
import { Tag } from './tag'
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
import { StudentModel } from '../student'
import { TagModel } from './tag'
@Table({
modelName: 'tag_student',
tableName: 'tag_student',
})
export class TagStudent extends Model<InferAttributes<TagStudent>, InferCreationAttributes<TagStudent>> {
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => Tag)
@ForeignKey(() => TagModel)
@Column(DataType.BIGINT)
tag_id!: string
@PrimaryKey
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Tag)
@BelongsTo(() => TagModel)
tag!: CreationOptional<Tag>
}
+13 -9
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsToMany,
Column,
@@ -11,15 +10,20 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { Course, CourseProvider, ProgrammeModule, SISStudyRight } from '.'
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
import { CourseProviderModel } from './courseProvider'
import { ProgrammeModuleModel } from './programmeModule'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
modelName: 'organization',
tableName: 'organization',
})
export class Organization extends Model<InferAttributes<Organization>> {
export class OrganizationModel extends Model<Organization> implements Organization {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -33,17 +37,17 @@ export class Organization extends Model<InferAttributes<Organization>> {
@Column(DataType.STRING)
parent_id!: string
@HasMany(() => Organization, { foreignKey: 'parent_id', as: 'children' })
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
children!: Organization[]
@HasMany(() => ProgrammeModule, { foreignKey: 'organization_id' })
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
programmeModules!: ProgrammeModule[]
@HasMany(() => SISStudyRight, { foreignKey: 'facultyCode', sourceKey: 'code' })
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
SISStudyRights!: SISStudyRight[]
@BelongsToMany(() => Course, () => CourseProvider, 'organizationcode')
courses!: Course[]
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
courses!: CourseModel[]
@CreatedAt
@Column(DataType.DATE)
+10 -10
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
BelongsToMany,
@@ -12,25 +11,26 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Name } from '../shared/types'
import { DegreeProgrammeType } from '../types'
import { Organization } from './organization'
import { ProgrammeModuleChild } from './programmeModuleChild'
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
import { OrganizationModel } from './organization'
import { ProgrammeModuleChildModel } from './programmeModuleChild'
@Table({
underscored: true,
modelName: 'programme_module',
tableName: 'programme_modules',
})
export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'child_id')
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
parents!: ProgrammeModule[]
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'parent_id')
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
children!: ProgrammeModule[]
@Column(DataType.STRING)
@@ -51,11 +51,11 @@ export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
@Column(DataType.STRING)
studyLevel!: string
@ForeignKey(() => Organization)
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organization_id!: string
@BelongsTo(() => Organization, { foreignKey: 'organization_id' })
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
organization!: Organization
@Column(DataType.DATE)
@@ -1,19 +1,20 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { ProgrammeModule } from './programmeModule'
import type { ProgrammeModuleChild } from '@oodikone/shared/models'
import { ProgrammeModuleModel } from './programmeModule'
@Table({
underscored: true,
modelName: 'programme_module_child',
tableName: 'programme_module_children',
})
export class ProgrammeModuleChild extends Model<InferAttributes<ProgrammeModuleChild>> {
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
@ForeignKey(() => ProgrammeModule)
@ForeignKey(() => ProgrammeModuleModel)
@Column(DataType.STRING)
parentId!: string
+3 -3
View File
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import type { Semester } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'semester',
tableName: 'semesters',
})
export class Semester extends Model<InferAttributes<Semester>> {
export class SemesterModel extends Model<Semester> implements Semester {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
+13 -9
View File
@@ -1,16 +1,20 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { GenderCode } from '../types'
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { GenderCode } from '@oodikone/shared/types'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
import { SISStudyRightModel } from './SISStudyRight'
import { StudyplanModel } from './studyplan'
@Table({
underscored: true,
modelName: 'student',
tableName: 'student',
})
export class Student extends Model<InferAttributes<Student>> {
export class StudentModel extends Model<Student> implements Student {
@PrimaryKey
@Column(DataType.STRING)
studentnumber!: string
@@ -24,16 +28,16 @@ export class Student extends Model<InferAttributes<Student>> {
@Column(DataType.STRING)
abbreviatedname!: string
@HasMany(() => Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
enrollments!: Enrollment[]
@HasMany(() => SISStudyRight, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
studyRights!: SISStudyRight[]
@HasMany(() => Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
studyplans!: Studyplan[]
@HasMany(() => Credit, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
credits!: Credit[]
@Column(DataType.DATE)
+9 -7
View File
@@ -1,4 +1,3 @@
import { InferAttributes } from 'sequelize'
import {
BelongsTo,
Column,
@@ -11,30 +10,33 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import { Student, SISStudyRight } from '.'
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@Table({
underscored: false,
modelName: 'studyplan',
tableName: 'studyplan',
})
export class Studyplan extends Model<InferAttributes<Studyplan>> {
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@ForeignKey(() => Student)
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
@ForeignKey(() => SISStudyRight)
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
sis_study_right_id!: string
@BelongsTo(() => SISStudyRight, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
studyRight!: SISStudyRight
@Column(DataType.STRING)
@@ -1,14 +1,14 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { ExtentCode } from '../types'
import type { StudyrightExtent } from '@oodikone/shared/models'
import { ExtentCode } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'studyright_extent',
tableName: 'studyright_extents',
})
export class StudyrightExtent extends Model<InferAttributes<StudyrightExtent>> {
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
@PrimaryKey
@Column(DataType.INTEGER)
extentcode!: ExtentCode
+6 -5
View File
@@ -1,15 +1,16 @@
import { InferAttributes } from 'sequelize'
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Credit } from './credit'
import { CreditTeacher } from './creditTeacher'
import type { Teacher, Credit } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { CreditTeacherModel } from './creditTeacher'
@Table({
underscored: true,
modelName: 'teacher',
tableName: 'teacher',
})
export class Teacher extends Model<InferAttributes<Teacher>> {
export class TeacherModel extends Model<Teacher> implements Teacher {
@PrimaryKey
@Column(DataType.STRING)
id!: string
@@ -17,7 +18,7 @@ export class Teacher extends Model<InferAttributes<Teacher>> {
@Column(DataType.STRING)
name!: string
@BelongsToMany(() => Credit, () => CreditTeacher)
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
credits!: Credit[]
@CreatedAt
+6 -4
View File
@@ -1,8 +1,10 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
import type { Language } from '@oodikone/shared/language'
import type { User, UserCreation } from '@oodikone/shared/models/user'
import type { Role } from '@oodikone/shared/types'
@Table({
underscored: true,
@@ -10,7 +12,7 @@ import { Role } from '../shared/types'
modelName: 'users',
tableName: 'users',
})
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
export class UserModel extends Model<User, UserCreation> implements User {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
+2 -2
View File
@@ -53,9 +53,9 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/semesters/codes`, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(`${url}/v3/course_exclusions`, courseExclusions)
app.use(`${url}/v3/course-exclusions`, courseExclusions)
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/changelog`, changelog)
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
+4 -2
View File
@@ -1,14 +1,16 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { isDev } from '../config'
import { Release } from '../shared/types'
const router = Router()
const changelog: { data?: Release[] } = {}
router.get('/', async (_req: Request, res: Response) => {
export type ChangelogResBody = Release[]
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
if (changelog.data) {
return res.status(200).send(changelog.data)
}
@@ -1,14 +1,17 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference, intersection } from 'lodash'
import { getCompletedCourses } from '../services/completedCoursesSearch'
import { CanError } from '@oodikone/shared/routes'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData } from '../util'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
import { getImporterClient } from '../util/importerClient'
import logger from '../util/logger'
@@ -16,17 +19,22 @@ const router = Router()
const importerClient = getImporterClient()
interface GetSearchRequest extends Request {
query: {
studentlist: string
courselist: string
}
export type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
>
export type SearchReqBody = never
export type SearchQuery = {
studentlist: string
courselist: string
}
router.get('/', async (req: GetSearchRequest, res: Response) => {
const studentNumbers = JSON.parse(req.query?.studentlist) || []
const courseCodes = JSON.parse(req.query?.courselist) || []
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { studentlist, courselist } = req.query
const studentNumbers: string[] = (await safeJSONParse(studentlist)) ?? []
const courseCodes: string[] = (await safeJSONParse(courselist)) ?? []
const { roles, studentsUserCanAccess, sisPersonId: teacherId } = req.user
if (!Array.isArray(studentNumbers)) {
return res.status(400).json({ error: 'Student numbers must be of type array' })
}
@@ -34,48 +42,60 @@ router.get('/', async (req: GetSearchRequest, res: Response) => {
return res.status(400).json({ error: 'Courses must be of type array' })
}
const answerTimeout = new Promise(resolve => setTimeout(resolve, 6000))
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
try {
// 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 teacherRightsToStudents = (await Promise.race([
importerClient!.post('/teacher-rights/', { teacherId, studentNumbers }),
answerTimeout,
])) as { data: string[] }
if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
studentsUserCanAccess.push(...teacherRightsToStudents.data)
}
} catch (error: any) {
// 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
])
)
: { data: null, error: null }
if (error) {
logger.error(`Importer teacher-rights request failed with message: ${error?.message}`)
} else if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
studentsUserCanAccess.push(...teacherRightsToStudents.data)
}
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
const completedCourses = await getCompletedCourses(filteredStudentNumbers, courseCodes)
const discardedStudentNumbers = difference(
studentNumbers,
completedCourses.students.map(student => student.studentNumber)
)
return res.json({ discardedStudentNumbers, ...completedCourses })
})
router.get('/searches', async (req: Request, res: Response) => {
export type GetSearchResBody = FoundSearch[]
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
interface CreateSearchRequest extends Request {
body: {
courselist: string[]
name: string
}
export type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
export type CreateSearchReqBody = {
courselist: string[]
name: string
}
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
const courseCodes = req.body?.courselist || []
const name = req.body?.name
const userId = req.user.id
@@ -98,26 +118,30 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
})
})
interface UpdateSearchRequest extends Request {
body: {
courselist: string[]
}
params: {
id: string
}
}
export type UpdateSearchParams = { id: string }
export type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
} | void>
export type UpdateSearchReqBody = { courselist: string[] }
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { courselist: courseCodes = [] } = req.body
const { id: userId } = req.user
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
const id = req.params?.id
const courseCodes = req.body?.courselist || []
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
@@ -127,22 +151,22 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
})
})
interface DeleteSearchRequest extends Request {
params: {
id: string
}
}
export type DeleteSearchParams = { id: string }
export type DeleteSearchResBody = CanError<string | void>
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { id: userId } = req.user
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
const id = req.params?.id
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
return res.json(id)
})
+12 -15
View File
@@ -1,39 +1,36 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
const router = Router()
interface ExcludedCoursesRequest extends Request {
body: {
courseCodes: string[]
curriculumVersion: string
}
params: {
code: string
}
export type ExcludedCoursesParams = { code: string }
export type ExcludedCoursesResBody = void
export type ExcludedCoursesReqBody = {
courseCodes: string[]
curriculumVersion: string
}
router.post('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
const result = await addExcludedCourses(programmeCode, courseCodes, curriculumVersion)
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
}
res.json(result)
res.status(201).end()
})
router.delete('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
const { code: programmeCode } = req.params
const { courseCodes, curriculumVersion } = req.body
const result = await removeExcludedCourses(programmeCode, courseCodes, curriculumVersion)
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
}
res.json(result)
res.status(204).end()
})
export default router
+2 -3
View File
@@ -1,6 +1,5 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import moment from 'moment'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
@@ -51,10 +50,10 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
}
} else {
const mergedCourse = mergedCourses[groupId]
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
@@ -1,11 +1,11 @@
import { Request, Response, Router } from 'express'
import { CurriculumPeriod } from '../models'
import { CurriculumPeriodModel } from '../models'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const result = await CurriculumPeriod.findAll()
const result = await CurriculumPeriodModel.findAll()
res.json(result)
})
@@ -1,5 +1,6 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
getOpenUniSearches,
createNewSearch,
@@ -7,59 +8,74 @@ import {
updateSearch,
} from '../services/openUni/openUniManageSearches'
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
import { safeJSONParse } from '../util'
const router = Router()
interface GetSearchRequest extends Request {
query: {
courselist: string
startdate: string
enddate: string
}
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
type SearchReqBody = never
type SearchQuery = {
courselist: string
startdate: string
enddate: string
}
router.get('/', async (req: GetSearchRequest, res: Response) => {
const courseCodes = JSON.parse(req.query?.courselist) || []
const startDate = req.query?.startdate !== 'null' ? new Date(req.query.startdate) : new Date(2017, 7, 1, 0, 0, 0)
const endDate = req.query?.enddate !== 'null' ? new Date(req.query.enddate) : new Date()
if (req.query?.enddate === 'null') {
endDate.setHours(23, 59, 59, 999)
}
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { courselist, startdate, enddate } = req.query
const courseCodes = (await safeJSONParse(courselist)) ?? []
if (!Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courses must be of type array' })
}
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
if (enddate === 'null') {
endDate.setHours(23, 59, 59, 999)
}
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
return res.json(customOpenUniSearches)
})
router.get('/searches', async (req: Request, res: Response) => {
const userId = req.user.id
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const { id: userId } = req.user
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
interface CreateSearchRequest extends Request {
body: {
courselist: string[]
name: string
}
type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type CreateSearchReqBody = {
courselist: string[]
name: string
}
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
const courseCodes = req.body?.courselist || []
const name = req.body?.name
const userId = req.user.id
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
const { courselist, name } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (courseCodes && !Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Course codes must be type of array' })
}
const createdSearch = await createNewSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create search' })
}
return res.status(201).json({
id: createdSearch.id,
userId: createdSearch.userId,
@@ -69,26 +85,36 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
})
})
interface UpdateSearchRequest extends Request {
body: {
courselist: string[]
}
params: {
id: string
}
type UpdateSearchParams = {
id: string
}
type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type UpdateSearchReqBody = {
courselist: string[]
}
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
const id = req.params?.id
const courseCodes = req.body?.courselist || []
const userId = req.user.id
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { courselist } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!id || !userId) {
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
@@ -98,22 +124,24 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
})
})
interface DeleteSearchRequest extends Request {
params: {
id: string
}
type DeleteSearchParams = {
id: string
}
type DeleteSearchResBody = CanError<string>
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { id: userId } = req.user
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
const id = req.params?.id
const userId = req.user.id
if (!id || !userId) {
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json(id)
})
@@ -26,7 +26,7 @@ router.post('/', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Students must be of type array' })
}
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
res.json(customPopulationSearch)
})
+1 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, Router } from 'express'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
import * as auth from '../middleware/auth'
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
@@ -22,7 +23,6 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
import logger from '../util/logger'
// Faculty uses a lot of tools designed for Study programme.
+151 -276
View File
@@ -3,297 +3,183 @@ import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import { difference, intersection, uniq } from 'lodash'
import { CanError } from '@oodikone/shared/routes'
import { GenderCode } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import { SISStudyRight } from '../models'
import { SISStudyRightModel } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { encrypt, decrypt, type EncrypterData } from '../services/encrypt'
import { encrypt, type EncrypterData } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
import { populationStudentsMerger, populationCourseStatsMerger } from '../services/statMerger'
import { findByTag, findByCourseAndSemesters } from '../services/students'
import { mapToProviders } from '../shared/util'
import { GenderCode, ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import { ApplicationError } from '../util/customErrors'
import { findByCourseAndSemesters } from '../services/students'
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
const router = Router()
const filterPersonalTags = (population: Record<string, any>, userId: string) => {
return {
...population,
students: population.students.map(student => {
student.tags = student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
return student
}),
}
}
type EncryptedStudent = EncrypterData
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
return (student as EncryptedStudent)?.encryptedData !== undefined
}
interface PostPopulationStatisticsRequest extends Request {
body: {
year: string
semesters: string[]
studyRights: string | string[]
months: number
selectedStudents: string[] | EncryptedStudent[]
selectedStudentsByYear?: { [year: string]: string[] }
studentStatuses?: string[]
years?: string[]
}
export type PopulationstatisticsCoursesResBody = CanError<Bottlenecks>
export type PopulationstatisticsCoursesReqBody = {
// NOTE: Encrypted students have their iv in selectedStudents
selectedStudents: string[] | EncryptedStudent[]
selectedStudentsByYear?: { [year: string]: string[] }
courses?: string[]
}
// POST instead of GET because of too long params and "sensitive" data
router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatisticsRequest, res: Response) => {
if (!req.body.year || !req.body.semesters || !req.body.studyRights) {
Sentry.captureException(new Error('The body should have a year, semester and study rights defined'))
return res.status(400).json({ error: 'The body should have a year, semester and study rights defined' })
}
// NOTE: POST instead of GET because of too long params and "sensitive" data
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
'/v4/populationstatistics/courses',
async (req, res) => {
const { roles, studentsUserCanAccess } = req.user
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
if (!Array.isArray(req.body.studyRights)) {
req.body.studyRights = [req.body.studyRights]
}
const hasFullAccess = hasFullAccessToStudentData(roles)
if (req.body.months == null) {
req.body.months = 12
}
const isEncrypted = selectedStudents.some(isEncryptedStudent)
const hasAccessToStudents =
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
const encrypted = isEncryptedStudent(req.body.selectedStudents[0])
if (
!encrypted &&
!hasFullAccessToStudentData(req.user.roles) &&
req.body.selectedStudents.some(student => !req.user.studentsUserCanAccess.includes(student))
) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
if (!req.body.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
}
if (
req.body.studentStatuses &&
!req.body.studentStatuses.every(
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
)
) {
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
if (req.body.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiCourseStatPromises = Promise.all(
req.body.years.map(year => {
if (req.body.selectedStudentsByYear) {
req.body.selectedStudents = encrypted
? (req.body.selectedStudents as EncryptedStudent[]).filter(isEncryptedStudent).map(decrypt)
: req.body.selectedStudentsByYear[year]
}
const newMonths = (upperYearBound - Number(year)) * 12
const query = { ...req.body, year, months: newMonths, selectedStudents: req.body.selectedStudents as string[] }
const coursestatistics = bottlenecksOf(query, null, encrypted)
return coursestatistics
})
)
const multiCourseStats = await multiCourseStatPromises
const result = populationCourseStatsMerger(multiCourseStats)
if (result.error) {
return res.status(400).json(result)
if (!hasAccessToStudents && !hasFullAccess) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
}
const requiredFields = [selectedStudents, selectedCourses]
if (requiredFields.some(field => !field)) {
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
}
const result = await bottlenecksOf(
selectedStudents as string[],
selectedStudentsByYear,
selectedCourses,
isEncrypted
)
return res.json(result)
}
if (encrypted) {
req.body.selectedStudents = (req.body.selectedStudents as EncryptedStudent[]).map(decrypt)
}
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
try {
const result = await bottlenecksOf(query, null, encrypted)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
}
})
interface PostPopulationStatisticsByTagRequest extends Request {
body: {
tag: string
}
}
router.post(
'/v2/populationstatistics/coursesbytag',
async (req: PostPopulationStatisticsByTagRequest, res: Response) => {
const { roles, studentsUserCanAccess } = req.user
const { tag } = req.body
if (!tag) {
return res.status(400).json({ error: 'The body should have a tag defined' })
}
const studentNumbers = await findByTag(tag)
const studentNumberList = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
try {
const result = await bottlenecksOf(
{
year: '1900',
studyRights: [],
semesters: ['FALL', 'SPRING'],
months: 10000,
tag,
},
studentNumberList
)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
}
}
)
interface PostPopulationStatisticsByStudentNumberListRequest extends Request {
body: {
studentnumberlist: string[]
year: string
studyRights: string[]
semesters: string[]
months: number
}
export type PopulationstatisticsResBody = CanError<{ students: any }>
export type PopulationstatisticsReqBody = never
export type PopulationstatisticsQuery = {
semesters: string[]
studentStatuses?: string[]
// NOTE: This param is a JSON -object
studyRights: string
year: string
months?: string
years?: string[]
}
router.post(
'/v2/populationstatistics/coursesbystudentnumberlist',
async (req: PostPopulationStatisticsByStudentNumberListRequest, res: Response) => {
const { roles, studentsUserCanAccess } = req.user
const {
body: { studentnumberlist, ...query },
} = req
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
if (!studentnumberlist) {
throw new ApplicationError('The body should have a studentnumberlist defined', 400)
const months = req.query.months ?? '12'
// NOTE: `year` isn't needed anymore if `years` is defined
const requiredFields = [year, semesters, requestedStudyRightsJSON]
if (requiredFields.some(field => !field)) {
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
}
const studentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
try {
const result = await bottlenecksOf(
{
year: query?.year ?? 1900,
studyRights: query?.studyRights ?? [],
semesters: query?.semesters ?? ['FALL', 'SPRING'],
months: query?.months ?? 10000,
},
studentNumbers
)
return res.json(result)
} catch (error: unknown) {
if (error instanceof Error) {
Sentry.captureException(error)
}
return res.status(400).end()
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
}
}
)
interface GetPopulationStatisticsRequest extends Request {
query: {
year: string
semesters: string[]
studyRights: string
months: string
years?: string[]
}
}
router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsRequest, res: Response) => {
const { year, semesters, studyRights: studyRightsJSON } = req.query
const { roles, programmeRights, id: userId } = req.user
if (!year || !semesters || !studyRightsJSON) {
res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
return
}
const studyRights: { programme: string; combinedProgramme: string } = JSON.parse(studyRightsJSON)
const fullProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeRightsCodes = programmeRights.map(({ code }) => code)
try {
if (
!hasFullAccessToStudentData(roles) &&
!programmeRightsCodes.includes(studyRights.programme) &&
!programmeRightsCodes.includes(studyRights.combinedProgramme)
) {
return res.status(403).json([])
const hasCorrectStatus = (studentStatuses: string[]) =>
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
} catch (error) {
return res.status(400).json({ error: 'The query had invalid studyRights' })
}
if (req.query.months === null) {
req.query.months = '12'
}
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
await safeJSONParse(requestedStudyRightsJSON)
if (requestedStudyRights === null) {
return res.status(400).json({ error: 'Invalid studyrights value!' })
}
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiPopulationStudentPromises = Promise.all(
req.query.years.map(year => {
const newMonths = (upperYearBound - Number(year)) * 12
const populationStudents = optimizedStatisticsOf({
...req.query,
year: Number(year),
studyRights: { programme: studyRights.programme },
months: newMonths,
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
let result
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiYearStudents = Promise.all(
req.query.years.map(year => {
const yearAsNumber = +year
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
return optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months: monthsForCurrentYear,
})
})
return populationStudents
)
const populationStudentsMerger = (multiYearStudents: any) => {
const samples = { students: [], courses: [] as any[] }
const uniqueCourseCodes = new Set<string>()
for (const year of multiYearStudents) {
samples.students = samples.students.concat(year.students)
for (const course of year.courses) {
if (!uniqueCourseCodes.has(course.code)) {
uniqueCourseCodes.add(course.code)
samples.courses.push(course)
}
}
}
return samples
}
result = populationStudentsMerger(await multiYearStudents)
} else {
result = await optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months,
})
)
const multiPopulationStudents = await multiPopulationStudentPromises
const result = populationStudentsMerger(multiPopulationStudents)
res.json(filterPersonalTags(result, userId))
} else {
const result: any = await optimizedStatisticsOf({
...req.query,
year: Number(req.query.year),
studyRights: { programme: studyRights.programme },
months: Number(req.query.months),
})
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
// Obfuscate if user has only limited study programme rights
// Obfuscate if user has only limited study programme rights and there are any students
if (
!hasFullAccessToStudentData(roles) &&
!fullProgrammeRights.includes(studyRights.programme) &&
!fullProgrammeRights.includes(studyRights.combinedProgramme)
'students' in result &&
!hasFullAccessToStudentData(userRoles) &&
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
) {
if (!('students' in result)) {
return
}
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
// 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: '',
@@ -305,7 +191,6 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
email: '',
secondaryEmail: '',
sis_person_id: '',
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
@@ -313,9 +198,9 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
})
}
res.json(filterPersonalTags(result, userId))
return res.json(result)
}
})
)
interface GetPopulationStatisticsByCourseRequest extends Request {
query: {
@@ -329,7 +214,7 @@ interface GetPopulationStatisticsByCourseRequest extends Request {
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
const { id, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
if (!coursecodes || !from || !to) {
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
@@ -354,10 +239,10 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
{
// Useless, because studentnumbers are already filtered above by from & to.
// We should probably refactor this to avoid more confusement.
year: 1900,
studyRights: [],
userId,
year: '1900',
studyRights: undefined,
semesters: ['FALL', 'SPRING'],
months: 10000,
},
studentNumbers
)
@@ -381,7 +266,7 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
courses,
gender_code,
}: {
studyRights: SISStudyRight[]
studyRights: SISStudyRightModel[]
studentNumber: string
courses: ParsedCourse[]
gender_code: GenderCode
@@ -401,18 +286,13 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
obfuscated: true,
})
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
if ('students' in result) {
result.students = result.students.map(student =>
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
)
}
res.json(filterPersonalTags(result, id))
res.json(result)
})
interface PostByStudentNumbersRequest extends Request {
@@ -427,32 +307,27 @@ interface PostByStudentNumbersRequest extends Request {
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
const { studentnumberlist, tags } = req.body
const { roles, id, studentsUserCanAccess } = req.user
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.includes('+')
? tags?.studyProgramme.split('+')[0]
: tags?.studyProgramme
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const result = await optimizedStatisticsOf(
{
year: tags?.year ? Number(tags.year) : 1900,
studyRights: studyProgrammeCode ? [studyProgrammeCode] : [],
userId,
year: tags?.year ?? '1900',
studyRights: studyProgrammeCode,
semesters: ['FALL', 'SPRING'],
months: 10000,
},
filteredStudentNumbers
)
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(500).json({ error: result.error })
}
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
res.status(200).json({ ...filterPersonalTags(resultWithStudyProgramme, id), discardedStudentNumbers })
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
})
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
@@ -1,12 +1,12 @@
import { Request, Response, Router } from 'express'
import { getCoursesAndModules, getCurriculumVersions } from '../services/programmeModules'
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
const router = Router()
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
const { code } = req.params
const result = await getCurriculumVersions(code)
const result = await getCurriculumOptions(code)
res.json(result)
})
+9 -10
View File
@@ -1,5 +1,6 @@
import { Request, Response, Router } from 'express'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import {
getBasicStats,
setBasicStats,
@@ -18,7 +19,6 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import logger from '../util/logger'
import { logInfoForGrafana } from '../util/logInfoForGrafana'
@@ -27,16 +27,16 @@ const router = Router()
interface GetCreditStatsRequest extends Request {
query: {
codes: string
isAcademicYear: string
includeSpecials: string
specialGroups: SpecialGroups
yearType: YearType
}
}
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
const { codes, isAcademicYear, includeSpecials } = req.query
const { codes, specialGroups, yearType } = req.query
const stats = {}
for (const code of JSON.parse(codes)) {
stats[code] = await getCreditsProduced(code, isAcademicYear !== 'false', includeSpecials !== 'false')
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
}
return res.json({ stats })
})
@@ -105,20 +105,19 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
interface GetCourseStatsRequest extends Request {
query: {
academicyear: string
combined_programme: string
combinedProgramme: string
yearType: YearType
}
}
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
const code = req.params.id
const showByYear = req.query.academicyear
const combinedProgramme = req.query?.combined_programme
const { combinedProgramme, yearType } = req.query
const date = new Date()
date.setHours(23, 59, 59, 999)
void logInfoForGrafana(code, combinedProgramme)
try {
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, showByYear, combinedProgramme)
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, yearType, combinedProgramme)
return res.json(data)
} catch (error) {
logger.error({ message: `Failed to get code ${code} programme courses stats`, meta: `${error}` })
@@ -10,12 +10,12 @@ const router = Router()
interface StudyProgrammeCriteriaRequest extends Request {
query: {
programmecode: string
programmeCode: string
}
}
router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
const studyProgramme = req.query.programmecode
const studyProgramme = req.query.programmeCode
if (studyProgramme !== '' && !studyProgramme) {
return res.status(422).end()
}
@@ -37,7 +37,10 @@ router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
return res.json(studyProgrammeCriteria)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
return res.status(201).end()
})
interface CriteriaCreditsRequest extends Request {
@@ -53,7 +56,10 @@ router.post('/credits', async (req: CriteriaCreditsRequest, res: Response) => {
return res.status(400).end()
}
const studyProgrammeCriteria = await saveYearlyCreditCriteria(code, credits)
return res.json(studyProgrammeCriteria)
if (!studyProgrammeCriteria) {
return res.status(404).end()
}
return res.status(201).end()
})
export default router
+46 -52
View File
@@ -1,6 +1,8 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference } from 'lodash'
import { CanError } from '@oodikone/shared/routes'
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
import { filterStudentNumbersByAccessRights } from '../services/students'
import {
findTagsByStudyTrack,
@@ -11,7 +13,6 @@ import {
findTagsFromStudyTrackById,
deleteMultipleStudentTags,
} from '../services/tags'
import { NewTag, Role, StudentTag, Tag } from '../shared/types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
@@ -36,13 +37,10 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
return combinedProgramme ? `${studyTrack}-${combinedProgramme}` : studyTrack
}
interface GetTagsByStudyTrackRequest extends Request {
params: {
studyTrack: string
}
}
export type GetTagsByStudyTrackParams = { studyTrack: string }
export type GetTagsByStudyTrackResBody = Tag[] | null
router.get('/tags/:studyTrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
router.get<GetTagsByStudyTrackParams, GetTagsByStudyTrackResBody>('/tags/:studyTrack', async (req, res) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
@@ -58,11 +56,10 @@ router.get('/tags/:studyTrack', async (req: GetTagsByStudyTrackRequest, res: Res
res.status(200).json(filterRelevantTags(tags, id))
})
interface PostTagRequest extends Request {
body: { tag: NewTag }
}
export type PostTagResBody = CanError<Tag[] | void>
export type PostTagReqBody = { tag: NewTag }
router.post('/tags', async (req: PostTagRequest, res: Response) => {
router.post<never, PostTagResBody, PostTagReqBody>('/tags', async (req, res) => {
const {
tag: { name, personalUserId, studyTrack, year },
} = req.body
@@ -83,11 +80,10 @@ router.post('/tags', async (req: PostTagRequest, res: Response) => {
res.status(200).json(filterRelevantTags(tags, id))
})
interface DeleteTagRequest extends Request {
body: { tag: Tag }
}
export type DeleteTagResBody = Tag[] | void
export type DeleteTagReqBody = { tag: Tag }
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
router.delete<never, DeleteTagResBody, DeleteTagReqBody>('/tags', async (req, res) => {
const {
tag: { id, personalUserId, studyTrack },
} = req.body
@@ -105,37 +101,36 @@ router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
res.status(200).json(filterRelevantTags(tags, id))
})
interface GetStudentTagsByStudyTrackRequest extends Request {
params: {
studyTrack: string
export type GetStudentTagsByStudyTrackParams = { studyTrack: string }
export type GetStudentTagsByStudyTrackResBody = StudentTag[] | null
router.get<GetStudentTagsByStudyTrackParams, GetStudentTagsByStudyTrackResBody>(
'/studenttags/:studyTrack',
async (req, res) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studyTrack)
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
if (lacksProgrammeAccess && !roles?.includes('admin')) {
return res.json(null)
}
const tags = await getStudentTagsByStudyTrack(studyTrack)
const relevantTags = filterRelevantStudentTags(tags, id)
res.status(200).json(relevantTags)
}
)
export type PostStudentTagsResBody = CanError<void>
export type PostStudentTagsReqBody = {
combinedProgramme: string
studentTags: StudentTag[]
studyTrack: string
}
router.get('/studenttags/:studyTrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studyTrack)
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
if (lacksProgrammeAccess && !roles?.includes('admin')) {
return res.json(null)
}
const tags = await getStudentTagsByStudyTrack(studyTrack)
const relevantTags = filterRelevantStudentTags(tags, id)
res.status(200).json(relevantTags)
})
interface PostStudentTagsRequest extends Request {
body: {
combinedProgramme: string
studentTags: StudentTag[]
studyTrack: string
}
}
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
router.post<never, PostStudentTagsResBody, PostStudentTagsReqBody>('/studenttags', async (req, res) => {
const { combinedProgramme, studentTags, studyTrack } = req.body
const { roles, programmeRights } = req.user
@@ -170,16 +165,15 @@ router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) =
res.status(204).end()
})
interface DeleteStudentTagsRequest extends Request {
body: {
combinedProgramme: string
tagId: string
studentNumbers: string[]
studyTrack: string
}
export type DeleteStudentTagsResBody = CanError<void>
export type DeleteStudentTagsReqBody = {
combinedProgramme: string
tagId: string
studentNumbers: string[]
studyTrack: string
}
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
router.delete<never, DeleteStudentTagsResBody, DeleteStudentTagsReqBody>('/studenttags', async (req, res) => {
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
const { roles, programmeRights } = req.user
+1 -1
View File
@@ -1,11 +1,11 @@
import { Request, Response, Router } from 'express'
import { mapToProviders } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
import { mapToProviders } from '../shared/util'
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
const router = Router()
+4 -4
View File
@@ -1,9 +1,10 @@
import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
import { magicFacultyCode } from '../config/organizationConstants'
import { Organization } from '../models'
import { OrganizationModel } from '../models'
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
import { countGraduationTimes, LevelGraduationStats } from '../services/faculty/facultyGraduationTimes'
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
@@ -15,16 +16,15 @@ import {
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import { Graduated, NameWithCode } from '../shared/types'
import logger from '../util/logger'
const router = Router()
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
const getProgrammeNames = (faculties: Organization[]) => {
const getProgrammeNames = (faculties: OrganizationModel[]) => {
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
const { name, code } = faculty.dataValues
const { name, code } = faculty.toJSON()
obj[faculty.code] = { code, ...name }
return obj
}, {})
+27 -33
View File
@@ -1,11 +1,12 @@
import { Request, Response, Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { Role } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { LANGUAGE_CODES, Language } from '../shared/language'
import { Role } from '../shared/types'
import logger from '../util/logger'
const router = Router()
@@ -26,13 +27,11 @@ interface UidRequest extends Request {
}
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
try {
const { uid } = req.params
const user = await userService.findOne(uid)
return res.json(user)
} catch (error: any) {
return res.status(400).json({ error: error.message })
}
const { uid } = req.params
const { data: user, error } = await tryCatch(userService.findOne(uid))
if (error) return res.status(400).json({ error: error.message })
return res.json(user)
})
interface ModifyRolesRequest extends Request {
@@ -44,12 +43,13 @@ interface ModifyRolesRequest extends Request {
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
const { username, roles } = req.body
try {
await userService.modifyAccess(username, roles)
res.status(204).end()
} catch (error) {
res.status(400).json(error)
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
return res.status(204).end()
})
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
@@ -64,18 +64,14 @@ interface EmailRequest extends Request {
}
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
const userEmail = req.body.email
if (!userEmail) {
return res.status(400).json({ error: 'Email address is missing' })
}
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
try {
await sendNotificationAboutAccessToUser(userEmail)
} catch (error: any) {
return res.status(500).json({ error: error.message })
}
logger.info('Succesfully sent message about Oodikone access to user')
res.status(200).end()
return res.status(200).end()
})
interface ElementsRequest extends Request {
@@ -108,18 +104,16 @@ interface ChangeLanguageRequest extends Request {
}
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
const {
body: { language },
} = req
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
return res.status(400).json('Invalid language')
}
try {
await userService.updateUser(req.user.username, { language })
return res.status(204).end()
} catch (error: any) {
return res.status(500).json({ error: error.message })
}
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
if (error) return res.status(500).json({ error: error.message })
return res.status(204).end()
})
export default router
@@ -1,7 +1,7 @@
import moment from 'moment'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -1,9 +1,10 @@
import { col, fn, Op, where } from 'sequelize'
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
import { CreditTypeCode, EnrollmentState } from '../types'
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>
interface StudentCredit {
date: Date
@@ -37,13 +38,16 @@ type StudentCredits = Record<
}
>
interface FormattedStudent extends StudentInfo {
export interface FormattedStudent extends StudentInfo {
studentNumber: string
allEnrollments: StudentEnrollment[]
}
interface StudentWithStudyplanNested
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
extends Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> {
studyplans: { included_courses: string[] }[]
}
@@ -53,7 +57,7 @@ interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplan
const getCourses = async (courseCodes: string[]) => {
const courses: Courses = (
await Course.findAll({
await CourseModel.findAll({
attributes: ['code', 'name', 'substitutions'],
where: where(fn('LOWER', col('code')), {
[Op.in]: courseCodes.map(code => code.toLowerCase()),
@@ -69,18 +73,19 @@ const getCredits = async (
courseCodes: string[],
studentNumbers: string[]
) => {
const credits: Array<Pick<Credit, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>> =
await Credit.findAll({
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
where: {
course_code: {
[Op.in]: fullCourseCodes,
},
student_studentnumber: {
[Op.in]: studentNumbers,
},
const credits: Array<
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
> = await CreditModel.findAll({
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
where: {
course_code: {
[Op.in]: fullCourseCodes,
},
})
student_studentnumber: {
[Op.in]: studentNumbers,
},
},
})
const formattedCredits = credits.map(credit => {
const originalCode = courseCodes.includes(credit.course_code)
? null
@@ -97,8 +102,8 @@ const getCredits = async (
}
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
const enrollments: Array<Pick<Enrollment, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
await Enrollment.findAll({
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
await EnrollmentModel.findAll({
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
where: {
course_code: {
@@ -125,7 +130,7 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
}
const getStudents = async (studentNumbers: string[]) => {
const students = await Student.findAll({
const students = await StudentModel.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
studentnumber: {
@@ -134,7 +139,7 @@ const getStudents = async (studentNumbers: string[]) => {
},
include: [
{
model: Studyplan,
model: StudyplanModel,
as: 'studyplans',
attributes: ['included_courses'],
},
@@ -148,7 +153,10 @@ const getStudents = async (studentNumbers: string[]) => {
})
}
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
export const getCompletedCourses = async (
studentNumbers: string[],
courseCodes: string[]
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
const courses = await getCourses(courseCodes)
const courseCodesSet = new Set(courseCodes)
courses.forEach(course => {
@@ -244,7 +252,7 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
// Omit allEnrollments, we're only supposed to show the recent, relevant enrollment,
// the user does not have rights to see all enrollments.
return {
students: students.map(({ allEnrollments, ...rest }) => rest),
students: students.map(student => omitKeys(student, ['allEnrollments'])),
courses,
}
}
@@ -1,7 +1,7 @@
import { orderBy } from 'lodash'
import { Op } from 'sequelize'
import { Course } from '../../models'
import { CourseModel } from '../../models'
import { CourseWithSubsId } from '../../types'
import { getSortRank } from '../../util/sortRank'
@@ -41,7 +41,7 @@ const codeLikeTerm = (code: string) => {
}
const getRawCourses = async (name: string, code: string) => {
return await Course.findAll({
return await CourseModel.findAll({
where: {
...nameLikeTerm(name),
...codeLikeTerm(code),
@@ -102,7 +102,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
}
export const getCoursesByCodes = (codes: string[]) => {
return Course.findAll({
return CourseModel.findAll({
where: {
code: {
[Op.in]: codes,
@@ -1,6 +1,12 @@
import { Name } from '../../shared/types'
import { EnrollmentState } from '../../types'
import { lengthOf, percentageOf } from '../../util'
import { Name, EnrollmentState } from '@oodikone/shared/types'
const lengthOf = (obj: object) => Object.keys(obj).length
const percentageOf = (num: number, denom: number) => {
if (denom === 0) {
return 0
}
return Math.round(((100 * num) / denom) * 100) / 100
}
const fall: string[] = []
const spring: string[] = []
@@ -95,15 +101,13 @@ export type CourseStatistics = {
}
export class CourseStatsCounter {
private studentsInTotal: number
private course: Course
private students: Students
private stats: Stats
private enrollments: DynamicEnrollments
private grades: Grades
constructor(code: string, name: Name, studentsInTotal: number) {
this.studentsInTotal = studentsInTotal
constructor(code: string, name: Name) {
this.course = {
code,
name,
@@ -176,16 +180,10 @@ export class CourseStatsCounter {
}
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
if (!this.enrollments[state]) {
this.enrollments[state] = new Set()
}
this.enrollments[state] ??= new Set()
this.enrollments[state].add(studentNumber)
if (!this.enrollments.semesters[semester]) {
this.enrollments.semesters[semester] = {}
}
if (!this.enrollments.semesters[semester][state]) {
this.enrollments.semesters[semester][state] = new Set()
}
this.enrollments.semesters[semester] ??= {}
this.enrollments.semesters[semester][state] ??= new Set()
this.enrollments.semesters[semester][state].add(studentNumber)
}
@@ -233,10 +231,9 @@ export class CourseStatsCounter {
private getPassingSemestersCumulative() {
const { passingSemesters } = this.stats
const attemptStats: Record<string, number> = {
BEFORE: passingSemesters.BEFORE,
}
const attemptStats: Record<string, number> = {}
attemptStats.BEFORE = passingSemesters.BEFORE
attemptStats['0-FALL'] = passingSemesters.BEFORE + passingSemesters['0-FALL']
attemptStats['0-SPRING'] = attemptStats['0-FALL'] + passingSemesters['0-SPRING']
@@ -250,8 +247,8 @@ export class CourseStatsCounter {
return attemptStats
}
public getFinalStats() {
const { stats, students } = this
public getFinalStats(populationCount: number): CourseStatistics {
const { stats, students, course, grades } = this
const allStudents = Object.keys(students.all)
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, allStudents)
@@ -262,14 +259,14 @@ export class CourseStatsCounter {
stats.failed = lengthOf(students.failed)
stats.improvedPassedGrade = lengthOf(students.improvedPassedGrade)
stats.percentage = percentageOf(stats.passed, stats.students)
stats.passedOfPopulation = percentageOf(stats.passed, this.studentsInTotal)
stats.triedOfPopulation = percentageOf(stats.students, this.studentsInTotal)
stats.passedOfPopulation = percentageOf(stats.passed, populationCount)
stats.triedOfPopulation = percentageOf(stats.students, populationCount)
stats.perStudent = percentageOf(stats.attempts, stats.passed + stats.failed) / 100
stats.passingSemestersCumulative = this.getPassingSemestersCumulative()
stats.totalStudents = stats.students
stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade)
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
return { stats, students, course: this.course, grades: this.grades } as CourseStatistics
return { stats, students, course, grades }
}
}
@@ -1,6 +1,6 @@
import { max, min } from 'lodash'
import { Name } from '../../shared/types'
import { Name } from '@oodikone/shared/types'
import { getSemesterNameByCode } from '../semesters'
import { FormattedProgramme, OrganizationDetails } from './helpers'
@@ -1,18 +1,27 @@
import { Op } from 'sequelize'
import { Credit, Student, Semester, Organization, Enrollment, SISStudyRight, SISStudyRightElement } from '../../models'
import { EnrollmentState, Unification } from '../../types'
import { EnrollmentState } from '@oodikone/shared/types'
import {
CreditModel,
StudentModel,
SemesterModel,
OrganizationModel,
EnrollmentModel,
SISStudyRightModel,
SISStudyRightElementModel,
} from '../../models'
import { Unification } from '../../types'
import { getIsOpen } from './helpers'
export const getCreditsForCourses = async (codes: string[], unification: Unification) => {
return await Credit.findAll({
return await CreditModel.findAll({
include: [
{
model: Student,
model: StudentModel,
attributes: ['studentnumber'],
},
{
model: Semester,
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
@@ -32,7 +41,7 @@ export const getCreditsForCourses = async (codes: string[], unification: Unifica
}
export const getStudentNumberToSrElementsMap = async (studentNumbers: string[]) => {
const studyRights = await SISStudyRight.findAll({
const studyRights = await SISStudyRightModel.findAll({
attributes: ['facultyCode', 'id', 'studentNumber'],
where: {
studentNumber: {
@@ -40,7 +49,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
},
},
include: {
model: Organization,
model: OrganizationModel,
attributes: ['name', 'code'],
},
})
@@ -52,7 +61,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
const studyRightIds = Object.keys(studyRightMap)
const studyRightElements = await SISStudyRightElement.findAll({
const studyRightElements = await SISStudyRightElementModel.findAll({
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
where: {
studyRightId: {
@@ -64,9 +73,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
const studentNumberToSrElementsMap = studyRightElements.reduce((obj, cur) => {
const studyRight = studyRightMap[cur.studyRightId]
const { studentNumber } = studyRight
if (!obj[studentNumber]) {
obj[studentNumber] = []
}
obj[studentNumber] ??= []
obj[studentNumber].push({ ...cur.toJSON(), studyRight })
return obj
}, {})
@@ -74,14 +81,14 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
}
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) => {
return await Enrollment.findAll({
return await EnrollmentModel.findAll({
include: [
{
model: Student,
model: StudentModel,
attributes: ['studentnumber'],
},
{
model: Semester,
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
startdate: {
@@ -1,6 +1,6 @@
import { Op } from 'sequelize'
import { Name } from '../../shared/types'
import { Name } from '@oodikone/shared/types'
import { Unification } from '../../types'
export type FormattedProgramme = {
+25 -27
View File
@@ -1,11 +1,11 @@
import crypto from 'crypto'
import moment from 'moment'
import { Op } from 'sequelize'
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
import { Name } from '../../shared/types'
import { EnrollmentState, Unification } from '../../types'
import { Name, EnrollmentState } from '@oodikone/shared/types'
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
import { Unification } from '../../types'
import { isOpenUniCourseCode } from '../../util'
import { dateIsBetween } from '../../util/datetime'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
@@ -22,7 +22,7 @@ const sortMainCode = (codes: string[]) => {
return codes.sort((a, b) => getSortRank(b) - getSortRank(a))
}
const formatStudyRightElement = (studyRightElement: SISStudyRightElement): FormattedProgramme => ({
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
code: studyRightElement.code,
name: studyRightElement.name,
startDate: studyRightElement.startDate,
@@ -55,9 +55,9 @@ type FormattedCredit = {
}
const parseCredit = (
credit: Credit,
credit: CreditModel,
anonymizationSalt: string | null,
studentNumberToSrElementsMap: Record<string, Array<SISStudyRightElement>>
studentNumberToSrElementsMap: Record<string, Array<SISStudyRightElementModel>>
) => {
const {
semester,
@@ -71,15 +71,13 @@ const parseCredit = (
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
let programmeOfCredit: SISStudyRightElement | undefined
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
if (!programmeOfCredit) {
programmeOfCredit = studyRightElements
const programmeOfCredit: SISStudyRightElementModel | undefined =
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
studyRightElements
.filter(studyRightElement =>
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
dateIsBetween(attainmentDate, studyRightElement.startDate, studyRightElement.endDate)
)
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
}
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
@@ -91,7 +89,7 @@ const parseCredit = (
attainmentDate,
courseCode,
grade,
passed: !Credit.failed(credit) || Credit.passed(credit) || Credit.improved(credit),
passed: !CreditModel.failed(credit) || CreditModel.passed(credit) || CreditModel.improved(credit),
studentNumber,
programmes,
credits,
@@ -118,9 +116,9 @@ type FormattedEnrollment = {
}
const parseEnrollment = (
enrollment: Enrollment,
enrollment: EnrollmentModel,
anonymizationSalt: string | null,
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
const {
studentnumber: studentNumber,
@@ -157,9 +155,9 @@ const getYearlyStatsOfNew = async (
unification: Unification,
anonymizationSalt: string | null,
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
const courseForSubs = await Course.findOne({
const courseForSubs = await CourseModel.findOne({
where: { code: courseCode },
})
@@ -175,7 +173,7 @@ const getYearlyStatsOfNew = async (
const [credits, enrollments, course] = await Promise.all([
getCreditsForCourses(codes, unification),
getEnrollmentsForCourses(codes, unification),
Course.findOne({
CourseModel.findOne({
where: {
code: courseCode,
},
@@ -242,9 +240,9 @@ const getYearlyStatsOfNew = async (
const statistics = await counter.getFinalStatistics(anonymizationSalt)
let substitutionCourses: Course[] | undefined
let substitutionCourses: CourseModel[] | undefined
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
substitutionCourses = await Course.findAll({
substitutionCourses = await CourseModel.findAll({
where: {
code: {
[Op.in]: codes,
@@ -266,7 +264,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
const maxAttainmentDate = new Date(
Math.max(
...(
await Course.findAll({
await CourseModel.findAll({
where: {
code: {
[Op.in]: courseCodes,
@@ -281,7 +279,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
const attainmentThreshold = new Date(maxAttainmentDate.getFullYear(), 0, 1)
attainmentThreshold.setFullYear(attainmentThreshold.getFullYear() - 6)
const credits = await Credit.findAll({
const credits = await CreditModel.findAll({
where: {
course_code: {
[Op.in]: courseCodes,
@@ -324,11 +322,11 @@ export const getCourseYearlyStats = async (
anonymizationSalt: string | null,
combineSubstitutions: boolean
) => {
const credits = await Credit.findAll({
const credits = await CreditModel.findAll({
attributes: ['student_studentnumber'],
where: { course_code: { [Op.in]: courseCodes } },
})
const enrollments = await Enrollment.findAll({
const enrollments = await EnrollmentModel.findAll({
attributes: ['studentnumber'],
where: {
course_code: {
@@ -385,10 +383,10 @@ export const getCourseYearlyStats = async (
export const getCourseProvidersForCourses = async (codes: string[]) => {
return (
await Organization.findAll({
await OrganizationModel.findAll({
attributes: ['code'],
include: {
model: Course,
model: CourseModel,
where: {
code: {
[Op.in]: codes,
@@ -1,11 +1,11 @@
import { CurriculumPeriod } from '../models'
import { CurriculumPeriodModel } from '../models'
export type CurriculumPeriods = Awaited<ReturnType<typeof getCurriculumPeriods>>
export const getCurriculumPeriods = async () => {
const curriculumPeriods = await CurriculumPeriod.findAll()
const curriculumPeriods = await CurriculumPeriodModel.findAll()
const result = curriculumPeriods.reduce<
Record<string, Pick<CurriculumPeriod, 'id' | 'name' | 'universityOrgId' | 'startDate' | 'endDate'>>
Record<string, Pick<CurriculumPeriodModel, 'id' | 'name' | 'universityOrgId' | 'startDate' | 'endDate'>>
>((acc, curriculumPeriod) => {
const { id, name, universityOrgId, startDate, endDate } = curriculumPeriod
acc[id] = { id, name, universityOrgId, startDate, endDate }
@@ -1,7 +1,7 @@
import { CustomPopulationSearch } from '../models/kone'
import { CustomPopulationSearchModel } from '../models/kone'
export const getCustomPopulationSearchesByUser = async (userId: string) => {
return CustomPopulationSearch.findAll({
return CustomPopulationSearchModel.findAll({
where: {
userId,
},
@@ -9,7 +9,7 @@ export const getCustomPopulationSearchesByUser = async (userId: string) => {
}
export const createCustomPopulationSearch = async (name: string, userId: string, students: string[]) => {
return CustomPopulationSearch.create({
return CustomPopulationSearchModel.create({
name,
userId,
students,
@@ -17,7 +17,7 @@ export const createCustomPopulationSearch = async (name: string, userId: string,
}
export const updateCustomPopulationSearch = async (userId: string, id: string, students: string[]) => {
const targetCustomPopulationSearch = await CustomPopulationSearch.findOne({
const targetCustomPopulationSearch = await CustomPopulationSearchModel.findOne({
where: {
id,
userId,
@@ -34,7 +34,7 @@ export const updateCustomPopulationSearch = async (userId: string, id: string, s
}
export const deleteCustomPopulationSearch = async (userId: string, id: string) => {
return CustomPopulationSearch.destroy({
return CustomPopulationSearchModel.destroy({
where: {
id,
userId,
@@ -1,9 +1,9 @@
import { Op } from 'sequelize'
import { ExcludedCourse } from '../models/kone'
import { ExcludedCourseModel } from '../models/kone'
export const addExcludedCourses = async (programmeCode: string, courseCodes: string[], curriculumVersion: string) => {
return ExcludedCourse.bulkCreate(
export const addExcludedCourses = async (courseCodes: string[], curriculumVersion: string, programmeCode: string) => {
return ExcludedCourseModel.bulkCreate(
courseCodes.map(courseCode => ({
programme_code: programmeCode,
curriculum_version: curriculumVersion,
@@ -13,11 +13,11 @@ export const addExcludedCourses = async (programmeCode: string, courseCodes: str
}
export const removeExcludedCourses = async (
programmeCode: string,
courseCodes: string[],
curriculumVersion: string
curriculumVersion: string,
programmeCode: string
) => {
return ExcludedCourse.destroy({
return ExcludedCourseModel.destroy({
where: {
programme_code: programmeCode,
curriculum_version: curriculumVersion,
@@ -1,11 +1,12 @@
import { groupBy, orderBy } from 'lodash'
import moment from 'moment'
import { InferAttributes, QueryTypes } from 'sequelize'
import { QueryTypes } from 'sequelize'
import { ProgrammeModule } from '@oodikone/shared/models'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { dbConnections } from '../../database/connection'
import { Organization, ProgrammeModule } from '../../models'
import { OrganizationModel } from '../../models'
import { CurriculumPeriods, getCurriculumPeriods } from '../curriculumPeriods'
const { sequelize } = dbConnections
@@ -20,10 +21,10 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
return { startDate: new Date('1800-08-01'), endDate: new Date('1801-08-01') }
}
type ProgrammeModuleWithRelevantAttributes = Pick<
InferAttributes<ProgrammeModule>,
'code' | 'name' | 'degreeProgrammeType'
> & { curriculumPeriodIds: string[]; progId: string }
type ProgrammeModuleWithRelevantAttributes = Pick<ProgrammeModule, 'code' | 'name' | 'degreeProgrammeType'> & {
curriculumPeriodIds: string[]
progId: string
}
// Some programme modules are not directly associated to a faculty (organization).
// Some have intermediate organizations, such as department, so the connection must be digged up
@@ -103,7 +104,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
export type ProgrammesOfOrganization = Awaited<ReturnType<typeof getDegreeProgrammesOfOrganization>>
export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurrentProgrammes: boolean) => {
const organization = await Organization.findOne({
const organization = await OrganizationModel.findOne({
attributes: ['id'],
where: {
code: facultyCode,
@@ -116,7 +117,7 @@ export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurr
}
export const getFacultyCodeById = async (facultyId: string) => {
const organization = await Organization.findOne({
const organization = await OrganizationModel.findOne({
attributes: ['code'],
where: {
id: facultyId,
@@ -1,7 +1,6 @@
import { cloneDeep } from 'lodash'
import { NameWithCode } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { NameWithCode, DegreeProgrammeType } from '@oodikone/shared/types'
import { getBasicStats, setBasicStats } from '../analyticsService'
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
import type { ProgrammesOfOrganization } from './faculty'
@@ -1,4 +1,4 @@
import { Name } from '../../shared/types'
import { Name } from '@oodikone/shared/types'
import { getCreditsProduced } from '../providerCredits'
import { isRelevantProgramme } from '../studyProgramme/studyProgrammeHelpers'
import { getDegreeProgrammesOfFaculty } from './faculty'
@@ -1,15 +1,11 @@
import { cloneDeep, omit } from 'lodash'
import { Name } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { Name, DegreeProgrammeType } from '@oodikone/shared/types'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
import {
getStudyTrackStatsForStudyProgramme,
ProgrammeOrStudyTrackGraduationStats,
} from '../studyProgramme/studyTrackStats'
import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrackStats'
import type { ProgrammesOfOrganization } from './faculty'
export type LevelGraduationStats = Omit<GraduationTimes['medians'][number], 'y'> & { median: number }
@@ -161,7 +157,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
if (!(programmeCode in graduationTimes)) continue
const statsForProgramme = graduationTimes[programmeCode] as ProgrammeOrStudyTrackGraduationStats
const statsForProgramme = graduationTimes[programmeCode]
const basicStats = statsForProgramme.medians.basic
const comboStats = statsForProgramme.medians.combo
const classSizesByYear = basicStats.reduce<Record<string, number>>(
@@ -1,7 +1,6 @@
import moment from 'moment'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType, DegreeProgrammeType } from '@oodikone/shared/types'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -1,8 +1,8 @@
import { cloneDeep } from 'lodash'
import moment from 'moment'
import { Graduated, SpecialGroups } from '@oodikone/shared/types'
import { rootOrgId } from '../../config'
import { Graduated, SpecialGroups } from '../../shared/types'
import { Unarray } from '../../types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
@@ -1,6 +1,6 @@
import { cloneDeep } from 'lodash'
import { DegreeProgrammeType } from '../../types'
import { DegreeProgrammeType } from '@oodikone/shared/types'
import { getGraduationStats, setGraduationStats } from '../analyticsService'
import { getGraduationStatsForStudyTrack } from '../studyProgramme/studyProgrammeGraduations'
import { getDegreeProgrammesOfFaculty, ProgrammesOfOrganization } from './faculty'
@@ -1,4 +1,4 @@
import { StatsType } from '../../shared/types'
import { StatsType } from '@oodikone/shared/types'
import { setCreditStats } from '../analyticsService'
import { computeCreditsProduced } from '../providerCredits'
import { getDegreeProgrammesOfFaculty } from './faculty'
@@ -1,7 +1,7 @@
const { orderBy } = require('lodash')
const { Op } = require('sequelize')
const { Course, Credit, Enrollment, SISStudyRight } = require('../models')
const { CourseModel, CreditModel, EnrollmentModel, SISStudyRightModel } = require('../models')
const { redisClient } = require('./redis')
const LANGUAGE_CENTER_REDIS_KEY = 'LANGUAGE_CENTER_DATA'
@@ -23,7 +23,7 @@ const getDifference = stats => {
}
const getLanguageCenterCourses = async () => {
const courses = await Course.findAll({
const courses = await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
[Op.or]: [{ code: { [Op.like]: 'KK%' } }, { code: { [Op.like]: 'AYKK%' } }],
@@ -92,7 +92,7 @@ const computeLanguageCenterData = async () => {
const courses = await getLanguageCenterCourses()
const autumnSemester2017 = 135
const credits = await Credit.findAll({
const credits = await CreditModel.findAll({
attributes: ['course_code', 'student_studentnumber', 'semestercode', 'attainment_date', 'studyright_id'],
where: {
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
@@ -102,7 +102,7 @@ const computeLanguageCenterData = async () => {
raw: true,
})
const enrollments = await Enrollment.findAll({
const enrollments = await EnrollmentModel.findAll({
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'studyright_id', 'state'],
where: {
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
@@ -112,7 +112,7 @@ const computeLanguageCenterData = async () => {
raw: true,
})
const studyRights = await SISStudyRight.findAll({ raw: true })
const studyRights = await SISStudyRightModel.findAll({ raw: true })
const attemptStudyRightToFacultyMap = studyRights.reduce((obj, cur) => {
if (!cur.id) return obj
@@ -1,13 +1,13 @@
import { Course, Credit, Enrollment, Student } from '../../models'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel } from '../../models'
export const formatCourseInfo = (course: Course) => {
export const formatCourseInfo = (course: CourseModel) => {
return {
label: course.code,
name: course.name,
}
}
export const formatOpenCredits = (credit: Credit) => {
export const formatOpenCredits = (credit: CreditModel) => {
return {
attainmentCourseCode: credit.course_code,
attainmentDate: credit.attainment_date,
@@ -16,7 +16,7 @@ export const formatOpenCredits = (credit: Credit) => {
}
}
export const formatOpenEnrollments = (enrollment: Enrollment) => {
export const formatOpenEnrollments = (enrollment: EnrollmentModel) => {
return {
enrollmentCourseCode: enrollment.course_code,
enrollmentDateTime: enrollment.enrollment_date_time,
@@ -24,7 +24,7 @@ export const formatOpenEnrollments = (enrollment: Enrollment) => {
}
}
export const formatStudentInfo = (student: Student) => {
export const formatStudentInfo = (student: StudentModel) => {
return {
studentNumber: student.studentnumber,
email: student.email,
@@ -6,7 +6,7 @@ import {
deleteOpenUniSearch,
} from './openUniSearches'
type FoundSearch = {
export type FoundSearch = {
id: string
userId: string
name: string
@@ -1,13 +1,14 @@
import { InferAttributes, Op, WhereOptions } from 'sequelize'
import { Op, WhereOptions } from 'sequelize'
import { Course, Credit, Enrollment, Student, SISStudyRight } from '../../models'
import { OpenUniPopulationSearch } from '../../models/kone'
import { EnrollmentState, ExtentCode } from '../../types'
import { SISStudyRight } from '@oodikone/shared/models'
import { EnrollmentState, ExtentCode } from '@oodikone/shared/types'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, SISStudyRightModel } from '../../models'
import { OpenUniPopulationSearchModel } from '../../models/kone'
import { formatCourseInfo, formatOpenCredits, formatOpenEnrollments, formatStudentInfo } from './format'
export const getCredits = async (courseCodes: string[], startdate: Date) =>
(
await Credit.findAll({
await CreditModel.findAll({
attributes: ['attainment_date', 'course_code', 'grade', 'student_studentnumber'],
where: {
course_code: {
@@ -22,7 +23,7 @@ export const getCredits = async (courseCodes: string[], startdate: Date) =>
export const getStudentInfo = async (studentNumbers: string[]) =>
(
await Student.findAll({
await StudentModel.findAll({
attributes: ['studentnumber', 'email', 'secondary_email'],
where: {
studentnumber: {
@@ -34,7 +35,7 @@ export const getStudentInfo = async (studentNumbers: string[]) =>
export const getEnrollments = async (courseCodes: string[], startDate: Date, endDate: Date) =>
(
await Enrollment.findAll({
await EnrollmentModel.findAll({
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
where: {
course_code: {
@@ -54,7 +55,7 @@ export const getEnrollments = async (courseCodes: string[], startDate: Date, end
export const getCourseNames = async (courseCodes: string[]) =>
(
await Course.findAll({
await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
code: {
@@ -65,7 +66,7 @@ export const getCourseNames = async (courseCodes: string[]) =>
).map(formatCourseInfo)
export const getStudyRights = async (studentNumbers: string[]) => {
const where: WhereOptions<InferAttributes<SISStudyRight>> = {
const where: WhereOptions<SISStudyRight> = {
extentCode: {
[Op.in]: [
ExtentCode.BACHELOR,
@@ -83,14 +84,14 @@ export const getStudyRights = async (studentNumbers: string[]) => {
}
}
return await SISStudyRight.findAll({
return await SISStudyRightModel.findAll({
attributes: ['startDate', 'endDate', 'studentNumber'],
where,
})
}
export const getOpenUniSearchesByUser = async (userId: string) => {
return await OpenUniPopulationSearch.findAll({
return await OpenUniPopulationSearchModel.findAll({
where: {
userId,
},
@@ -98,7 +99,7 @@ export const getOpenUniSearchesByUser = async (userId: string) => {
}
export const createOpenUniPopulationSearch = async (userId: string, name: string, courseCodes: string[]) => {
return await OpenUniPopulationSearch.create({
return await OpenUniPopulationSearchModel.create({
userId,
name,
courseCodes,
@@ -106,7 +107,7 @@ export const createOpenUniPopulationSearch = async (userId: string, name: string
}
export const updateOpenUniPopulationSearch = async (userId: string, id: string, courseCodes: string[]) => {
const searchToUpdate = await OpenUniPopulationSearch.findOne({
const searchToUpdate = await OpenUniPopulationSearchModel.findOne({
where: {
userId,
id,
@@ -121,7 +122,7 @@ export const updateOpenUniPopulationSearch = async (userId: string, id: string,
}
export const deleteOpenUniSearch = async (userId: string, id: string) => {
return await OpenUniPopulationSearch.destroy({
return await OpenUniPopulationSearchModel.destroy({
where: {
userId,
id,
@@ -1,6 +1,6 @@
import moment from 'moment'
import { SISStudyRight } from '../../models'
import { SISStudyRightModel } from '../../models'
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
type CourseInfo = {
@@ -46,15 +46,15 @@ const getAllCourseCodes = (courseCodes: string[]) => {
return courseCodes.concat(ayCourseCodes)
}
const isStartDateOutsideInterval = (studyRight: SISStudyRight, startDate: Date) => {
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
return moment(studyRight.startDate).isBetween(startDate, moment())
}
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRight, startDate: Date) => {
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
return moment(studyRight.startDate).isSameOrBefore(startDate) && moment(studyRight.endDate).isSameOrAfter(moment())
}
const isEndDateBeforeNow = (studyRight: SISStudyRight) => moment(studyRight.endDate).isSameOrBefore(moment())
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => moment(studyRight.endDate).isSameOrBefore(moment())
const getEmptyCourseInfo = (): CourseInfo => ({
status: {
@@ -2,10 +2,10 @@ import { Op, QueryTypes } from 'sequelize'
import { facultyCodes } from '../config/organizationConstants'
import { dbConnections } from '../database/connection'
import { Organization } from '../models'
import { OrganizationModel } from '../models'
export const getOrganizations = () => {
return Organization.findAll({
return OrganizationModel.findAll({
where: {
code: {
[Op.in]: facultyCodes,
@@ -1,65 +1,19 @@
import { keyBy } from 'lodash'
import { getPassingSemester } from '../../util/semester'
import { CourseStatistics, CourseStatsCounter } from '../courses/courseStatsCounter'
import { encrypt } from '../encrypt'
import {
CoursesQueryResult,
dateMonthsFromNow,
EnrollmentsQueryResult,
findCourses,
findCourseEnrollments,
Params,
parseCreditInfo,
parseQueryParams,
Query,
} from './shared'
import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements'
import { type CourseStatistics, CourseStatsCounter } from '../courses/courseStatsCounter'
import { encrypt, decrypt, EncrypterData } from '../encrypt'
import { findCourses, parseCreditInfo } from './shared'
const getStudentsAndCourses = async (
params: Params,
selectedStudents: string[] | undefined,
studentNumbers: string[] | null,
courseCodes: string[] | undefined
): Promise<[number, CoursesQueryResult, EnrollmentsQueryResult]> => {
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
const studentnumbers =
studentNumbers ??
selectedStudents ??
(await getStudentNumbersWithAllStudyRightElements({
studyRights,
startDate,
endDate,
exchangeStudents,
nondegreeStudents,
transferredOutStudents: transferredStudents,
}))
const allStudents = studentnumbers.length
const beforeDate = months && startDate ? dateMonthsFromNow(startDate, months) : new Date()
const courses = !studentNumbers
? await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
: await findCourses(studentnumbers, beforeDate, courseCodes)
const foundCourseCodes = [...new Set(courses.map(({ code }) => code))].toSorted()
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
const courseEnrollments = filteredCourseCodes?.length
? !studentNumbers
? await findCourseEnrollments(studentnumbers, dateMonthsFromNow(startDate, months), filteredCourseCodes)
: await findCourseEnrollments(studentnumbers, beforeDate, filteredCourseCodes)
: []
return [allStudents, courses, courseEnrollments]
}
type Bottlenecks = {
coursestatistics: CourseStatistics[]
export type Bottlenecks = {
allStudents: number
coursestatistics: CourseStatistics[]
}
export const bottlenecksOf = async (query: Query, studentNumbers: string[] | null, encryptData = false) => {
export const bottlenecksOf = async (
selectedStudents: string[],
selectedStudentsByYear: { [year: string]: string[] },
selectedCourses: string[],
isEncrypted = false
) => {
const encryptStudentNumbers = (bottlenecks: Bottlenecks) => {
for (const course of Object.keys(bottlenecks.coursestatistics)) {
const encryptedStudentStats = {}
@@ -75,91 +29,58 @@ export const bottlenecksOf = async (query: Query, studentNumbers: string[] | nul
}
}
const params = parseQueryParams(query)
const allStudentsByYears = query?.selectedStudentsByYear
? Object.keys(query.selectedStudentsByYear).reduce(
(res, year) => [...res, ...query.selectedStudentsByYear![year]],
[] as string[]
)
: []
// HACK: Encrypted students can't be decrypted if passed as strings
if (isEncrypted) selectedStudents = (selectedStudents as unknown as EncrypterData[]).map(decrypt)
// To fix failed and enrolled, no grade filter options some not so clean and nice solutions were added
// Get the data with actual 1. courses and filtered students. 2. all students by year, if provided.
const [[allStudents, courses, courseEnrollements], [, allCourses]] = await Promise.all([
getStudentsAndCourses(params, query.selectedStudents, studentNumbers, query.courses),
getStudentsAndCourses(params, allStudentsByYears, null, query.courses),
])
const courses = await findCourses(selectedStudents, selectedCourses)
// Get the substitution codes for the fetch data by selected students
const substitutionCodes = Object.entries(courses).reduce(
(codes, [, course]) => [...codes, ...(course?.substitutions || [])],
[] as string[]
)
const codes = Object.keys(keyBy(courses, 'code')).map(code => code)
// Filter substitution courses for fetched courses -> by this we avoid the situation in which there
// are only courses with old course codes. Frontend NEEDS in most cases the current course.
const substitutionCourses = allCourses.filter(
course => substitutionCodes.includes(course.code) && !codes.includes(course.code)
)
const bottlenecks: Bottlenecks = {
coursestatistics: [],
allStudents: 0,
}
const stats = {} as Record<string, CourseStatsCounter>
const startYear = parseInt(query.year, 10)
let coursesToLoop: Array<EnrollmentsQueryResult[number] | CoursesQueryResult[number]> =
courses.concat(substitutionCourses)
const courseCodes = coursesToLoop.map(course => course.code)
// This fixes a problem when "Enrolled, no grade" is chosen. The SQL query for fetching
// credits does not fetch enrollments if no credits are found for selected students.
// This and other SQL query ensures that enrollments are added.
const coursesOnlyWithEnrollments = courseEnrollements.filter(course => !courseCodes.includes(course.code))
coursesToLoop = coursesToLoop.concat(coursesOnlyWithEnrollments)
const coursesByCode = keyBy(coursesToLoop, 'code')
for (const course of coursesToLoop) {
let mainCourse = course
if (course.main_course_code && course.main_course_code !== course.code) {
const newMainCourse = coursesByCode[course.main_course_code]
if (newMainCourse) {
mainCourse = newMainCourse
// HACK: If year isn't present or is invalid, return MAGIC_NUMBER.
// The new degree programs start on 2017.
const getYear = (studentnumber: string): number => {
const MAGIC_NUMBER = 2017
for (const year in selectedStudentsByYear) {
if (selectedStudentsByYear[year].includes(studentnumber)) {
return parseInt(year, 10) || MAGIC_NUMBER
}
}
if (!stats[mainCourse.code]) {
stats[mainCourse.code] = new CourseStatsCounter(mainCourse.code, mainCourse.name, allStudents)
}
const coursestats = stats[mainCourse.code]
coursestats.addCourseSubstitutions(course.substitutions)
if (course.enrollments) {
course.enrollments.forEach(({ studentnumber, state, enrollment_date_time }) => {
if (query?.selectedStudents?.includes(studentnumber) || !query?.selectedStudents) {
const semester = getPassingSemester(startYear, enrollment_date_time)
coursestats.markEnrollment(studentnumber, state, semester)
}
})
}
if ('credits' in course) {
course.credits?.forEach(credit => {
const { studentnumber, passingGrade, improvedGrade, failingGrade, grade, date } = parseCreditInfo(credit)
if (query?.selectedStudents?.includes(studentnumber) || !query?.selectedStudents) {
const semester = getPassingSemester(startYear, date)
coursestats.markCredit(studentnumber, grade, passingGrade, failingGrade, improvedGrade, semester)
}
})
}
stats[mainCourse.code] = coursestats
return MAGIC_NUMBER
}
bottlenecks.coursestatistics = Object.values(stats).map(coursestatistics => coursestatistics.getFinalStats())
bottlenecks.allStudents = allStudents
const selectedStudentCount = selectedStudents.length
const stats = {} as Record<string, CourseStatsCounter>
if (encryptData) {
for (const course of courses) {
const { code: mainCode, name: mainName } = courses.find(({ code }) => code == course?.main_course_code) ?? course
const coursestats = stats[mainCode] ?? new CourseStatsCounter(mainCode, mainName)
coursestats.addCourseSubstitutions(course.substitutions)
course.enrollments?.forEach(({ studentnumber, state, enrollment_date_time }) => {
// HACK: date should already be Date object
const initialDate = new Date(enrollment_date_time)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
coursestats.markEnrollment(studentnumber, state, semester)
})
course.credits
?.map(parseCreditInfo)
.forEach(({ studentnumber, passingGrade, improvedGrade, failingGrade, grade, date }) => {
// HACK: date should already be Date object
const initialDate = new Date(date)
const semester = getPassingSemester(getYear(studentnumber), initialDate)
coursestats.markCredit(studentnumber, grade, passingGrade, failingGrade, improvedGrade, semester)
})
stats[mainCode] = coursestats
}
const bottlenecks: Bottlenecks = {
allStudents: selectedStudentCount,
coursestatistics: Object.values(stats).map(coursestatistics =>
coursestatistics.getFinalStats(selectedStudentCount)
),
}
if (isEncrypted) {
encryptStudentNumbers(bottlenecks)
}
@@ -1,17 +1,24 @@
import { col, InferAttributes, Op, where } from 'sequelize'
import { col, Op, where } from 'sequelize'
import { Credit, Student, Studyplan } from '@oodikone/shared/models'
import {
Course,
Credit,
Organization,
ProgrammeModule,
Student,
Studyplan,
SISStudyRight,
SISStudyRightElement,
Name,
CreditTypeCode,
DegreeProgrammeType,
EnrollmentType,
ExtentCode,
SemesterEnrollment,
} from '@oodikone/shared/types'
import {
CourseModel,
CreditModel,
OrganizationModel,
ProgrammeModuleModel,
StudentModel,
StudyplanModel,
SISStudyRightModel,
SISStudyRightElementModel,
} from '../../models'
import { Name } from '../../shared/types'
import { CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode, SemesterEnrollment } from '../../types'
import { redisClient } from '../redis'
import { getCurrentSemester } from '../semesters'
import { getCurriculumVersion } from './shared'
@@ -82,13 +89,9 @@ const findThesisAndLatestAndEarliestAttainments = (
) {
thesisData = attainment
}
if (!attainmentDates.latestTotal) {
attainmentDates.latestTotal = attainment.attainment_date
}
attainmentDates.latestTotal ??= attainment.attainment_date
if (studyPlan.included_courses.includes(attainment.course?.code)) {
if (!attainmentDates.latestHops) {
attainmentDates.latestHops = attainment.attainment_date
}
attainmentDates.latestHops ??= attainment.attainment_date
if (
!attainmentDates.earliestHops ||
attainment.attainment_date.getTime() < attainmentDates.earliestHops.getTime()
@@ -101,11 +104,7 @@ const findThesisAndLatestAndEarliestAttainments = (
return { attainmentDates, thesisData }
}
const formatStudent = (
student: InferAttributes<Student>,
facultyMap: Record<string, Name>,
currentSemesterCode: number
) => {
const formatStudent = (student: Student, facultyMap: Record<string, Name>, currentSemesterCode: number) => {
const {
studentnumber: studentNumber,
abbreviatedname: name,
@@ -191,7 +190,7 @@ const formatStudent = (
export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) => {
const students = (
await Student.findAll({
await StudentModel.findAll({
attributes: [
'abbreviatedname',
'creditcount',
@@ -211,18 +210,18 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
: {},
include: [
{
model: Studyplan,
model: StudyplanModel,
attributes: ['completed_credits', 'included_courses', 'programme_code', 'curriculum_period_id'],
required: true,
include: [
{
model: SISStudyRight,
model: SISStudyRightModel,
as: 'studyRight',
attributes: ['id', 'semesterEnrollments', 'startDate', 'extentCode'],
where: { cancelled: false },
include: [
{
model: SISStudyRightElement,
model: SISStudyRightElementModel,
as: 'studyRightElements',
where: {
graduated: false,
@@ -279,23 +278,23 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
],
},
{
model: Credit,
model: CreditModel,
attributes: ['attainment_date', 'grade', 'studyright_id'],
where: { credittypecode: CreditTypeCode.PASSED },
include: [
{
model: Course,
model: CourseModel,
attributes: ['code', 'course_unit_type'],
},
],
},
],
order: [[{ model: Credit, as: 'credits' }, 'attainment_date', 'DESC']],
order: [[{ model: CreditModel, as: 'credits' }, 'attainment_date', 'DESC']],
})
).map(student => student.toJSON())
const programmeCodes = [...new Set(students.flatMap(student => student.studyplans.map(sp => sp.programme_code)))]
const programmesWithFaculties = await ProgrammeModule.findAll({
const programmesWithFaculties = await ProgrammeModuleModel.findAll({
attributes: ['code'],
where: {
code: {
@@ -303,7 +302,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
},
},
include: {
model: Organization,
model: OrganizationModel,
attributes: ['name'],
},
raw: true,

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