Compare commits

...
356 Commits
Author SHA1 Message Date
Riku RauhalaandGitHub 1976d2914d Merge pull request #4750 from UniversityOfHelsinkiCS/material-ui-study-programme-basic-information
Material UI: Study programme overview - Basic information tab
2025-02-27 16:09:23 +02:00
Riku Rauhala 52e6a49fd3 [Students] Update tests 2025-02-27 15:56:01 +02:00
Riku Rauhala fc4e48aaa0 [University] Update tests 2025-02-27 15:43:42 +02:00
Riku Rauhala ae83dc3563 [Study programme] Update tests 2025-02-27 14:14:16 +02:00
Riku Rauhala 2109cfcf87 [Study programme] Update Basic information tab to MUI 2025-02-27 14:14:16 +02:00
Riku Rauhala 1a3facf680 [Study programme] Convert BasicInformationTab into TypeScript 2025-02-27 14:14:16 +02:00
Valtteri Kantanen d016cec63c [Updater] Use BullMQ to schedule student updates 2025-02-26 18:02:09 +02:00
Valtteri Kantanen 74bfceaf72 [Updater] Add initial BullMQ setup 2025-02-26 17:27:15 +02:00
Valtteri Kantanen a549ae5095 [Updater] Make CHUNK_SIZE configurable via environment variable 2025-02-26 14:27:47 +02:00
Valtteri Kantanen 863cb9d134 [Backend] Make cron job schedule configurable 2025-02-26 14:27:02 +02:00
Riku RauhalaandGitHub eb2576bfad Merge pull request #4749 from UniversityOfHelsinkiCS/OOD-82
[UserPage] Added service provider check to show IamGroupsCard
2025-02-26 14:04:41 +02:00
Matti Räsänen 77831b3bcf [UserPage] Added service provider to IamGroupsCard 2025-02-26 13:51:10 +02:00
Valtteri Kantanen 99ff14b13f [Updater] Add REDIS_PORT env variable to updater-worker and updater-scheduler 2025-02-26 13:25:55 +02:00
Riku Rauhala e3c2427885 [Faculties] Remove unnecessary Section wrappers
Having the tables wrapped in a Section without any loading status leads to a more cluttered UI and unnecessary borders
2025-02-26 12:23:05 +02:00
Riku Rauhala a6e52a690f [Users] Improve user page responsiveness 2025-02-26 12:05:14 +02:00
Riku RauhalaandGitHub eac4a03ea8 Merge pull request #4748 from UniversityOfHelsinkiCS/material-ui-study-programme-update-statistics
Material UI: Study programme overview - Update statistics tab
2025-02-26 10:42:08 +02:00
Riku Rauhala def64d5612 [Study programme] Update tests 2025-02-26 10:30:42 +02:00
Riku Rauhala 9ddd42aced [Study programme] Update the Update statistics tabs to MUI 2025-02-26 09:31:11 +02:00
Riku Rauhala 60aa43d520 [Faculties] Extract component UpdateButton
This is also needed in StudyProgramme
2025-02-26 09:08:59 +02:00
Riku Rauhala 49be4b4f54 [Study programme] Refactor tabs to handle Funidata/admin 2025-02-26 09:05:02 +02:00
Riku Rauhala 2f5c68f439 [PageTitle] Display a skeleton when loading the title
In some cases the title comes from the API and there is a slight delay. From a UX perspective, it is better to display a skeleton for a brief second than flashing another text such as Faculties/Programmes
2025-02-25 21:41:50 +02:00
Riku Rauhala 54321f349c [Languages] Export type for GetTextIn
It's getting annoying to always type this mess separately, let's just export the type and use it instead
2025-02-25 20:12:29 +02:00
Riku Rauhala 9aba7ab5c5 [Study programme] Upgrade tabs to MUI 2025-02-25 19:56:44 +02:00
Riku Rauhala 059ccbd42b [Faculties] Use Stack for layout 2025-02-25 19:42:52 +02:00
Joonas Ahovalli 733cfac1be [Cypress] Add class statistics student tab tests 2025-02-25 17:26:46 +02:00
Joonas Ahovalli bcf1bca28b [Cypress] Change population statistics test structure to mimic that of the actual page 2025-02-25 14:19:23 +02:00
Riku RauhalaandGitHub 025aca0c15 Merge pull request #4747 from UniversityOfHelsinkiCS/material-ui-study-programme-selector
Material UI: Study programme overview - Programme selector
2025-02-24 20:07:19 +02:00
Riku Rauhala 00c4a6b409 [Study programme] Write tests for study programme selector 2025-02-24 19:53:25 +02:00
Riku Rauhala 9be9509562 [Study programme] Rename test file for consistency 2025-02-24 18:57:02 +02:00
Riku Rauhala 9e6a92c114 [Loading] Extract new component for loading spinner 2025-02-24 18:41:21 +02:00
Riku Rauhala 178ff11b48 [Study programme] Rewrite StudyProgrammeSelector with MUI 2025-02-24 18:27:56 +02:00
Riku Rauhala e7a1cdf098 [Util] Extract comparator functions 2025-02-24 11:11:48 +02:00
Riku Rauhala 60f10faf9e [Util] Extract getCombinedProgrammeName
Let's also rename this from unified programme to combined programme for consistency
2025-02-24 10:35:25 +02:00
Riku Rauhala b31b3543bd [Redux] Type studyProgrammePins 2025-02-24 10:14:22 +02:00
Riku Rauhala b4263f29c5 [Study programme] Convert StudyProgrammeSelector into TypeScript 2025-02-24 09:49:35 +02:00
Riku Rauhala 6a69a3b98b [Study programme] Move to pages 2025-02-24 09:22:20 +02:00
Joonas Ahovalli 921fa2d1de [Class statistics] Add loading indicator to modules tab if no data, prevent page crash 2025-02-21 15:59:21 +02:00
Riku Rauhala c4724f8c07 [Lint-staged] Check for type errors before commit 2025-02-21 15:08:00 +02:00
Joonas Ahovalli 44a7f8bcee [Class statistics] Fix page crash when opening courses tab before data has finished loading 2025-02-21 14:48:10 +02:00
Riku Rauhala a632f3757d [Teachers] Move access checking to access utils 2025-02-21 14:23:26 +02:00
Riku Rauhala a84ad67b76 [Hooks] Remove useStatusNotification 2025-02-21 14:06:40 +02:00
Riku Rauhala 29331e13d3 [Languages] Simplify context
No reason to wrap LanguageContext in a CommonContext
2025-02-21 13:48:55 +02:00
Riku Rauhala 0358cb49a6 [StatusNotification] Switch to context 2025-02-21 13:48:05 +02:00
Valtteri Kantanen 78719ee09a [Updater] Add new REDIS_HOST env variable to updater-worker and updater-scheduler 2025-02-21 13:41:08 +02:00
Valtteri Kantanen e66542fe89 [Updater] Add indexes to enrollment and studyplan tables to make updating students significantly faster 2025-02-21 13:38:05 +02:00
Riku Rauhala ca9d43b4a9 [Hooks] Extract a new hook for using StatusNotification 2025-02-21 13:16:45 +02:00
Riku Rauhala 286b4a90b4 [Users] Allow filtering by IAM groups in the Users table 2025-02-21 12:20:45 +02:00
Joonas AhovalliandGitHub cfa65da634 Merge pull request #4746 from UniversityOfHelsinkiCS/material-ui-users
Material UI: Users
2025-02-21 12:00:26 +02:00
Riku Rauhala 3c81a8cbcd [Users] Update tests 2025-02-21 10:12:43 +02:00
Riku Rauhala 444a261261 [Frontend] Fix TypeScript issues 2025-02-20 19:45:12 +02:00
Riku Rauhala a5928b0dba [Users] Remove EmailNotification 2025-02-20 19:36:34 +02:00
Riku Rauhala 27ab354a70 [Users] Only copy email addresses of visible users 2025-02-20 19:30:54 +02:00
Riku Rauhala e293eb8471 [Users] Rewrite email preview with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 4dcacbb2ce [Users] Extract component CardHeader 2025-02-20 19:30:54 +02:00
Riku Rauhala 122eb29068 [Users] Add IAM groups card to UserPage 2025-02-20 19:30:54 +02:00
Riku Rauhala d5e6b52316 [Users] Rewrite StudyProgrammesCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 601d69cc3d [Users] Update info box text 2025-02-20 19:30:54 +02:00
Riku Rauhala e8f73adc81 [Populations] Type studyProgramme endpoint 2025-02-20 19:30:54 +02:00
Riku Rauhala c0187ad18e [Users] Prevent crashing and improve typing 2025-02-20 19:30:54 +02:00
Riku Rauhala f877e1187d [Users] Extract component EditButton 2025-02-20 19:30:54 +02:00
Riku Rauhala 3d86be5b9a [Users] Improve RolesCard 2025-02-20 19:30:54 +02:00
Riku Rauhala fe9aad8043 [Backend] Use term roles instead of access groups
The term access group seems outdates and the same thing is referred to as roles elsewhere
2025-02-20 19:30:54 +02:00
Riku Rauhala 34d9855782 [Users] Rewrite RolesCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala e1213bd768 [Constants] Add roles 2025-02-20 19:30:54 +02:00
Riku Rauhala 5d6df13d8e [Users] Move RoleChip to components
This will be used on the UserPage too
2025-02-20 19:30:54 +02:00
Riku Rauhala bf08700807 [Users] Rewrite InfoCard with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala c95b5a9cef [Users] Move MockButton under components/material 2025-02-20 19:30:54 +02:00
Riku Rauhala cd7766982b [Users] Move UserPage into a subfolder 2025-02-20 19:30:54 +02:00
Riku Rauhala 02932036fe [Users] Rewrite NewUserSection with MUI 2025-02-20 19:30:54 +02:00
Riku Rauhala 7b3bea1f9c [Users] Display a status message when loading/error 2025-02-20 19:30:54 +02:00
Riku Rauhala 09dec983aa [Users] Rewrite UsersTable with MUI and MRT 2025-02-20 19:30:54 +02:00
Riku Rauhala 3e8ac19af1 [Redux] Type getUsers 2025-02-20 19:30:54 +02:00
Riku Rauhala 93aacd3e16 [Theme] Add role colors 2025-02-20 19:30:54 +02:00
Riku Rauhala 29108ca2b2 [Users] Rewrite UsersTable with MUI/MRT 2025-02-20 19:30:54 +02:00
Riku Rauhala bfa14c9959 [Users] Rewrite index with MUI 2025-02-20 19:30:53 +02:00
Riku Rauhala 836f797da9 [Users] Convert to TypeScript 2025-02-20 19:30:53 +02:00
Riku Rauhala 45ca6f0083 [Users] Move to pages 2025-02-20 19:30:53 +02:00
Joonas Ahovalli 2b44529ed4 [Modules Tab] Add ordering by module code 2025-02-20 17:40:55 +02:00
Joonas Ahovalli e27d162921 [Modules Tab] Fix displayed modules not matching those shown in 'courses of class' in some cases 2025-02-20 17:21:50 +02:00
Valtteri Kantanen 7ebddf076f [Class statistics] Fix course queries being fired endlessly for users with only IAM rights 2025-02-20 15:03:08 +02:00
Joonas Ahovalli d0283bbc34 [Cypress] Fix broken study guidance group test 2025-02-20 13:27:04 +02:00
Valtteri Kantanen facbb2da44 [Filters] Update tooltip text for enrollment status filter (#4224) 2025-02-20 13:24:10 +02:00
Valtteri Kantanen cafc09f805 [Filters] Remove unused 'label' property from tool tip texts 2025-02-20 13:24:10 +02:00
Valtteri Kantanen c3a61ab7b3 [Filters] Remove future semesters from semester options 2025-02-20 13:24:10 +02:00
Joonas Ahovalli 2a0c39ab05 [Students tab] Add info about modules tab to info box 2025-02-20 12:43:28 +02:00
Joonas Ahovalli c83646512e [Study guidance groups] Add modules tab 2025-02-20 12:05:24 +02:00
Joonas Ahovalli 82c6181cd5 [Modules Tab] Switch to square icon for consistency 2025-02-20 11:50:00 +02:00
Joonas AhovalliandGitHub cc5f5e01c6 Merge pull request #4734 from UniversityOfHelsinkiCS/studyplan-selected-modules
[Modules Tab] New view to Class Statistics > Students. This view displays which study modules a student has selected in their study plan for that programme.
2025-02-19 18:50:58 +02:00
Joonas AhovalliandGitHub 7e04951e08 Merge branch 'master' into studyplan-selected-modules 2025-02-19 18:25:06 +02:00
Joonas Ahovalli 5cabca6311 [Modules Tab] Fix updated import aliasing 2025-02-19 18:05:11 +02:00
Joonas Ahovalli 96b181d3af [Modules Tab] Fix use of wrong studyplan in case of multiple studyplans or active studyrights 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 72acdf9a3b [Modules Tab] Improve how displayed modules are selected 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 0b29b6aa58 [Class statistics > students] Add tab for study modules 2025-02-19 18:00:04 +02:00
Joonas Ahovalli c272f1fde3 [Backend] Add included modules to getStudents query 2025-02-19 18:00:04 +02:00
Joonas Ahovalli 7174ed55c1 [Filters] Fix usememo dependencies 2025-02-19 18:00:04 +02:00
Joonas Ahovalli f958ba6701 [Updater] Add chosen modules to studyplan 2025-02-19 18:00:04 +02:00
renovate[bot]andJoonas Ahovalli 93264fef4d Update all non-major dependencies 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli a4baf8ae6d [Course statistics] Change 'Both' to 'University + Open university' for clarity
It was discussed that Both is not that explicit and it should be immediately clear what the option means
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli f3aa59fe1f [Course statistics] Fix Excel exports 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 322f19826a [Course statistics] Revert gradeCategories
The order of the grades acually matters so let's not do this
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 2a2388cbf6 [Course statistics] Extract common table options 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli d2c283c1b9 [Course statistics] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 3d84fb6a74 [Course statistics] Convert Attempts table to MaterialReactTable 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 6a53e30a94 [Course statistics] Refactor utils
Move around util functions closer to the components they are only used in
2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli f8616ba1c4 [Course statistics] Convert AttemptsTable into TypeScript 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli a87c35069d [Course statistics] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli fe87b0b960 [Students] Update tests 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 1a8b88dd0e [Course statistics] Disable population link for empty rows 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli b09cb8d574 [Course statistics] Simplify obfuscated cells 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 630df92e9d [Course statistics] Fix grade column order 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 948808ce11 [Course statistics] Remove SortableTable from Students tab 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 30e822b3f6 [Course statistics] Use MaterialReactTable 2025-02-19 18:00:03 +02:00
Riku RauhalaandJoonas Ahovalli 2769d0999e [ExternalLink] Move under components/material
This component is used by two components now so it should be moved up
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 86a1ef7b9f [Student search] Simplify API call
Only return the data actually used on the page. Use proper camelCase naming
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli b4b6d45463 [Student details] Include student number in the page title 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli dc7ad404cb [Student search] Fix search 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli dec0087de0 [Students] Type API requests for student search 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 61f91d1cfe [StudentNameVisibilityToggle] Move to components/material
This components is already written using MUI so it should go under /material
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli c11f905a2e [Students table] Convert into TypeScript 2025-02-19 18:00:02 +02:00
renovate[bot]andJoonas Ahovalli b2e433a79b Update dependency eslint-config-prettier to v10 2025-02-19 18:00:02 +02:00
renovate[bot]andJoonas Ahovalli 1e7160dcf5 Update dependency stylelint-config-standard to v37 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli da973fe74a [Filters] Fix hover color on filter clear icon 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 309d3128e4 [Backend] Remove unused dependency 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 4add189096 [Filters] Fix ESLint warnings in tests 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli f5ddf6291b [Class statistics] Write tests for Bachelor + master view 2025-02-19 18:00:02 +02:00
Valtteri KantanenandJoonas Ahovalli 3989e0da94 [Documentation] Add instructions for reverting migrations locally 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 3035b846a4 [Course statistics] Refactor pass rate series calculation
Eliminate copy-paste
2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli c88aa37b68 [Course statistics] Update tests 2025-02-19 18:00:02 +02:00
Riku RauhalaandJoonas Ahovalli 884191f352 [Course statistics] Limit pass/fail rate to 100%
In some cases the pass rate could go over 100% if there are more passed grades than attempts logged. This should not be possible
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 35af223c32 [Students close to graduation] Use title 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 71e639efa1 [Languages] Extract function getFullLanguage 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 546c683c8b [Course statistics] Include name of the selected course in the url 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 1c5c7781a5 [Course statistics] Update tests
Include empty rows
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 99b08020db [Backend] Convert group keys to integers
The min-max does not work for strings in some cases, better to handle the values as numbers instead
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 8c2efa9199 [Course statistics] Fix show grades on the attempts tab
Not a full fix yet but a step in the correct direction
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 7c50c80b15 [Backend] Insert empty rows for missing courseStat years/semesters 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli 9290fbf32b [Updater] Change included_modules column from varchar(255) to text[] in studyplan table 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli ffaa3eb05b [Students] Prevent crash when study rights lack semester enrollments 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 77b159739f [Course statistics] Fix separate by semesters state
The toggle state wouldn't update if separate was already set to true in the query params
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 0e63b970f7 [Grade distribution] Refactor getGradeSeries 2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli 7bedbe6451 [Grade distribution] Use new colors and update relative view
Relative view should stack the bars like in the pass rate chart. The new colors help make this more clear to read
2025-02-19 18:00:01 +02:00
Riku RauhalaandJoonas Ahovalli dacf5cc51b [Theme] Update grade colors
Use a unique color for each grade
2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli de5fdb7eaf [Class statistics] Make 'Show Bachelor + Master' toggle disabled if no programme is selected or the selected programme is not a bachelor's or master's programme 2025-02-19 18:00:01 +02:00
Valtteri KantanenandJoonas Ahovalli c35a632469 [Class statistics] Show more options in 'Study right status' filter if Bachelor + Master is selected 2025-02-19 18:00:00 +02:00
Valtteri KantanenandJoonas Ahovalli 691b6d6cb3 [Class statistics] Make header better if Bachelor + Master is selected 2025-02-19 18:00:00 +02:00
Valtteri KantanenandJoonas Ahovalli c8a69a15b4 [Close to graduation] Add 'Semesters used' column (#4491) 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 665b95a6f8 [Students] Fix content alignment if there are no enrollments 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d6ab10bed2 [Course statistics] Extract type ViewMode and refactor 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli a7fb7eda97 [Users] Acually add retries to a flaky test 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 44ce8ac298 [Users] Add retries to a flaky test 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 7312416e2b [Course statistics] Combine graph options for grade distribution
The same options can be used for the grade distribution chart as well
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d967bce32b [Course statistics] Combine attempts/students graph options
These are identical except for yAxis title
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 4716299771 [Course statistics] Extract component TotalsDisclaimer 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 1036b47523 [Highcharts] Disable titles globally
This was also done individually for most charts. If a title is needed, it will overwrite the global default setting
2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli 317fb6f655 [Highcharts] Disable credits globally 2025-02-19 18:00:00 +02:00
Riku RauhalaandJoonas Ahovalli d6d804d288 [Course statistics] Move grade colors to theme
This change gives more meaningful names to the colors used for grades, gets rid of the legacy color file and its unused colors
2025-02-19 18:00:00 +02:00
Valtteri KantanenandGitHub 8b3c408700 Merge pull request #4729 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-02-18 14:33:07 +02:00
renovate[bot]andGitHub 7a94c92181 Update all non-major dependencies 2025-02-18 12:21:53 +00:00
Riku RauhalaandGitHub 2feeaddb8b Merge pull request #4741 from UniversityOfHelsinkiCS/material-ui-attempt-statistics
Material UI: Course statistics - Attempts table
2025-02-17 18:38:17 +02:00
Riku Rauhala 6d9c4b0b0a [Course statistics] Change 'Both' to 'University + Open university' for clarity
It was discussed that Both is not that explicit and it should be immediately clear what the option means
2025-02-17 18:26:05 +02:00
Riku Rauhala c9abbf958a [Course statistics] Fix Excel exports 2025-02-17 17:39:10 +02:00
Riku Rauhala 47692de1ce [Course statistics] Revert gradeCategories
The order of the grades acually matters so let's not do this
2025-02-17 15:23:00 +02:00
Riku Rauhala ad6260a313 [Course statistics] Extract common table options 2025-02-17 14:19:08 +02:00
Riku Rauhala 298e43a89a [Course statistics] Update tests 2025-02-17 13:21:13 +02:00
Riku Rauhala 055d3b4eac [Course statistics] Convert Attempts table to MaterialReactTable 2025-02-17 12:48:13 +02:00
Riku Rauhala 5a831cdc86 [Course statistics] Refactor utils
Move around util functions closer to the components they are only used in
2025-02-14 14:43:18 +02:00
Riku Rauhala c23ee470d8 [Course statistics] Convert AttemptsTable into TypeScript 2025-02-14 14:06:59 +02:00
Riku RauhalaandGitHub 59b4aad1ab Merge pull request #4740 from UniversityOfHelsinkiCS/material-ui-student-statistics
Material UI: Course statistics - Students table
2025-02-14 13:57:42 +02:00
Riku Rauhala de3e0f5e6b [Course statistics] Update tests 2025-02-14 13:47:41 +02:00
Riku Rauhala 7ede62760e [Students] Update tests 2025-02-14 12:50:46 +02:00
Riku Rauhala d2fdeeb830 [Course statistics] Disable population link for empty rows 2025-02-14 10:44:06 +02:00
Riku Rauhala 220952153f [Course statistics] Simplify obfuscated cells 2025-02-14 09:05:49 +02:00
Riku Rauhala bace3c80af [Course statistics] Fix grade column order 2025-02-14 09:03:10 +02:00
Riku Rauhala d9457bf855 [Course statistics] Remove SortableTable from Students tab 2025-02-13 19:16:13 +02:00
Riku Rauhala 2d6d12f95f [Course statistics] Use MaterialReactTable 2025-02-13 18:57:34 +02:00
Valtteri KantanenandGitHub 93a3014209 Merge pull request #4714 from UniversityOfHelsinkiCS/renovate/eslint-config-prettier-10.x
Update dependency eslint-config-prettier to v10
2025-02-13 17:15:30 +02:00
renovate[bot]andGitHub 3d6bc23658 Update dependency eslint-config-prettier to v10 2025-02-13 15:02:52 +00:00
Valtteri KantanenandGitHub 1906827760 Merge pull request #4713 from UniversityOfHelsinkiCS/renovate/stylelint-config-standard-37.x
Update dependency stylelint-config-standard to v37
2025-02-13 16:54:55 +02:00
renovate[bot]andGitHub 4b8f389dd3 Update dependency stylelint-config-standard to v37 2025-02-13 14:42:16 +00:00
Valtteri Kantanen 5d69a9b53b [Filters] Fix hover color on filter clear icon 2025-02-13 16:40:38 +02:00
Valtteri Kantanen 5c0c267606 [Backend] Remove unused dependency 2025-02-13 16:21:41 +02:00
Valtteri Kantanen 00c7e45672 [Filters] Fix ESLint warnings in tests 2025-02-13 16:06:41 +02:00
Valtteri Kantanen b28d43c7f8 [Class statistics] Write tests for Bachelor + master view 2025-02-13 16:04:06 +02:00
Riku Rauhala c0f15c9dd9 [ExternalLink] Move under components/material
This component is used by two components now so it should be moved up
2025-02-13 13:51:57 +02:00
Valtteri Kantanen 64240c9f6f [Documentation] Add instructions for reverting migrations locally 2025-02-13 12:00:20 +02:00
Riku Rauhala cbbcdd0e40 [Student search] Simplify API call
Only return the data actually used on the page. Use proper camelCase naming
2025-02-12 17:31:47 +02:00
Riku Rauhala dc7a6805a7 [Student details] Include student number in the page title 2025-02-12 17:13:27 +02:00
Riku Rauhala 0321dbed62 [Student search] Fix search 2025-02-12 17:00:01 +02:00
Riku Rauhala 88a46a8f23 [Students] Type API requests for student search 2025-02-12 16:58:11 +02:00
Riku Rauhala 5c26f04436 [StudentNameVisibilityToggle] Move to components/material
This components is already written using MUI so it should go under /material
2025-02-12 16:13:50 +02:00
Riku Rauhala e80fa25fb3 [Students table] Convert into TypeScript 2025-02-12 10:27:12 +02:00
Riku Rauhala 12f11183cf [Course statistics] Refactor pass rate series calculation
Eliminate copy-paste
2025-02-12 09:35:11 +02:00
Riku RauhalaandGitHub abfefa8c3e Merge pull request #4739 from UniversityOfHelsinkiCS/course-statistics-pass-rate-fix
[Course statistics] Limit pass/fail rate to 100%
2025-02-11 22:57:46 +02:00
Riku Rauhala eb82eee286 [Course statistics] Update tests 2025-02-11 22:47:15 +02:00
Riku Rauhala 5ff9b3b788 [Course statistics] Limit pass/fail rate to 100%
In some cases the pass rate could go over 100% if there are more passed grades than attempts logged. This should not be possible
2025-02-11 22:27:54 +02:00
Riku Rauhala f4d73b649f [Students close to graduation] Use title 2025-02-11 21:16:09 +02:00
Riku Rauhala 4136c0d3b5 [Languages] Extract function getFullLanguage 2025-02-11 21:15:38 +02:00
Riku Rauhala 2b8cf76053 [Course statistics] Include name of the selected course in the url 2025-02-11 21:04:35 +02:00
Riku RauhalaandGitHub e23c60cb38 Merge pull request #4737 from UniversityOfHelsinkiCS/course-statistics-empty-rows
[Course statistics] Insert empty rows for years/semesters without attainments
2025-02-11 20:16:43 +02:00
Riku Rauhala 676804b770 [Course statistics] Update tests
Include empty rows
2025-02-11 20:05:18 +02:00
Riku Rauhala 2785742934 [Backend] Convert group keys to integers
The min-max does not work for strings in some cases, better to handle the values as numbers instead
2025-02-11 19:14:37 +02:00
Riku Rauhala f82638763f [Course statistics] Fix show grades on the attempts tab
Not a full fix yet but a step in the correct direction
2025-02-11 17:36:39 +02:00
Riku Rauhala 7253ba1e17 [Backend] Insert empty rows for missing courseStat years/semesters 2025-02-11 17:36:39 +02:00
Valtteri Kantanen e2847a68ea [Updater] Change included_modules column from varchar(255) to text[] in studyplan table 2025-02-11 16:47:28 +02:00
Valtteri Kantanen 687e231e65 [Students] Prevent crash when study rights lack semester enrollments 2025-02-11 14:55:57 +02:00
Riku Rauhala 0629614ed8 [Course statistics] Fix separate by semesters state
The toggle state wouldn't update if separate was already set to true in the query params
2025-02-10 22:10:05 +02:00
Riku Rauhala 843e04f154 [Grade distribution] Refactor getGradeSeries 2025-02-10 18:22:20 +02:00
Riku Rauhala d7c3423a3f [Grade distribution] Use new colors and update relative view
Relative view should stack the bars like in the pass rate chart. The new colors help make this more clear to read
2025-02-10 18:22:20 +02:00
Riku Rauhala 8be238d4cc [Theme] Update grade colors
Use a unique color for each grade
2025-02-10 18:22:20 +02:00
Valtteri Kantanen ecbaa35802 [Class statistics] Make 'Show Bachelor + Master' toggle disabled if no programme is selected or the selected programme is not a bachelor's or master's programme 2025-02-10 16:57:48 +02:00
Valtteri Kantanen 732e14cb2d [Class statistics] Show more options in 'Study right status' filter if Bachelor + Master is selected 2025-02-10 16:49:53 +02:00
Valtteri Kantanen e98ef1b94f [Class statistics] Make header better if Bachelor + Master is selected 2025-02-10 16:47:53 +02:00
Valtteri Kantanen e254de4df3 [Close to graduation] Add 'Semesters used' column (#4491) 2025-02-10 12:52:23 +02:00
Riku Rauhala 1d169ee70a [Students] Fix content alignment if there are no enrollments 2025-02-07 15:43:04 +02:00
Riku Rauhala 0380cab715 [Course statistics] Extract type ViewMode and refactor 2025-02-07 15:06:54 +02:00
Riku Rauhala 4391c06f00 [Users] Acually add retries to a flaky test 2025-02-07 15:02:42 +02:00
Riku Rauhala 36a3044b16 [Users] Add retries to a flaky test 2025-02-07 14:53:07 +02:00
Joonas Ahovalli db08330cb9 [Updater] Fix studyplanmapper for students with multiple programmes 2025-02-07 14:48:12 +02:00
Joonas Ahovalli 0a071ee071 [Updater] Add chosen modules to studyplan 2025-02-07 14:48:12 +02:00
Riku Rauhala 2354b696a5 [Course statistics] Combine graph options for grade distribution
The same options can be used for the grade distribution chart as well
2025-02-07 14:36:27 +02:00
Riku Rauhala f6db0f2fe8 [Course statistics] Combine attempts/students graph options
These are identical except for yAxis title
2025-02-07 14:22:26 +02:00
Riku Rauhala e40c219e78 [Course statistics] Extract component TotalsDisclaimer 2025-02-07 14:04:06 +02:00
Riku Rauhala 2e7ca4d960 [Highcharts] Disable titles globally
This was also done individually for most charts. If a title is needed, it will overwrite the global default setting
2025-02-07 13:42:43 +02:00
Riku Rauhala c3fd874559 [Highcharts] Disable credits globally 2025-02-07 13:29:52 +02:00
Riku Rauhala 80768b4d15 [Course statistics] Move grade colors to theme
This change gives more meaningful names to the colors used for grades, gets rid of the legacy color file and its unused colors
2025-02-07 13:21:49 +02:00
Joonas Ahovalli 7de955398c [Updater] Fix studyplanmapper for students with multiple programmes 2025-02-07 12:47:39 +02:00
Joonas Ahovalli 2510903d63 [Updater] Add chosen modules to studyplan 2025-02-06 17:01:34 +02:00
Riku Rauhala ef3aa1faf2 [Course statistics] Test info boxes 2025-02-06 14:34:05 +02:00
Riku Rauhala 7444567d64 [Course statistics] Update tests for ProviderOrganizationSelect 2025-02-06 14:14:27 +02:00
Riku Rauhala a559dab709 [Frontend] Update icon imports
Update the remaining MUI icon imports for consistency
2025-02-06 13:49:53 +02:00
Riku Rauhala 34feb51d7b [Course statistics] Turn Provider organization selector into a dropdown 2025-02-06 13:36:36 +02:00
Riku Rauhala a206a1a9b3 [Close to graduation] Align numeric columns to the right 2025-02-06 12:58:09 +02:00
Riku Rauhala 9a18de83f9 [Backend] Remove Funidata debug logging
Confirmed by Funidata on Slack
2025-02-05 16:10:52 +02:00
Riku RauhalaandGitHub d1723e7ad6 Merge pull request #4733 from UniversityOfHelsinkiCS/material-ui-course-statistics
Material UI: Course statistics
2025-02-05 16:08:03 +02:00
Riku Rauhala e1adc24ccd [Course statistics] Add missing info boxes 2025-02-05 15:59:33 +02:00
Riku Rauhala 1668f9581b [Course statistics] Update tests 2025-02-05 15:46:18 +02:00
Riku Rauhala 58b0b97e3a [Course statistics] Revert getGrades 2025-02-05 15:27:30 +02:00
Riku Rauhala 90479e5fbf [Course statistics] Fix console warning for YearFilter 2025-02-05 15:03:52 +02:00
Riku Rauhala 7461ace443 [Course statistics] Update tests 2025-02-05 12:57:40 +02:00
Riku Rauhala 7490395f99 [Course statistics] Update tests 2025-02-04 22:22:18 +02:00
Riku Rauhala a16195d47a [Course statistics] Add New query button back 2025-02-04 20:49:43 +02:00
Riku Rauhala c4084faf89 Update tests 2025-02-04 12:17:17 +02:00
Riku Rauhala 09a9958272 [Course statistics] Convert tables back to JavaScript
These will be rewritten separately with Material React Table
2025-02-04 11:11:31 +02:00
Riku Rauhala e4325f67bd [Documentation] Add Material React Table 2025-02-04 11:03:26 +02:00
Riku Rauhala 91145e3a88 [Course statistics] Improve typing 2025-02-04 11:03:26 +02:00
Riku Rauhala f4920de7d2 [Course statistics] Improve typing of charts 2025-02-04 11:03:26 +02:00
Riku Rauhala d2f96b4657 [Course statistics] Update settings to MUI 2025-02-04 11:03:26 +02:00
Riku Rauhala 21d252f1c7 [Course statistics] Refactor result tabs 2025-02-04 11:03:26 +02:00
Riku Rauhala 06f63110b9 [Course statistics] Replace semantic tabs in ResultsTabs 2025-02-04 11:03:26 +02:00
Riku Rauhala 9bbd67f7a3 [Hooks] Refactor useTabs 2025-02-04 11:03:25 +02:00
Riku Rauhala fb465ec17e [Course statistics] Improve typing 2025-02-04 11:03:25 +02:00
Riku Rauhala 76f06a6481 [Course statistics] Rewrite Course tab with Material UI 2025-02-04 11:03:25 +02:00
Riku Rauhala 929c022bf8 [Course statistics] Simplify DropdownOptions
Remove unused fields
2025-02-04 11:03:25 +02:00
Riku Rauhala d32aba88fc [Course statistics] Update types 2025-02-04 11:03:25 +02:00
Riku Rauhala e9d482f99c [ProgrammeDropdown] Move to components directory 2025-02-04 11:03:25 +02:00
Riku Rauhala 496fd6bb15 [Course statistics] Fix type issues 2025-02-04 11:03:25 +02:00
Riku Rauhala 016d8da641 [Course statistics] Type selector 2025-02-04 11:03:25 +02:00
Riku Rauhala 37d12ac65f [Frontend] Fix types attemptData and courseStat 2025-02-04 11:03:25 +02:00
Riku Rauhala 1b42b51e6e [Redux] Refactor coursesSummaryForm
Rewriting with modern syntax and deprecating formreducer as unused
2025-02-04 11:03:25 +02:00
Riku Rauhala 36230033ef [Redux] Refactor selectedCourse reducer
Rewriting with more modern syntax and removing unused stats object
2025-02-04 11:03:25 +02:00
Riku Rauhala dd1e143367 [Course statistics] Rewrite selected courses section with MUI 2025-02-04 11:03:25 +02:00
Riku Rauhala d44ee6738f [Course statistics] Fix edge case for pass rates
If passed is 0 and failed is >0, pass rate should be 0.00%
2025-02-04 11:03:25 +02:00
Riku Rauhala 1e8c6a2c79 [Course statistics] Remove Timeframe from the Summary tab
This seemed very redundant as it offered no functionality. Removal mluukkai approved
2025-02-04 11:03:25 +02:00
Riku Rauhala 3833720a22 [Course statistics] Convert CourseTab into TypeScript
Also, renaming the component SingleCourseTab to CourseTab to match the texts displayed in the UI
2025-02-04 11:03:25 +02:00
Riku Rauhala 90ae26903e [Course statistics] Refactor Excel export functionality 2025-02-04 11:03:25 +02:00
Riku Rauhala b68806e6ed [Course statistics] Remove outer Sections 2025-02-04 11:03:25 +02:00
Riku Rauhala 3a030a163d [Util] Extract access checking functionality 2025-02-04 11:03:25 +02:00
Riku Rauhala cd726716f5 [Course statistics] Fix selector 2025-02-04 11:03:25 +02:00
Riku Rauhala 627cd52b5e [Redux] Type feedbackApi 2025-02-04 11:03:25 +02:00
Riku Rauhala 01afe1607c [Course statistics] Rewrite ProgrammeDropdown with MUI 2025-02-04 11:03:25 +02:00
Riku Rauhala fd497422f8 [Redux] Type coursesSummaryForm reducer 2025-02-04 11:03:24 +02:00
Riku Rauhala fdcc445086 [Course statistics] Convert AttemptsTable into MUI 2025-02-04 11:03:24 +02:00
Riku Rauhala 36d7b3f7f3 [Course statistics] Rearrange components 2025-02-04 11:03:24 +02:00
Riku Rauhala b8d8e7df87 [Course statistics] Rewrite Faculty statistics tab with MUI 2025-02-04 11:03:24 +02:00
Riku Rauhala 7c2f2344b8 [Course statistics] Update tabs to Material UI 2025-02-04 11:03:24 +02:00
Riku Rauhala 33691621d8 [Hooks] Allow manually changing the tab 2025-02-04 11:03:24 +02:00
Valtteri Kantanen 93ed56dd38 [Class statistics] Add bachelor's and master's modes to "Graduated from programme" filter if "Show Bachelor + Master" is toggled on (#4718) 2025-02-03 18:58:57 +02:00
Valtteri Kantanen 54f47b8607 [Updater] Remove three attainments from excluded partial attainments CSV file that shouldn't be excluded (#4396) 2025-02-03 13:02:16 +02:00
Valtteri Kantanen db2649be6f [Filters] Fix programme filter to correctly check active study right (#4723) 2025-02-03 12:32:32 +02:00
Valtteri Kantanen 17a07de1f0 [Updater] Upgrade vulnerable @sentry/node version 2025-02-03 12:12:12 +02:00
Valtteri Kantanen 3aa6892bff [Frontend] Hide placeholder text for most column filters in material-react-table 2025-02-03 12:08:00 +02:00
Joonas Ahovalli 92405c3561 [Courses Tab] update tooltip 2025-02-01 00:47:43 +02:00
Joonas Ahovalli cfc2658078 [Courses Tab] Refactor, add HOPS icons, fix memoization & incorrect counting of completions 2025-02-01 00:45:53 +02:00
Valtteri Kantanen 209feebb5d [Class statistics] Display "Show Bachelor + Master" to all users instead of just admins 2025-01-31 16:12:25 +02:00
Valtteri Kantanen 0fb7a94eb1 [Class statistics] Change "showFullStudyPath" to "showBachelorAndMaster" everywhere 2025-01-31 16:10:39 +02:00
Valtteri KantanenandGitHub e80c0aaf85 Merge pull request #4728 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-01-31 13:33:47 +02:00
renovate[bot]andGitHub 8dda45da48 Update all non-major dependencies 2025-01-31 11:23:03 +00:00
Valtteri Kantanen e5a85500b3 Fix eslint errors 2025-01-31 13:21:07 +02:00
Valtteri Kantanen a4b8844c77 Upgrade package gelf-pro from version 1.3.12 to version 1.4.0 2025-01-31 13:14:21 +02:00
Valtteri Kantanen 7696e5bcd8 [Course statistics] Fix faculty statistics calculations 2025-01-31 12:53:43 +02:00
Valtteri Kantanen 486561789b Upgrade Redis image version in docker-compose files from 6.2.16 to 7.2.6 to match production version 2025-01-31 12:48:30 +02:00
Valtteri Kantanen 28fc185a2a Exclude Docker images from Renovate updates
We want the local Docker versions to match production versions as well as possible so automatic updates aren't good
2025-01-31 12:39:26 +02:00
Valtteri KantanenandGitHub db59d0e138 Merge pull request #4716 from UniversityOfHelsinkiCS/renovate/cypress-14.x
Update dependency cypress to v14
2025-01-31 11:43:49 +02:00
Valtteri KantanenandGitHub 1687f0a0c6 Merge pull request #4719 from UniversityOfHelsinkiCS/renovate/npm-vite-vulnerability
Update dependency vite to v6.0.9 [SECURITY]
2025-01-31 11:42:56 +02:00
Valtteri KantanenandGitHub 6ac6ad687e Merge pull request #4724 from UniversityOfHelsinkiCS/renovate/npm-sentry-node-vulnerability
Update dependency @sentry/node to v8.49.0 [SECURITY]
2025-01-31 11:42:08 +02:00
renovate[bot]andGitHub 5e1e71b559 Update dependency @sentry/node to v8.49.0 [SECURITY] 2025-01-28 21:58:26 +00:00
Joonas Ahovalli 66b5e9a65d [Completed courses of students] Display an icon for courses that are in students' HOPS but have no enrollment or completion 2025-01-28 12:08:29 +02:00
Joonas Ahovalli 30b1a31bb3 [Backend] completedCoursesSearch additionally fetches list of courses in student's HOPS 2025-01-28 10:51:00 +02:00
Santeri Rajamäki d6765bd7d6 [Class statistics] Improved performance of "Courses of Class" tab
Closes #4715
2025-01-27 16:21:12 +02:00
Valtteri Kantanen 5ce3337977 Fix Markdown error in documentation 2025-01-22 15:15:34 +02:00
Valtteri Kantanen c5e24c7409 Add documentation for db schema updates 2025-01-22 14:58:30 +02:00
Joonas Ahovalli 34f029da9e [Close to graduation] Show preferred language for student 2025-01-22 14:52:13 +02:00
Joonas Ahovalli e95414806c [Updater] add preferred language to student model 2025-01-22 12:44:03 +02:00
renovate[bot]andGitHub 456b420321 Update dependency vite to v6.0.9 [SECURITY] 2025-01-21 21:22:24 +00:00
Riku Rauhala 0eb87ea44a [Hooks] Extract remaining hooks
All hooks are now converted into TypeScript and stored in @/hooks
2025-01-20 13:53:09 +02:00
Riku Rauhala 454fabeb1d [Hooks] Extract hook useSemanticTabs
Renaming this for clarity. This will be replaced by the new useTabs hook for all MUI components
2025-01-20 12:52:04 +02:00
Riku Rauhala d3f022adc5 [Hooks] Extract hook useDeepMemo 2025-01-20 12:46:15 +02:00
Riku Rauhala 8efe3d2aa3 [Hooks] Extract hook useTabChangeAnalytics 2025-01-20 12:40:48 +02:00
Riku Rauhala d3003d5797 [Hooks] Extract hook useProgress 2025-01-20 12:36:45 +02:00
Riku Rauhala 0dd5f32f96 [Hooks] Extract hook useDidMount 2025-01-20 12:31:09 +02:00
Riku Rauhala 85b307b306 [Hooks] Extract hook useLocalStorage 2025-01-20 12:09:48 +02:00
Riku Rauhala 73eadbd1b1 [Hooks] Remove duplicate toggle hook 2025-01-20 11:59:12 +02:00
Riku Rauhala b294c560e4 [Hooks] Extract hook useTitle 2025-01-20 11:56:41 +02:00
Riku Rauhala 455d9dc532 [Hooks] Rename debouncedState for consistency 2025-01-20 11:47:32 +02:00
Riku RauhalaandGitHub df1049a436 Merge pull request #4717 from UniversityOfHelsinkiCS/material-ui-course-search
Material UI: Course search
2025-01-20 10:56:49 +02:00
Riku Rauhala ac22df8ced [Course search] Hide selected list when not selecting multiple 2025-01-20 09:52:28 +02:00
Riku Rauhala 1fd987221d [Course statistics] Update tests 2025-01-20 09:43:25 +02:00
Riku Rauhala 45985f2c44 [CourseStatistics] Convert utils into TypeScript 2025-01-17 21:27:47 +02:00
Riku Rauhala 159e11a508 [CourseStatistics] Convert to TypeScript 2025-01-17 21:08:46 +02:00
Riku Rauhala 5ff68533d7 [Courses search] Add circular progress when fetching 2025-01-17 21:04:04 +02:00
Riku Rauhala 61f20173a3 [Courses search] Place the toggles back to their original location
This seems to work better than having the toggles below
2025-01-17 20:26:56 +02:00
Riku Rauhala 987f1ec42b [Courses search] Refactor toggles 2025-01-17 20:08:19 +02:00
Riku Rauhala 3f4c39600b [Course search] Add missing search icons 2025-01-17 19:54:02 +02:00
Riku Rauhala 88ef490b1b [CourseStatistics] Rewrite course search with Material UI 2025-01-17 18:34:57 +02:00
renovate[bot]andGitHub dbc8b684c2 Update dependency cypress to v14 2025-01-17 01:32:19 +00:00
Riku Rauhala 7e135d4643 [Types] Extract type SearchHistoryItem 2025-01-16 10:03:10 +02:00
Riku Rauhala 06a8760701 [Hooks] Extract hook useSearchHistory 2025-01-15 19:16:42 +02:00
Riku Rauhala 5eaa035f1d [Hooks] Extract hook useToggle 2025-01-15 18:05:50 +02:00
Riku Rauhala ae1f0ceeb7 [Redux] Type courseSearch 2025-01-15 17:56:04 +02:00
Riku Rauhala 3ca82286b4 [Redux] Type courseStats 2025-01-15 17:09:22 +02:00
Riku Rauhala 63add43c0f [Course statistics] Move components under pages 2025-01-15 10:56:44 +02:00
Rajamäki Santeri J 72440609b4 [Study Programme] Fixed Cypress broken tests 2025-01-13 18:14:59 +02:00
Rajamäki Santeri J c946cd2405 [Study Programme] Added study right thesis counting
Closing issue #4712
2025-01-13 17:56:51 +02:00
Valtteri Kantanen 96d316ebf5 [Frontend] Rewrite Updater page with MUI components 2025-01-13 15:43:32 +02:00
Riku Rauhala b3eb7dceac [Custom population] Fix an info box text
There was an extra ' character
2025-01-10 14:18:07 +02:00
Valtteri Kantanen ee02d002ba [Course statistics] Fix tests 2025-01-10 13:16:22 +02:00
Valtteri Kantanen 34448d27d0 [Frontend] Remove unused components 2025-01-10 13:03:05 +02:00
Valtteri Kantanen 39ce64edce [Course statistics] Move minimum and maximum attainment date calculation to the backend 2025-01-10 13:02:46 +02:00
Valtteri Kantanen 0d210a607b [Course statistics] Fix a bug in the search form where every keypress triggered a new backend request 2025-01-10 13:01:23 +02:00
Valtteri Kantanen f1a6302928 [Course statistics] Use RTK Query for course search 2025-01-10 12:59:25 +02:00
Valtteri Kantanen 359db8f365 [Frontend] Add custom useDebouncedState hook 2025-01-10 12:58:12 +02:00
Valtteri Kantanen 387a3e0ef2 [Frontend] Refactor validateInputLength to always return a boolean 2025-01-10 12:56:47 +02:00
Valtteri Kantanen 4c957b347b Remove automerge from renovate config as it doesn't seem to work properly 2025-01-09 10:41:35 +02:00
Valtteri KantanenandGitHub 10d3d5aeed Merge pull request #4711 from UniversityOfHelsinkiCS/OOD-80
[Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls
2025-01-09 10:26:10 +02:00
Matti Räsänen 6e365d4a23 Removed ending slash from SIS_URL in fd ci compose 2025-01-08 09:08:32 +02:00
Matti Räsänen 68292a45d0 [FD compose] Changed SIS_HOST to SIS_URL in Dockerfile and added SIS_URL to fd ci compose as well 2025-01-07 16:17:25 +02:00
Matti Räsänen f0088cf7a0 [Frontend] Added env variable for sisu url and changed to use that instead of hardcoded sisu urls 2025-01-07 16:03:09 +02:00
Valtteri KantanenandGitHub b8469a242e Merge pull request #4595 from UniversityOfHelsinkiCS/renovate/axios-1.x
Update dependency axios to v1
2025-01-07 15:21:27 +02:00
Valtteri Kantanen 8c5d5f1ee9 [General tab] Move Bachelor/Master column more to the left 2025-01-07 15:13:31 +02:00
Valtteri Kantanen 80759cacee [Frontend] Fix key spread error on RangeSelector 2025-01-07 14:25:38 +02:00
renovate[bot]andGitHub 4df2cc78ff Update dependency axios to v1 2025-01-07 11:58:36 +00:00
Valtteri Kantanen 8b34f7a5f9 [Faculties] Use StyledTable instead of regular Table 2025-01-07 13:55:09 +02:00
Valtteri Kantanen e81d6eb877 [Faculties] Fix tests 2025-01-07 10:58:32 +02:00
Valtteri Kantanen 0c47d7c21c [Faculties] Combine 'Students by starting year' and 'Progress' tabs 2025-01-07 10:46:43 +02:00
Valtteri KantanenandGitHub 175996235d Merge pull request #4705 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-01-07 09:45:05 +02:00
renovate[bot]andGitHub 640dc741a4 Update all non-major dependencies 2024-12-17 08:19:50 +00:00
402 changed files with 12788 additions and 12252 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test studyprogramme overview'
spec: cypress/e2e/Studyprogramme_overview.js
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
+2 -2
View File
@@ -199,8 +199,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test studyprogramme overview'
spec: cypress/e2e/Studyprogramme_overview.js
- name: 'Test study_programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
+2
View File
@@ -6,6 +6,8 @@ const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file
module.exports = {
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
File diff suppressed because it is too large Load Diff
+66 -68
View File
@@ -56,11 +56,10 @@ describe('Faculty statistics', () => {
})
it('Correct tabs are shown', () => {
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('contain', 'Update statistics')
})
})
@@ -72,67 +71,66 @@ describe('Faculty statistics', () => {
describe('Info boxes', () => {
it('Students of the faculty', () => {
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
cy.cs('students-of-the-faculty-info-box-button').click()
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
})
it('Graduated of the faculty', () => {
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
cy.cs('graduated-of-the-faculty-info-box-button').click()
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Thesis writers of the faculty', () => {
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Credits produced by the faculty', () => {
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').contains('Sisältää opintopisteet')
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
})
})
it('All graphs and tables are shown', () => {
cy.get('[data-cy="StudentsOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="GraduatedOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="ThesisWritersOfTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('be.visible')
cy.get('[data-cy="CreditsProducedByTheFacultyGraph"]').should('be.visible')
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('be.visible')
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
})
it('Correct tabs are shown', () => {
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
})
it('Toggle years works', () => {
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
cy.get('[data-cy="YearToggle"]').click()
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
})
it('Toggle programmes works', () => {
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
cy.get('[data-cy="ProgrammeToggle"]').click()
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
cy.cs('faculty-programmes-shown-info').should('not.exist')
cy.cs('programme-toggle').click()
cy.cs('faculty-programmes-shown-info').should('be.visible')
})
it('Toggle study rights works', () => {
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('not.exist')
cy.get('[data-cy="StudentToggle"]').click()
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('be.visible')
cy.cs('faculty-exclude-specials-info').should('not.exist')
cy.cs('study-right-toggle').click()
cy.cs('faculty-exclude-specials-info').should('be.visible')
})
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
@@ -144,7 +142,7 @@ describe('Faculty statistics', () => {
'CreditsProducedByTheFaculty',
]
sections.forEach(section => {
cy.get(`[data-cy="${section}Graph"]`).within(() => {
cy.cs(`${section}Graph`).within(() => {
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
@@ -163,14 +161,14 @@ describe('Faculty statistics', () => {
})
it('Export button', { retries: 2 }, () => {
cy.get('[data-cy=FacultyStudentTableExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info box', () => {
cy.get('[data-cy=FacultyStudentTableInfoBoxButton]').click()
cy.get('[data-cy=FacultyStudentTableInfoBoxContent]').contains('Opiskelijat, joiden')
cy.cs('faculty-student-table-info-box-button').click()
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
})
it.skip('Study right toggle', () => {
@@ -194,10 +192,10 @@ describe('Faculty statistics', () => {
})
it('Percentage toggle works', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '92.3 %')
cy.get('[data-cy="HidePercentagesToggle"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '92.3 %')
cy.cs('faculty-student-stats-table').should('be.visible')
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
cy.cs('percentage-toggle').click()
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
})
})
@@ -210,33 +208,33 @@ describe('Faculty statistics', () => {
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it.skip('Info boxes', () => {
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan')
cy.cs('FacultyProgress-info-box-button').click()
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
// TODO: Click open the accordion first
cy.get('[data-cy=BachelorMastersProgressInfoBoxButton]').click()
cy.get('[data-cy=BachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
cy.cs('BachelorMastersProgress-info-box-button').click()
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
})
it.skip('Progress bar charts exist', () => {
// TODO: Click open the accordion first
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorMastersProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyMastersProgressBarChart"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
})
it.skip('Progress tables exist', () => {
// TODO: Click open the accordion first
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
cy.cs('FacultyMastersProgressTable').should('be.visible')
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
})
it.skip('Graduated toggle', () => {
@@ -257,14 +255,14 @@ describe('Faculty statistics', () => {
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.get('[data-cy=AverageGraduationTimesExportButton]').should('be.visible').and('not.be.disabled').click()
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info boxes', () => {
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset')
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
})
it.skip('Study times toggle', () => {
@@ -280,10 +278,10 @@ describe('Faculty statistics', () => {
})
it('Graphs are visible', () => {
cy.get('[data-cy="bachelorGraduationTimes"]').should('be.visible')
cy.cs('bachelor-graduation-times-section').should('be.visible')
// ! No test data for bachelor + master
cy.get('[data-cy="masterGraduationTimes"]').should('be.visible')
cy.get('[data-cy="doctorGraduationTimes"]').should('be.visible')
cy.cs('master-graduation-times-section').should('be.visible')
cy.cs('doctor-graduation-times-section').should('be.visible')
})
})
})
+83 -35
View File
@@ -48,23 +48,23 @@ const testRangeFilter = (parentEl, min, max, expected) => {
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
return (filterName, testStepFunctionToRun) => {
const card = cy.cs(`${filterName}-filter-card`)
cy.cs(`${filterName}-filter-card`)
.invoke('attr', 'data-open')
.then(open => {
const getHeader = () => cy.cs(`${filterName}-header`)
card.invoke('attr', 'data-open').then(open => {
const getHeader = () => cy.cs(`${filterName}-header`)
if (open === 'false') {
getHeader().click({ force: true })
}
if (open === 'false') {
getHeader().click({ force: true })
}
checkFilteringResult(amountWithoutFiltering)
testStepFunctionToRun()
checkFilteringResult(amountWithoutFiltering)
checkFilteringResult(amountWithoutFiltering)
testStepFunctionToRun()
checkFilteringResult(amountWithoutFiltering)
if (open === 'false') {
getHeader().click({ force: true })
}
})
if (open === 'false') {
getHeader().click({ force: true })
}
})
}
}
@@ -79,10 +79,9 @@ describe("Population statistics with a master's programme", () => {
it('Study track filter works', () => {
runTestStepWithPreAndPostParts('StudyTrack', () => {
const card = cy.cs('StudyTrack-filter-card')
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
checkFilteringResult(15)
programmeDropdown.get('i.delete').click()
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
})
})
@@ -93,8 +92,7 @@ describe("Population statistics with a master's programme", () => {
it('is set to all by default', () => {
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
const card = cy.cs('studyRightTypeFilter-filter-card')
card.get('[data-cy="all"] input').should('be.checked')
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
})
})
@@ -127,8 +125,7 @@ describe('Population Statistics', () => {
cy.contains('By default only students who have not transferred to this study programme are shown.')
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
const card = cy.cs('TransferredToProgramme-filter-card')
card.get('[data-cy="option-havenot"] input').should('be.checked')
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
})
})
@@ -219,15 +216,19 @@ describe('Population Statistics', () => {
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
checkFilteringResult(10)
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
checkFilteringResult(8)
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
checkFilteringResult(6)
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
checkFilteringResult(5)
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
courses.forEach(({ code }) => {
cy.cs(`courseFilter-${code}-clear`).click()
})
})
})
@@ -259,6 +260,50 @@ describe('Population Statistics', () => {
})
})
describe('Population Statistics with Bachelor + Master', () => {
const pathToMathBSc2017 =
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
const defaultAmountOfStudents = 47
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToMathBSc2017)
})
it('Graduation filter works', () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
getCard().cs('option-graduated-bachelor').click()
checkFilteringResult(42)
getCard().cs('option-graduated-master').click()
checkFilteringResult(18)
getCard().cs('option-not-graduated-bachelor').click()
checkFilteringResult(5)
getCard().cs('option-not-graduated-master').click()
checkFilteringResult(29)
getCard().cs('option-all').click()
})
})
it('Study right status filter works', () => {
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
// First two are 0 because there are no semester enrollments for the current semester
getCard().cs('option-active').click()
checkFilteringResult(0)
getCard().cs('option-active-combined').click()
checkFilteringResult(0)
getCard().cs('option-inactive').click()
checkFilteringResult(5)
getCard().cs('option-inactive-combined').click()
checkFilteringResult(24)
getCard().cs('option-activity-status-all').click()
})
})
})
describe('Course Statistics', () => {
const pathToLimits2021 =
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
@@ -288,10 +333,9 @@ describe('Course Statistics', () => {
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
const card = cy.cs('Programme-filter-card')
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(33)
programmeDropdown.get('i.delete').click()
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
})
@@ -349,9 +393,10 @@ describe('Custom Population Statistics', () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
cy.cs('student-number-input').click().type(students.join('\n'))
cy.cs('student-number-input').click()
cy.cs('student-number-input').type(students.join('{enter}'))
cy.cs('search-button').click()
})
cy.cs('search-button').click()
})
it('Age filter works', () => {
@@ -369,10 +414,9 @@ describe('Custom Population Statistics', () => {
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
const card = cy.cs('Programme-filter-card')
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
programmeDropdown.get('i.delete').click()
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
})
@@ -402,15 +446,19 @@ describe('Custom Population Statistics', () => {
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
checkFilteringResult(7)
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
checkFilteringResult(6)
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
cy.cs('courseFilter-course-dropdown').click()
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
checkFilteringResult(5)
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
checkFilteringResult(2)
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
courses.forEach(({ code }) => {
cy.cs(`courseFilter-${code}-clear`).click()
})
})
})
})
+239 -193
View File
@@ -3,19 +3,20 @@
const selectStudyProgramme = programme => {
cy.contains('Select study programme')
cy.get('[data-cy=select-study-programme]').click().children().contains(programme).click()
cy.get('[data-cy=select-study-programme]').click()
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
}
const selectStudyTrack = studyTrack => {
cy.contains('Select study track')
cy.get('[data-cy=select-study-track]').click().children().contains(studyTrack).click()
cy.get('[data-cy=select-study-track]').click()
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
}
describe('Population statistics tests', () => {
const getPath = programme => {
return `/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"${programme}"%7D&year=2020`
}
const pathToMathBSc2020 = getPath('KH50_001')
const pathToMathMSc2020 = getPath('MH50_001')
@@ -90,90 +91,6 @@ describe('Population statistics tests', () => {
cy.cs('filtered-students')
})
it('Courses of class is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 Johdatus yliopistomatematiikkaan')
cy.contains('A57033 Avoin yo: Johdatus yliopistomatematiikkaan')
})
it('Courses of class curriculum selection works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
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.get('[data-cy=curriculum-picker]').click()
cy.contains('2020–2023').click({ force: true })
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
})
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
})
})
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.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
})
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
cy.get('[data-cy=option-graduated-true]').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(16)
})
})
})
it("Empty 'tags' tab has a link to the page where tags can be created", { retries: 2 }, () => {
cy.visit(pathToMathBSc2020)
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
cy.get('[data-cy=student-table-tabs]').within(() => {
cy.contains('Tags').click()
})
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.url().should('include', '/study-programme/KH50_001?p_m_tab=0&p_tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create tags for study programme')
cy.contains('button', 'Create a new tag').should('be.disabled')
})
// This test sometimes fails on headless mode. It seems that the click on the
// 'Fetch class with new settings' button doesn't always trigger history.push()
// so the page doesn't reload. This is why waiting also doesn't help.
@@ -200,121 +117,248 @@ describe('Population statistics tests', () => {
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
})
it("'Credits gained' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit(pathToMathBSc2020)
const totalStudents = 27
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.contains('Credits gained').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
const getTableData = (selector, numbersOfStudents) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `[data-cy='credits-gained-table-${selector}']`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
cy.get('th').eq(2).contains('Number of students')
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
cy.get('th').eq(3).contains('Percentage of population')
})
for (const category of [
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
]) {
category.forEach(({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(1).contains(value)
cy.get('td').eq(2).contains(students)
cy.get('td').eq(3).contains(percentage)
})
describe('Credit statistics', () => {
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit(pathToMathBSc2020)
const totalStudents = 27
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.contains('Credits gained').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
const getTableData = (selector, numbersOfStudents) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `[data-cy='credits-gained-table-${selector}']`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
cy.get('th').eq(2).contains('Number of students')
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
cy.get('th').eq(3).contains('Percentage of population')
})
}
cy.get(
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
).then(tds => {
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
expect(sum).to.equal(totalStudents)
})
})
it("'Statistics' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.visit(pathToMathBSc2020)
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h3', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
for (const category of [
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
]) {
category.forEach(({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(0).contains(text)
cy.get('td').eq(1).contains(data[index])
cy.get('td').eq(1).contains(value)
cy.get('td').eq(2).contains(students)
cy.get('td').eq(3).contains(percentage)
})
})
})
}
cy.get(
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
).then(tds => {
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
expect(sum).to.equal(totalStudents)
})
}
})
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.visit(pathToMathBSc2020)
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h3', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(0).contains(text)
cy.get('td').eq(1).contains(data[index])
})
})
})
})
}
})
})
describe('Courses of class', () => {
it('Is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
})
it('Curriculum selection works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
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.get('[data-cy=curriculum-picker]').click()
cy.contains('2020–2023').click({ force: true })
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
})
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
})
})
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.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(27)
})
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
cy.get('[data-cy=option-graduated-true]').click()
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
expect(response.body.allStudents).to.equal(16)
})
})
})
})
describe('Students', () => {
beforeEach(() => {
cy.visit(pathToMathBSc2020)
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
})
it("'General tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
cy.contains('Teoreettisten ja laskennallisten menetelmien maisteriohjelma')
cy.contains('Todistusvalinta')
})
})
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
cy.contains('MAT12001')
cy.contains('MAT21001')
})
})
it("'Modules tab' Displays correct modules based on the selected programme", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Modules').click()
cy.contains('MAT011')
cy.contains('MAT110')
})
cy.contains('Courses of class').click()
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click()
cy.contains('Courses of class').click()
cy.cs('student-table-tabs').within(() => {
cy.contains('MAT011').should('not.exist')
cy.contains('MAT110')
})
})
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Tags').click()
cy.contains('No tags defined. You can define them here.').find('a').click()
})
cy.url().should('include', '/study-programme/KH50_001?tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create tags for study programme')
cy.contains('button', 'Create a new tag').should('be.disabled')
})
})
})
@@ -332,7 +376,9 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.get('textarea').type(existing).type('{enter}').type(nonExisting)
cy.get('textarea').type(existing)
cy.get('textarea').type('{enter}')
cy.get('textarea').type(nonExisting)
cy.contains('button', 'Check students').click()
cy.contains('#checkstudentsresults', 'Results').within(() => {
cy.contains('Student numbers in list and in Sisu').click()
+65 -48
View File
@@ -1,15 +1,15 @@
/// <reference types="cypress" />
const student = {
firstnames: 'Varpu Roope',
lastname: 'Mårtensson',
studentnumber: '550003',
sis_person_id: 'hy-hlo-115926826',
firstNames: 'Varpu Roope',
lastName: 'Mårtensson',
studentNumber: '550003',
sisPersonId: 'hy-hlo-115926826',
email: 'sisutestidata134902@testisisudata.fi',
}
const typeToSearch = text => {
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
cy.cs('student-search').type(text)
}
const typeStudentNumberAndClick = studentNumber => {
@@ -17,10 +17,6 @@ const typeStudentNumberAndClick = studentNumber => {
cy.contains('td', studentNumber).click()
}
const clearSearch = () => {
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").clear()
}
describe('Students tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
@@ -31,65 +27,84 @@ describe('Students tests', () => {
})
it('Students search form is usable', () => {
typeToSearch(student.lastname)
typeToSearch(student.lastName)
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains(student.firstnames).should('not.exist')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.firstnames)
cy.contains(student.firstNames)
cy.contains('Student number')
cy.contains('Last name')
cy.contains('First names')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames)
cy.contains(student.lastName)
cy.cs('toggleStudentNames').click()
cy.contains(student.firstnames).should('not.exist')
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
})
it('Search term must be at least 4 characters long', () => {
typeToSearch(student.lastname.slice(0, 3))
typeToSearch(student.lastName.slice(0, 3))
cy.contains('Search term is not accurate enough')
typeToSearch(student.lastname.slice(3))
typeToSearch(student.lastName.slice(3))
cy.get('table tbody tr').should('have.length', 1)
})
it('Can search with studentnumber too', () => {
typeToSearch(student.studentnumber)
cy.contains(student.studentnumber)
it('Can search with student number too', () => {
typeToSearch(student.studentNumber)
cy.contains(student.studentNumber)
})
it('Can get student specific page by clicking student', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
cy.contains(student.lastname).should('not.exist')
cy.contains(student.firstnames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.lastname)
cy.contains(student.firstnames)
cy.contains(student.lastName)
cy.contains(student.firstNames)
cy.contains(student.email)
cy.cs('toggleStudentNames').click()
cy.contains(student.lastname).should('not.exist')
cy.contains(student.firstnames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
})
it("'Update student' button is not shown", () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=student-info-card]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student').should('not.exist')
})
})
it('Can get back to search menu', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.go('back')
cy.contains('Student number').should('not.exist')
cy.contains('Credits').should('not.exist')
})
it('Can jump to course', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Tilastollinen päättely I (MAT12004)')
.parent()
.siblings()
@@ -98,26 +113,26 @@ describe('Students tests', () => {
cy.get('a').click()
})
cy.url().should('include', '/coursestatistics')
cy.contains('MAT12004 Tilastollinen päättely I')
cy.contains('AYMAT12004 Avoin yo: Tilastollinen päättely I')
cy.contains('57046 Johdatus tilastolliseen päättelyyn')
cy.contains('MAT12004 • Tilastollinen päättely I')
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
})
it('Has correct Sisu link', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=sisu-link]')
typeStudentNumberAndClick(student.studentNumber)
cy.cs('sisu-link')
.should('have.attr', 'href')
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
})
it('Semester enrollments can be toggled', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
const programmes = [
'Oikeustieteen tohtoriohjelma',
'Matemaattisten tieteiden kandiohjelma',
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
]
cy.get('[data-cy=student-info-card]').within(() => {
cy.cs('student-info-card').within(() => {
cy.contains('Enrollments').click()
cy.get('table tbody tr')
.should('have.length', 3)
@@ -129,17 +144,19 @@ describe('Students tests', () => {
})
})
it("Searching with bad inputs doesn't yield results", () => {
it("Searching with bad name doesn't yield results", () => {
typeToSearch('SWAG LITTINEN')
cy.contains('Student number').should('not.exist')
clearSearch()
})
it("Searching with bad student number doesn't yield results", () => {
typeToSearch('01114')
cy.contains('Student number').should('not.exist')
})
it('Can jump to population page', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=study-rights-section]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('study-rights-section').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => {
@@ -151,16 +168,16 @@ describe('Students tests', () => {
})
it('Grade graph works in all three different modes', () => {
typeStudentNumberAndClick(student.studentnumber)
typeStudentNumberAndClick(student.studentNumber)
cy.contains('button', 'Grade graph').click()
cy.contains('button', 'Show group mean').click()
cy.get('[data-cy=group-size-input]').within(() => {
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').should('have.value', '5')
})
cy.contains('.highcharts-container text', "Nov '22")
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
cy.get('[data-cy=group-size-input]').within(() => {
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
@@ -215,7 +232,7 @@ describe('Students tests', () => {
})
it("Shows 'Has not graduated' when the student has not graduated", () => {
cy.visit(`students/${student.studentnumber}`)
cy.visit(`students/${student.studentNumber}`)
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
@@ -229,14 +246,14 @@ describe('Students tests', () => {
cy.init('/students', 'admin')
})
it('Does not crash if student has no studyright or courses', () => {
it('Does not crash if student has no study rights or courses', () => {
typeStudentNumberAndClick('450730')
cy.contains('Credits: 0')
})
it("'Update student' button is shown", () => {
typeStudentNumberAndClick(student.studentnumber)
cy.get('[data-cy=student-info-card]').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student')
})
})
+1 -1
View File
@@ -124,7 +124,7 @@ describe('Study guidance group tests', () => {
cy.cs('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
})
})
@@ -10,8 +10,34 @@ const deleteTag = name => {
}
describe('Study programme overview', () => {
/* Basic information overview -tests */
describe('Basic information view works for basic user', () => {
describe('Study programme selector', () => {
beforeEach(() => {
cy.init('/study-programme', 'admin')
})
it('Correct categories', () => {
cy.contains('Bachelor programmes')
cy.contains('Master programmes')
cy.contains('Combined programmes')
cy.contains('Doctoral programmes')
})
it('Study programme search filter', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
})
it('Old and specialized programmes filter', () => {
cy.contains('Biotekniikka (ylempi)').should('not.exist')
cy.cs('filter-old-programmes-toggle').click()
cy.contains('Biotekniikka (ylempi)').should('exist')
})
})
describe('Basic information tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
@@ -19,11 +45,11 @@ describe('Study programme overview', () => {
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information tab loads', () => {
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
cy.get('[data-cy=Section-averageGraduationTimes]')
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('programmes-before-or-after-section')
cy.cs('average-graduation-times-section')
})
it('Basic information contains correct students', () => {
@@ -40,7 +66,7 @@ describe('Study programme overview', () => {
[2017, 41, 47, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
})
it('Basic information contains correct credits', () => {
@@ -56,11 +82,11 @@ describe('Study programme overview', () => {
[2017, 1211, 1211, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it("Toggling 'Show special categories' on displays additional information", () => {
cy.contains('Show special categories').click()
cy.cs('special-categories-toggle').click()
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
@@ -73,7 +99,7 @@ describe('Study programme overview', () => {
[2017, 1211, 1211, 0, 0, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it('Basic information contains correct thesis writers and graduates', () => {
@@ -81,20 +107,20 @@ describe('Study programme overview', () => {
const tableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 0],
[2022, 48, 0],
[2021, 47, 66],
[2020, 12, 18],
[2019, 1, 15],
[2023, 26, 5],
[2022, 48, 13],
[2021, 47, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
})
it('Special study rights can be excluded and basic data changes accordingly', () => {
cy.get('[data-cy=StudentToggle]').click()
cy.cs('study-right-toggle').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated]
@@ -108,25 +134,25 @@ describe('Study programme overview', () => {
[2017, 41, 47, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 0],
[2022, 44, 0],
[2021, 46, 60],
[2020, 11, 17],
[2019, 1, 15],
[2023, 24, 5],
[2022, 44, 12],
[2021, 46, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.get('[data-cy=YearToggle]').click({ force: true })
cy.cs('year-toggle').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
@@ -140,7 +166,7 @@ describe('Study programme overview', () => {
['2018 - 2019', 40, 45, 0, 0, 0],
['2017 - 2018', 41, 47, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
@@ -153,33 +179,33 @@ describe('Study programme overview', () => {
['2017 - 2018', 2350, 2345, 0, 5, 26],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
cy.get('[data-cy=YearToggle]').click()
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
cy.cs('year-toggle').click()
})
it('Basic information graphs render', () => {
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
cy.cs('students-of-the-study-programme-line-graph-section')
.should('contain', 'Started studying')
.should('contain', 'Accepted')
.should('contain', 'Graduated')
.should('contain', 'Transferred away')
.should('contain', 'Transferred to')
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
.should('contain', 'Degree students')
.should('contain', 'Transferred')
.should('contain', 5796)
.should('contain', 428)
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
.should('contain', 'Graduated students')
.should('contain', 'Wrote thesis')
.should('contain', 47)
.should('contain', 66)
.should('contain', 76)
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
cy.contains('Graduation year')
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
@@ -190,7 +216,7 @@ describe('Study programme overview', () => {
cy.contains('14 graduated over year late')
})
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
@@ -205,11 +231,11 @@ describe('Study programme overview', () => {
cy.init('/study-programme')
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('master-breakdown-bar-chart-section')
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
cy.cs('graduation-time-toggle').click()
cy.cs('master-median-time-bar-chart-section').within(() => {
cy.contains('Master study right')
cy.contains('Graduation year')
cy.contains('2021')
@@ -221,7 +247,7 @@ describe('Study programme overview', () => {
cy.contains('0 graduated over year late')
})
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
cy.contains('Bachelor + master study right')
cy.contains('Graduation year')
cy.contains('2023')
@@ -235,19 +261,18 @@ describe('Study programme overview', () => {
})
})
/* Study track overview tests */
describe('Study track overview works for basic user', () => {
describe('Study tracks and class statistics tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Study tracks and class statistics').click()
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 -tab loads', () => {
cy.get('[data-cy=Section-studyTrackOverview]')
cy.get('[data-cy=Section-studyTrackProgress]')
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
it('Study tracks and class statistics', () => {
cy.cs('Section-studyTrackOverview')
cy.cs('Section-studyTrackProgress')
cy.cs('Section-averageGraduationTimesStudyTracks')
})
it('Students of the study programme are shown correctly', () => {
@@ -263,11 +288,11 @@ describe('Study programme overview', () => {
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'StudyTrackOverview')
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it('Years in the students table can be expanded and study track data will be shown', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
@@ -302,7 +327,7 @@ describe('Study programme overview', () => {
})
it('Links to class statistics page work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(1)
.within(() => {
@@ -315,7 +340,7 @@ describe('Study programme overview', () => {
})
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('td.total-row-cell a').click()
})
@@ -324,7 +349,7 @@ describe('Study programme overview', () => {
})
it('Links to class statistics page with study track info included work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
@@ -357,20 +382,20 @@ describe('Study programme overview', () => {
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
]
cy.checkTableStats(tableContents, 'StudyTrackProgress')
cy.checkTableStats(tableContents, 'study-track-progress-basic')
})
it('Study track overview graphs render', () => {
cy.get('[data-cy=Graph-StudyTrackProgress]')
cy.cs('study-track-progress-bar-chart')
.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.get('[data-cy=Graph-StudyTrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
cy.cs('study-track-progress-bar-chart').contains('49.8%').trigger('mouseover', { force: true })
cy.contains('At least 180 credits: 114')
cy.get("[data-cy='Section-KH50_001']").within(() => {
cy.cs('KH50_001-section').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
@@ -385,9 +410,9 @@ describe('Study programme overview', () => {
cy.contains('On time: 14')
})
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.cs('graduation-time-toggle').click()
cy.get("[data-cy='Section-KH50_001']").within(() => {
cy.cs('KH50_001-section').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
@@ -406,9 +431,7 @@ describe('Study programme overview', () => {
})
it('Students of the study track are shown correctly', () => {
cy.get("[data-cy='Section-studyTrackOverview']").contains(
'Students of the study track MAT-MAT by starting year'
)
cy.cs('Section-studyTrackOverview').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],
@@ -419,11 +442,11 @@ describe('Study programme overview', () => {
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'StudyTrackOverview')
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it('Links to class statistics page with study track info included work', () => {
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.regular-row')
.eq(2)
.within(() => {
@@ -445,7 +468,7 @@ describe('Study programme overview', () => {
)
})
it('Average graduation times are displayed correctly', () => {
it.skip('Average graduation times are displayed correctly', () => {
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
cy.contains('Start year')
@@ -454,8 +477,8 @@ describe('Study programme overview', () => {
cy.contains('Graduated On time: 9 students')
})
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('12 graduated').trigger('mouseover')
@@ -469,17 +492,16 @@ describe('Study programme overview', () => {
})
})
/* Programme courses -tests */
describe('Programme courses works for basic user', () => {
describe('Programme courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Programme courses').click()
cy.cs('programme-courses-tab').click()
})
it('content loads', () => {
cy.get('[data-cy=CoursesYearFilter]')
cy.get('[data-cy=CourseTabs]')
cy.cs('CoursesYearFilter')
cy.cs('CourseTabs')
})
it('time range selection works', () => {
@@ -492,8 +514,8 @@ describe('Study programme overview', () => {
cy.get('td').eq(2).contains('75')
})
})
cy.get('[data-cy=fromYear]').click().contains('2018').click()
cy.get('[data-cy=toYear').click().contains('2019').click()
cy.cs('fromYear').click().contains('2018').click()
cy.cs('toYear').click().contains('2019').click()
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
cy.get('tr')
@@ -507,9 +529,9 @@ describe('Study programme overview', () => {
})
it("'Calendar year/Academic year' toggle works", () => {
cy.get('[data-cy=calendarAcademicYearToggle]').click()
cy.get('[data-cy=fromYear]').contains('2017-2018')
cy.get('[data-cy=toYear').contains('2023-2024')
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)
@@ -522,14 +544,14 @@ describe('Study programme overview', () => {
})
it('contains correct courses in alphabetical order', () => {
cy.get('[data-cy=CoursesSortableTable]').within(() => {
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.get('[data-cy=CoursesSortableTable]').within(() => {
cy.cs('CoursesSortableTable').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
@@ -608,7 +630,7 @@ describe('Study programme overview', () => {
})
})
cy.get('[data-cy=creditsStudentsToggle]').click()
cy.cs('creditsStudentsToggle').click()
cy.get('[data-cy=CoursesSortableTable] thead tr')
.eq(0)
@@ -657,16 +679,16 @@ describe('Study programme overview', () => {
})
})
describe('Degree courses works for basic user', () => {
describe('Degree courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.get('.attached').contains('Degree courses').click()
cy.cs('degree-courses-tab').click()
})
it('content loads', () => {
cy.contains('h3', 'Select curriculum to edit:')
cy.get('[data-cy=curriculum-picker]').contains('2023–2026')
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(() => {
@@ -678,12 +700,11 @@ describe('Study programme overview', () => {
})
})
/* Tag-tests */
describe('Tags view works for basic user', () => {
describe('Tags tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.get('.attached').contains('Tags').click()
cy.cs('tags-tab').click()
})
it('can create and delete tags for population', () => {
@@ -728,7 +749,9 @@ describe('Study programme overview', () => {
cy.contains('Successfully added tags to students.')
cy.contains('td', name).get('i.level.up.alternate.icon').click()
cy.contains('td', name).within(() => {
cy.get('i.level.up.alternate.icon').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains(`Tagged with: ${name}`)
@@ -749,7 +772,7 @@ describe('Study programme overview', () => {
}
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
cy.url().should('include', '/study-programme/KH50_001?tab=4')
deleteTag(name)
})
@@ -780,25 +803,27 @@ describe('Study programme overview', () => {
})
it('can access basic information', () => {
cy.contains('Basic information').click()
cy.cs('basic-information-tab').click()
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
cy.get('[data-cy=Section-averageGraduationTimes]')
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('average-graduation-times-section')
cy.cs('programmes-before-or-after-section')
})
it('can access study tracks', () => {
cy.get('.attached').contains('Study tracks and class statistics').click()
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.get('[data-cy=Section-studyTrackOverview]')
cy.get('[data-cy=Section-studyTrackProgress]')
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
cy.cs('Section-studyTrackOverview')
cy.cs('Section-studyTrackProgress')
cy.cs('Section-averageGraduationTimesStudyTracks')
})
it("doesn't see other tabs", () => {
cy.get('div.ui.tabular.menu a').should('have.length', 2)
cy.cs('degree-courses-tab').should('not.exist')
cy.cs('tags-tab').should('not.exist')
cy.cs('update-statistics-tab').should('not.exist')
})
})
})
+17 -17
View File
@@ -1,26 +1,26 @@
/// <reference types="cypress" />
const progressLevels = ['Bachelors', 'BachelorMasters', 'Masters', 'Doctoral']
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
const checkProgressBarCharts = () => {
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressBarChart]`))
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
}
const checkProgressTables = () => {
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressTable]`))
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
const checkAverageGraduationTimesBreakdownBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.get(`[data-cy=${level}BreakdownBarChart]`)
cy.cs(`${level}-breakdown-bar-chart`)
})
}
const checkAverageGraduationTimesMedianBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.get(`[data-cy=${level}MedianBarChart]`)
cy.cs(`${level}-median-bar-chart`)
})
}
@@ -40,20 +40,20 @@ describe('University view', () => {
})
it("'Graduated included / Graduated excluded' toggle works", () => {
cy.get('[data-cy=GraduatedToggle]').click()
cy.cs('graduated-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
it("'All study rights / Special study rights excluded' toggle works", () => {
cy.get('[data-cy=StudyRightToggle]').click()
cy.cs('study-right-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
it('years in the tables can be clicked to show faculty level breakdown', () => {
cy.get('[data-cy=StudyRightToggle]').click()
cy.get('[data-cy=FacultyBachelorsProgressTableShowButton3]').click()
cy.cs('study-right-toggle').click()
cy.cs('faculty-bachelors-progress-table-show-button3').click()
cy.contains('29.5%').trigger('mouseover', { force: true })
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
@@ -66,16 +66,16 @@ describe('University view', () => {
})
it('info boxes contain correct information', () => {
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan kuuluvien')
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxButton]').click()
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
cy.cs('faculty-progress-info-box-button').click()
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
})
})
describe('Faculty graduations tab', () => {
beforeEach(() => {
cy.get('[data-cy=FacultyGraduationsTab]').click()
cy.cs('faculty-graduations-tab').click()
})
it('has all the correct median time bar charts', () => {
@@ -83,13 +83,13 @@ describe('University view', () => {
})
it("'Breakdown/Median times' toggle works", () => {
cy.get('[data-cy=GraduationTimeToggle]').click()
cy.cs('graduation-time-toggle').click()
checkAverageGraduationTimesMedianBarCharts()
})
it('info boxes contain correct information', () => {
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset valmistumisajat')
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
})
+5 -5
View File
@@ -9,7 +9,7 @@ visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Progra
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const containsLinks = links => {
cy.get('[data-cy=nav-bar]').within(() => {
cy.cs('nav-bar').within(() => {
for (const link of links) {
cy.contains(link)
}
@@ -17,7 +17,7 @@ const containsLinks = links => {
}
const userButtonWorks = (username, mocking = false) => {
cy.get('[data-cy=nav-bar-user-button]').click()
cy.cs('nav-bar-user-button').click()
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
cy.contains('Language')
cy.contains('suomi')
@@ -61,10 +61,10 @@ describe('Users tests', () => {
userButtonWorks('admin')
})
describe('can mock as other users', () => {
describe('can mock as other users', { retries: 3 }, () => {
beforeEach(() => {
cy.cs('user-edit-button-basic').click()
cy.get('i.spy').click()
cy.cs('user-page-button-basic').click()
cy.cs('mock-button').click()
})
it('user button shows mocked user', () => {
+1 -1
View File
@@ -128,7 +128,7 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) =>
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
correctStats.forEach((values, trIndex) => {
cy.get('tr')
.filter(':visible')
+3 -1
View File
@@ -46,7 +46,7 @@ services:
<<: *defaults
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:6.2.16
image: redis:7.2.6
ports:
- 6379:6379
@@ -123,6 +123,7 @@ services:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
@@ -145,6 +146,7 @@ services:
environment:
CI: 'true'
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
+1
View File
@@ -21,6 +21,7 @@ services:
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
SIS_URL: 'https://sis-qa.funidata.fi'
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
+3
View File
@@ -64,6 +64,7 @@ services:
environment:
REACT_APP_SERVICE_PROVIDER: fd
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
@@ -124,6 +125,7 @@ services:
- sis-updater-nats
environment:
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
@@ -155,6 +157,7 @@ services:
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
+3 -1
View File
@@ -76,7 +76,7 @@ services:
<<: *defaults
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:6.2.16
image: redis:7.2.6
ports:
- '6379:6379'
volumes:
@@ -123,6 +123,7 @@ services:
- sis-updater-nats
environment:
REDIS_URI: //redis:6379
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
@@ -151,6 +152,7 @@ services:
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_URI: //redis:6379
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
+127
View File
@@ -0,0 +1,127 @@
# How to update the database schema
These instructions explain how to update the schemas for the following databases used by Oodikone:
- `sis-db`
- `user-db`
- `kone-db`
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
> [!NOTE]
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
## General guidelines
### File names
- Name new migration files using the format `YYYYMMDD_XX_description.js`.
- The `XX` part is a migration number (starting from `00` each day).
- Example: `20250122_00_add_column_to_table.js`.
### Model vs. column names
- Use **camelCase** in your model definitions.
- Use **snake_case** in the actual database columns.
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
> [!IMPORTANT]
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
### Verifying the migration locally
- Migrations are run automatically whenever the server restarts.
- After creating a migration, ensure the relevant service is running:
- For `sis-db`: `npm run both:real` or `npm run updater:real`
- For `user-db`/`kone-db`: `npm run both:real` or `npm run oodikone:real`
- **Connect to the database container** and check:
```bash
docker exec -it <container-name> psql -U postgres <database-name>-real
```
Replace `<container-name>` and `<database-name>` with the actual values (e.g., `sis-db`, `user-db`, or `kone-db`).
- In the psql prompt, verify your migration:
```sql
SELECT * FROM migrations;
```
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
### Reverting a migration locally
If you need to **revert a migration locally**, follow these steps:
1. Locate the `migrator.up()` function in the code:
- For `user-db` and `kone-db`: [`services/backend/src/database/connection.ts`](../services/backend/src/database/connection.ts)
- For `sis-db`: [`updater/sis-updater-worker/src/db/connection.js`](../updater/sis-updater-worker/src/db/connection.js)
1. Change `migrator.up()` to `migrator.down()` and save the file. The latest migration will be reverted automatically when the server reloads.
1. **Important:** After the migration is reverted, change `migrator.down()` back to `migrator.up()` to ensure future migrations run correctly.
1. For more instructions, see the [umzug documentation](https://github.com/sequelize/umzug?tab=readme-ov-file#usage).
### Deploying changes to production
- **Push changes** to the `master` branch and wait for the workflow to finish.
- For **`sis-db`** migrations (in the updater):
- You **do not** need to create a new release. Once tests pass, changes to the updater go directly to production.
- For **`user-db`** and **`kone-db`** migrations (in the backend):
- A new release must be made before the changes go to production.
- After deployment, check logs (for example, in OpenShift) to confirm the migration ran successfully.
- Optionally, use the `db-cli` tool to inspect the production database. See `how_to_db_cli.md` (in GitLab) for instructions.
## `sis-db`
> [!NOTE]
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
1. **Update the updater model:**
- Go to `updater/sis-updater-worker/src/db/models/` and choose the model you want to change.
- Add or modify the column (with its **type** and any other necessary fields).
- Check the correct data type in the importer.
- Use camelCase in the model, snake_case in the database.
1. **Update the backend model:**
- Go to `services/backend/src/models/`.
- The models are in different formats (JavaScript in the updater, TypeScript in the backend), but follow existing examples.
1. **Create a migration file:**
- Go to `updater/sis-updater-worker/src/db/migrations/`.
- Name your file using the format described above (e.g. `20250122_00_add_column_to_table.js`).
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
1. **Run and verify the migration:**
- Ensure that the updater is running (`npm run both:real` or `npm run updater:real`).
- Use the steps in [General guidelines](#general-guidelines) to verify.
1. **Push to master and deploy:**
- Your migration will go to production automatically after tests pass.
- Check logs in production (OpenShift) or use `db-cli` to confirm success.
## `user-db` and `kone-db`
1. **Update the model:**
- Go to `services/backend/src/models/kone` (for `kone-db`) or `services/backend/src/models/user.ts` (the only model for user-db)
- Add or modify the column, type, and any other needed fields. Follow existing models for structure.
1. **Create a migration file:**
- Go to `services/backend/src/database/`.
- Use `migrations_kone` (for `kone-db`) or `migrations_user` (for `user-db`).
- Name the file using the same `YYYYMMDD_XX_description.js` format.
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
1. **Run and verify the migration:**
- Make sure Oodikone is running (`npm run both:real` or `npm run oodikone:real`).
- Follow the same verification steps as in [General Guidelines](#general-guidelines).
1. **Push to master and release:**
- Wait for the CI workflow to pass.
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
+1
View File
@@ -44,6 +44,7 @@ A collection of useful tools, documentation and other resources.
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
- [Material Icons](https://mui.com/material-ui/material-icons/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
<!--
+982 -628
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -38,27 +38,27 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.9",
"@eslint/js": "^9.17.0",
"@cypress/code-coverage": "^3.13.11",
"@eslint/js": "^9.20.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^13.16.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"cypress": "^14.0.3",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-import-resolver-typescript": "^3.8.1",
"eslint-plugin-cypress": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.13.0",
"globals": "^15.15.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"lint-staged": "^15.4.3",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.4.2",
"stylelint": "^16.11.0",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.18.0"
"prettier": "3.5.1",
"stylelint": "^16.14.1",
"stylelint-config-standard": "^37.0.0",
"typescript-eslint": "^8.24.1"
},
"dependencies": {
"lodash": "^4.17.21",
-9
View File
@@ -7,19 +7,10 @@
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true,
"matchPackageNames": ["*"]
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
"versioning": "node"
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"matchUpdateTypes": ["minor", "patch"],
"enabled": false
}
],
+267 -1011
View File
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -5,22 +5,21 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.45.0",
"@sentry/profiling-node": "^8.45.0",
"axios": "^0.29.0",
"bullmq": "^5.34.2",
"compression": "^1.7.5",
"@sentry/node": "^8.55.0",
"@sentry/profiling-node": "^8.55.0",
"axios": "^1.7.9",
"bullmq": "^5.41.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^3.3.1",
"cron": "^3.5.0",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"morgan": "^1.10.0",
"papaparse": "^5.4.1",
"pg": "^8.13.1",
"papaparse": "^5.5.2",
"pg": "^8.13.3",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
@@ -34,16 +33,16 @@
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.13",
"@types/lodash": "^4.17.15",
"@types/morgan": "^1.9.9",
"@types/node": "^22.10.2",
"@types/node": "^22.13.4",
"@types/validator": "^13.12.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.3.12"
"gelf-pro": "^1.4.0"
}
}
}
+2
View File
@@ -56,6 +56,7 @@ let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
if (DB_MAX_CRON_CONNECTIONS < 1) {
DB_MAX_CRON_CONNECTIONS = 1
}
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
const redis = process.env.REDIS
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
@@ -70,6 +71,7 @@ module.exports = {
DB_URL_KONE,
DB_MAX_CONNECTIONS,
DB_MAX_CRON_CONNECTIONS,
CRON_SCHEDULE,
redis,
SECRET_TOKEN,
DB_SCHEMA_KONE,
+3 -3
View File
@@ -1,6 +1,6 @@
import { CronJob } from 'cron'
import { isProduction, runningInCI, languageCenterViewEnabled } from './config'
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
import { getFaculties } from './services/faculty/facultyHelpers'
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
@@ -105,8 +105,8 @@ const dailyJobs = async () => {
export const startCron = () => {
if (isProduction && !runningInCI) {
logger.info('Cronjob for refreshing stats started: runs daily at 23:00')
schedule('0 23 * * *', async () => {
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
schedule(CRON_SCHEDULE, async () => {
logger.info('Running daily jobs from cron')
await dailyJobs()
})
+3
View File
@@ -69,6 +69,9 @@ export class Student extends Model<InferAttributes<Student>> {
@Column(DataType.BOOLEAN)
hasPersonalIdentityCode!: boolean
@Column(DataType.STRING)
preferredLanguage!: string
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
+3
View File
@@ -43,6 +43,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
@Column(DataType.ARRAY(DataType.STRING))
included_courses!: string[]
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
includedModules!: string[]
@Column(DataType.STRING)
sisu_id!: string
+30 -16
View File
@@ -1,12 +1,10 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import moment from 'moment'
import { serviceProvider } from '../config'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { CourseWithSubsId } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
import logger from '../util/logger'
const router = Router()
@@ -24,24 +22,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
let results: { courses: CourseWithSubsId[] } = { courses: [] }
results = await getCoursesByNameAndOrCode(name, code)
const courses = await getCoursesByNameAndOrCode(name, code)
if (combineSubstitutions === 'false') {
const courseCodes = results.courses.map(course => course.code)
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
results.courses
.flatMap(course => course.substitutions)
.filter(code => code !== null && !courseCodes.includes(code))
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
results.courses.push(substitution.dataValues)
courses.push(substitution.toJSON())
}
}
res.json(results)
const mergedCourses = {}
for (const course of courses) {
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
if (!(course.max_attainment_date && course.min_attainment_date)) {
continue
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
}
res.json({ courses: Object.values(mergedCourses) })
})
interface GetCourseYearlyStatsRequest extends Request {
@@ -72,16 +91,11 @@ router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
if (serviceProvider === 'fd')
logger.info('Debugging c.y.s.: got through authorization and collected req query params.')
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific study programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
if (serviceProvider === 'fd')
logger.info('Debugging c.y.s.: getCourseYearlyStats returned and route should return result.')
res.json(results)
})
+7 -6
View File
@@ -5,6 +5,7 @@ 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()
@@ -14,7 +15,7 @@ router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
res.json(results)
})
router.get('/access_groups', auth.roles(['admin']), (_req: Request, res: Response) => {
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
res.json(roles)
})
@@ -34,17 +35,17 @@ router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response
}
})
interface ModifyAccessRequest extends Request {
interface ModifyRolesRequest extends Request {
body: {
username: string
accessgroups: Record<string, boolean>
roles: Record<Role, boolean>
}
}
router.post('/modifyaccess', auth.roles(['admin']), async (req: ModifyAccessRequest, res: Response) => {
const { username, accessgroups } = req.body
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
const { username, roles } = req.body
try {
await userService.modifyAccess(username, accessgroups)
await userService.modifyAccess(username, roles)
res.status(204).end()
} catch (error) {
res.status(400).json(error)
@@ -1,6 +1,6 @@
import { col, fn, Op, where } from 'sequelize'
import { Course, Credit, Enrollment, Student } from '../models/index'
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
import { CreditTypeCode, EnrollmentState } from '../types'
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
@@ -23,9 +23,10 @@ interface StudentInfo {
lastname: string
email: string
sis_person_id: string
secondary_email: string | null
coursesInStudyPlan: string[]
secondaryEmail: string | null
credits: StudentCredit[]
enrollments: StudentEnrollment[]
enrollments: Record<string, StudentEnrollment>
}
type StudentCredits = Record<
@@ -36,16 +37,18 @@ type StudentCredits = Record<
}
>
interface FormattedStudent {
interface FormattedStudent extends StudentInfo {
studentNumber: string
sis_person_id: string
credits: StudentCredit[]
enrollments: Record<string, any>
allEnrollments: StudentEnrollment[]
firstnames: string
lastname: string
email: string
secondaryEmail: string | null
}
interface StudentWithStudyplanNested
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
studyplans: { included_courses: string[] }[]
}
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
coursesInStudyPlan: string[]
}
const getCourses = async (courseCodes: string[]) => {
@@ -122,19 +125,27 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
}
const getStudents = async (studentNumbers: string[]) => {
const students: Array<
Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'>
> = (
await Student.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
const students = await Student.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
})
).map(student => student.toJSON())
return students
},
include: [
{
model: Studyplan,
as: 'studyplans',
attributes: ['included_courses'],
},
],
})
return students.map(student => {
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
return { ...rest, coursesInStudyPlan } as StudentWithCourses
})
}
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
@@ -159,7 +170,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
lastname: student.lastname,
email: student.email,
sis_person_id: student.sis_person_id,
secondary_email: student.secondary_email,
secondaryEmail: student.secondary_email,
coursesInStudyPlan: student.coursesInStudyPlan,
credits: [],
enrollments: [],
}
@@ -214,7 +226,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondary_email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
}, [] as FormattedStudent[])
@@ -1,3 +1,4 @@
import { orderBy } from 'lodash'
import { Op } from 'sequelize'
import { Course } from '../../models'
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
const getCourses = async (name: string, code: string) => {
const rawCourses = await getRawCourses(name, code)
const courses: CourseWithSubsId[] = rawCourses
.map(course => ({ ...course.dataValues }))
.map(course => ({
...course.toJSON(),
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
}))
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
return courses
}
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
}
})
return { courses }
return courses
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -1,4 +1,7 @@
import { max, min } from 'lodash'
import { Name } from '../../shared/types'
import { getSemesterNameByCode } from '../semesters'
import { FormattedProgramme, OrganizationDetails } from './helpers'
type Programme = {
@@ -284,14 +287,42 @@ export class CourseYearlyStatsCounter {
return this.programmes
}
private parseGroupStatistics(anonymizationSalt: string | null) {
private insertEmptyRows = async () => {
const first = min(Object.keys(this.groups).map(Number))
const last = max(Object.keys(this.groups).map(Number))
if (!(first && last)) {
return
}
const getNextYear = (name: string) => {
const years = name.split('-')
const startYear = parseInt(years[0], 10) + 1
const endYear = parseInt(years[1], 10) + 1
return `${startYear}-${endYear}`
}
for (let i = first + 1; i <= last; i++) {
if (this.groups[i]) {
continue
}
const previous = this.groups[i - 1]
const semester = await getSemesterNameByCode(i)
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
}
}
private async parseGroupStatistics(anonymizationSalt: string | null) {
for (const [studentNumber, data] of this.students) {
this.groups[data.code].students.studentNumbers.push(studentNumber)
}
await this.insertEmptyRows()
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
const { students } = rest
const grades = {}
const grades = {} as Grades
Object.keys(students.grades).forEach(student => {
const { grade, passed } = students.grades[student]
const parsedGrade = passed ? grade : '0'
@@ -350,10 +381,10 @@ export class CourseYearlyStatsCounter {
return this.facultyStats
}
public getFinalStatistics(anonymizationSalt: string | null) {
public async getFinalStatistics(anonymizationSalt: string | null) {
return {
programmes: this.parseProgrammeStatistics(anonymizationSalt),
statistics: this.parseGroupStatistics(anonymizationSalt),
statistics: await this.parseGroupStatistics(anonymizationSalt),
facultyStats: this.parseFacultyStatistics(anonymizationSalt),
obfuscated: this.obfuscated,
}
@@ -53,7 +53,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
const studyRightIds = Object.keys(studyRightMap)
const studyRightElements = await SISStudyRightElement.findAll({
attributes: ['code', 'name', 'startDate', 'studyRightId'],
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
where: {
studyRightId: {
[Op.in]: studyRightIds,
+14 -37
View File
@@ -1,12 +1,11 @@
import crypto from 'crypto'
import moment from 'moment'
import { Op } from 'sequelize'
import { serviceProvider } from '../../config'
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
import { Name } from '../../shared/types'
import { EnrollmentState, Unification } from '../../types'
import { isOpenUniCourseCode } from '../../util'
import logger from '../../util/logger'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
@@ -67,11 +66,22 @@ const parseCredit = (
credits,
attainment_date: attainmentDate,
student_studentnumber: studentNumber,
studyright_id: studyRightId,
} = credit
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
const programmes = studyRightElements.map(formatStudyRightElement)
let programmeOfCredit: SISStudyRightElement | undefined
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
if (!programmeOfCredit) {
programmeOfCredit = studyRightElements
.filter(studyRightElement =>
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
)
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
}
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
const formattedCredit: FormattedCredit = {
yearCode,
@@ -149,10 +159,6 @@ const getYearlyStatsOfNew = async (
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
) => {
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s.: entered getYearlyStatsOfNew with courseCode ${courseCode} and unification ${unification}`
)
const courseForSubs = await Course.findOne({
where: { code: courseCode },
})
@@ -176,11 +182,6 @@ const getYearlyStatsOfNew = async (
}),
])
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, courseCode ${courseCode} unification ${unification}: got credits ${credits.length}, enrollments ${enrollments.length} and course ${course ? 'yes' : 'no'}`
)
const counter = new CourseYearlyStatsCounter()
for (const credit of credits) {
@@ -238,17 +239,8 @@ const getYearlyStatsOfNew = async (
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
})
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: finished looping through credits.`
)
const statistics = counter.getFinalStatistics(anonymizationSalt)
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got final statistics.`
)
const statistics = await counter.getFinalStatistics(anonymizationSalt)
let substitutionCourses: Course[] | undefined
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
@@ -262,11 +254,6 @@ const getYearlyStatsOfNew = async (
})
}
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got substitutionCourses ${substitutionCourses?.length} items.`
)
return {
...statistics,
coursecode: courseCode,
@@ -351,9 +338,6 @@ export const getCourseYearlyStats = async (
},
})
if (serviceProvider === 'fd')
logger.info(`Debugging c.y.s.: got credits ${credits.length} items and enrollments ${enrollments.length} items`)
const studentNumbers = {}
credits.forEach(credit => {
@@ -365,11 +349,6 @@ export const getCourseYearlyStats = async (
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap(Object.keys(studentNumbers))
if (serviceProvider === 'fd')
logger.info(
`Debugging c.y.s.: got a studentNumberToSrElementsMap of size ${Object.keys(studentNumberToSrElementsMap).length}`
)
const statsRegular = await Promise.all(
courseCodes.map(async courseCode => {
const unifyStats = await getYearlyStatsOfNew(
@@ -401,8 +380,6 @@ export const getCourseYearlyStats = async (
})
)
if (serviceProvider === 'fd') logger.info('Debugging c.y.s.: finished creating statsRegular')
return statsRegular
}
@@ -22,16 +22,21 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
type ProgrammeModuleWithRelevantAttributes = Pick<
InferAttributes<ProgrammeModule>,
'code' | 'name' | 'degreeProgrammeType' | 'curriculum_period_ids'
> & { progId: string }
'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
export const getDegreeProgrammesOfOrganization = async (organizationId: string, onlyCurrentProgrammes: boolean) => {
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { valid_from: Date }> =
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { validFrom: Date }> =
await sequelize.query(
`
SELECT code, name, degree_programme_type as "degreeProgrammeType", curriculum_period_ids, valid_from
SELECT
code,
curriculum_period_ids as "curriculumPeriodIds",
degree_programme_type as "degreeProgrammeType",
name,
valid_from as "validFrom"
FROM programme_modules
WHERE organization_id IN (
WITH RECURSIVE cte AS (
@@ -65,7 +70,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
for (const programmeVersions of Object.values(programmesGroupedByCode)) {
// Programmes are ordered by valid_from in descending order, so the first one whose valid_from date isn't in the future, is the newest version
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.valid_from)
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.validFrom)
if (!newestProgrammeVersion) {
continue
}
@@ -73,7 +78,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
const yearsOfProgramme = programmeVersions
.map(prog =>
prog.curriculum_period_ids.map(curriculumPeriodId => {
prog.curriculumPeriodIds.map(curriculumPeriodId => {
return mapCurriculumPeriodIdToYear(curriculumPeriodId, curriculumPeriods)
})
)
@@ -85,10 +90,10 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
if (isRelevantProgramme) {
relevantProgrammes.push({
code,
name,
curriculumPeriodIds: programmeVersions.map(prog => prog.curriculumPeriodIds).flat(),
degreeProgrammeType,
name,
progId,
curriculum_period_ids: programmeVersions.map(prog => prog.curriculum_period_ids).flat(),
})
}
}
@@ -22,39 +22,34 @@ const getStudentsAndCourses = async (
studentNumbers: string[] | null,
courseCodes: string[] | undefined
): Promise<[number, CoursesQueryResult, EnrollmentsQueryResult]> => {
if (!studentNumbers) {
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
const studentnumbers =
selectedStudents ??
(await getStudentNumbersWithAllStudyRightElements({
studyRights,
startDate,
endDate,
exchangeStudents,
nondegreeStudents,
transferredOutStudents: transferredStudents,
}))
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
const allStudents = studentnumbers.length
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
const courseEnrollments = await findCourseEnrollments(
studentnumbers,
dateMonthsFromNow(startDate, months),
filteredCourseCodes
)
const studentnumbers =
studentNumbers ??
selectedStudents ??
(await getStudentNumbersWithAllStudyRightElements({
studyRights,
startDate,
endDate,
exchangeStudents,
nondegreeStudents,
transferredOutStudents: transferredStudents,
}))
return [allStudents, courses, courseEnrollments]
}
const allStudents = studentnumbers.length
const { months, startDate } = params
const beforeDate = months && startDate ? dateMonthsFromNow(startDate, months) : new Date()
const allStudents = studentNumbers.length
const courses = await findCourses(studentNumbers, beforeDate, courseCodes)
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
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 = await findCourseEnrollments(studentNumbers, beforeDate, filteredCourseCodes)
const courseEnrollments = filteredCourseCodes?.length
? !studentNumbers
? await findCourseEnrollments(studentnumbers, dateMonthsFromNow(startDate, months), filteredCourseCodes)
: await findCourseEnrollments(studentnumbers, beforeDate, filteredCourseCodes)
: []
return [allStudents, courses, courseEnrollments]
}
@@ -149,7 +144,7 @@ export const bottlenecksOf = async (query: Query, studentNumbers: string[] | nul
})
}
if ('credits' in course) {
course.credits.forEach(credit => {
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)
@@ -13,6 +13,7 @@ import {
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'
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
@@ -31,6 +32,7 @@ type AccumulatorType = {
email: string
phoneNumber: string
secondaryEmail: string
preferredLanguage: string
}
studyright: {
startDate: Date
@@ -52,6 +54,7 @@ type AccumulatorType = {
faculty: Name
attainmentDates: AttainmentDates
numberOfAbsentSemesters: number
numberOfUsedSemesters: number
curriculumPeriod: string | null
credits: {
hops: number
@@ -98,13 +101,18 @@ const findThesisAndLatestAndEarliestAttainments = (
return { attainmentDates, thesisData }
}
const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<string, Name>) => {
const formatStudent = (
student: InferAttributes<Student>,
facultyMap: Record<string, Name>,
currentSemesterCode: number
) => {
const {
studentnumber: studentNumber,
abbreviatedname: name,
sis_person_id,
email,
phone_number: phoneNumber,
preferredLanguage,
secondary_email: secondaryEmail,
} = student
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
@@ -117,10 +125,20 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
semesterEnrollments,
} = studyRight
const numberOfAbsentSemesters = (semesterEnrollments ?? []).reduce(
(acc, enrollment) => (enrollment.type === EnrollmentType.ABSENT ? acc + 1 : acc),
0
)
let numberOfAbsentSemesters = 0
let numberOfUsedSemesters = 0
if (semesterEnrollments != null) {
for (const enrollment of semesterEnrollments) {
if (enrollment.semester > currentSemesterCode) continue
if (enrollment.type === EnrollmentType.ABSENT) {
numberOfAbsentSemesters += 1
} else {
numberOfUsedSemesters += 1
}
}
}
const {
code: programmeCode,
@@ -137,7 +155,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
)
acc.push({
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
studyright: {
startDate: startOfStudyright,
semesterEnrollments,
@@ -160,6 +178,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
faculty: facultyMap[programmeCode],
attainmentDates,
numberOfAbsentSemesters,
numberOfUsedSemesters,
curriculumPeriod: getCurriculumVersion(studyPlan.curriculum_period_id),
credits: {
hops: studyPlan.completed_credits,
@@ -178,6 +197,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
'creditcount',
'email',
'phone_number',
'preferredLanguage',
'secondary_email',
'sis_person_id',
'studentnumber',
@@ -294,8 +314,10 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
facultyMap[programme.code] = programme['organization.name']
}
const { semestercode: currentSemesterCode } = await getCurrentSemester()
return students
.flatMap(student => formatStudent(student, facultyMap))
.flatMap(student => formatStudent(student, facultyMap, currentSemesterCode))
.reduce(
(acc, student) => {
if (student.programme.degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
@@ -143,7 +143,7 @@ const getStudents = async (studentNumbers: string[]) => {
model: Studyplan,
attributes: [
'included_courses',
'programme_code',
'includedModules',
'completed_credits',
'curriculum_period_id',
'sis_study_right_id',
@@ -448,24 +448,6 @@ export const formatStudentsForApi = (
}
}
const getSubstitutions = async (codes: string[]) => {
const courses = await Course.findAll({
attributes: ['code', 'substitutions'],
where: {
code: {
[Op.iLike]: { [Op.any]: codes },
},
},
raw: true,
})
const substitutions = [
...new Set(courses.flatMap(({ code, substitutions }) => [code, ...Object.values(substitutions).flat()])),
]
return substitutions
}
export type EnrollmentsQueryResult = Array<{
code: string
name: Name
@@ -482,12 +464,10 @@ export type CoursesQueryResult = Array<
}
>
// This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students
export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
const result: EnrollmentsQueryResult = await sequelize.query(
`
SELECT DISTINCT ON (course.id)
SELECT DISTINCT
course.code,
course.name,
course.substitutions,
@@ -496,24 +476,32 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
FROM course
INNER JOIN (
SELECT
course_id,
ARRAY_AGG(JSON_BUILD_OBJECT(
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
GROUP BY enrollment.course_id
) enrollment ON enrollment.course_id = course.id
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
WHERE studentnumber IN (:studentnumbers)
AND enrollment_date_time < :beforeDate
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
WHERE course.code IN (:courseCodes)
OR (
SELECT
JSONB_AGG(DISTINCT alt_code)
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
WHERE code IN (:courseCodes)
) ? course.code
`,
{
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
beforeDate,
courseCodes,
skipCourseCodeFilter: courses.length === 0,
courseCodes: courses.length ? courses : ['DUMMY'],
enrollmentState: EnrollmentState.ENROLLED,
},
type: QueryTypes.SELECT,
@@ -523,21 +511,35 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
}
export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
const result: CoursesQueryResult = await sequelize.query(
return sequelize.query(
`
SELECT DISTINCT ON (course.id)
SELECT
course.code,
course.name,
course.substitutions,
course.main_course_code,
credit.data AS credits,
enrollment.data AS enrollments
enrollment.data AS enrollments,
credit.data AS credits
FROM course
INNER JOIN (
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSON_BUILD_OBJECT(
ARRAY_AGG(JSONB_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE studentnumber IN (:studentnumbers)
AND enrollment_date_time < :beforeDate
AND state = :enrollmentState
GROUP BY course_code
) AS enrollment
ON enrollment.course_code = course.code
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'grade', grade,
'student_studentnumber', student_studentnumber,
'attainment_date', attainment_date,
@@ -545,35 +547,31 @@ export const findCourses = async (studentNumbers: string[], beforeDate: Date, co
'course_code', course_code
)) AS data
FROM credit
WHERE student_studentnumber IN (:studentnumbers) AND attainment_date < :beforeDate
GROUP BY credit.course_code
) credit ON credit.course_code = course.code
LEFT JOIN (
SELECT
course_id,
ARRAY_AGG(JSON_BUILD_OBJECT(
'studentnumber', studentnumber,
'state', state,
'enrollment_date_time', enrollment_date_time
)) AS data
FROM enrollment
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
GROUP BY enrollment.course_id
) enrollment ON enrollment.course_id = course.id
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
WHERE student_studentnumber IN (:studentnumbers)
AND attainment_date < :beforeDate
GROUP BY course_code
) AS credit
ON credit.course_code = course.code
WHERE :skipCourseCodeFilter
OR course.code IN (:courseCodes)
OR (
SELECT
JSONB_AGG(DISTINCT alt_code)
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
WHERE code IN (:courseCodes)
) ? course.code
`,
{
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
beforeDate,
courseCodes,
courseCodes: courses.length ? courses : ['DUMMY'],
skipCourseCodeFilter: courses.length === 0,
enrollmentState: EnrollmentState.ENROLLED,
},
type: QueryTypes.SELECT,
}
)
return result
) as Promise<CoursesQueryResult>
}
export const parseCreditInfo = (credit: CreditPick) => {
@@ -13,6 +13,16 @@ type SemestersAndYears = {
}
}
export const getSemesterNameByCode = async (semesterCode: number) => {
const semester = await Semester.findOne({
attributes: ['name'],
where: {
semestercode: semesterCode,
},
})
return semester!
}
export const getCurrentSemester = async () => {
const today = new Date()
const currentSemester = await Semester.findOne({
+24 -6
View File
@@ -303,7 +303,7 @@ const nameLike = (terms: string[]) => {
}
}
const studentnumberLike = (terms: string[]) => {
const studentNumberLike = (terms: string[]) => {
return {
studentnumber: {
[Op.iLike]: likefy(terms[0]),
@@ -311,8 +311,26 @@ const studentnumberLike = (terms: string[]) => {
}
}
export const bySearchTermAndStudentNumbers = async (searchterm: string, studentNumbers?: string[]) => {
const terms = splitByEmptySpace(searchterm)
const formatStudentForSearch = ({
creditcount,
dateofuniversityenrollment,
firstnames,
lastname,
studentnumber,
studyRights,
}: InferAttributes<Student>) => {
return {
activeStudyRights: studyRights || [],
credits: creditcount || 0,
firstNames: firstnames,
lastName: lastname,
studentNumber: studentnumber,
started: dateofuniversityenrollment,
}
}
export const bySearchTermAndStudentNumbers = async (searchTerm: string, studentNumbers?: string[]) => {
const terms = splitByEmptySpace(searchTerm)
return (
await Student.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'creditcount', 'dateofuniversityenrollment'],
@@ -337,16 +355,16 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
where: studentNumbers
? {
[Op.and]: {
[Op.or]: [nameLike(terms), studentnumberLike(terms)],
[Op.or]: [nameLike(terms), studentNumberLike(terms)],
studentnumber: {
[Op.in]: studentNumbers,
},
},
}
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
: { [Op.or]: [nameLike(terms), studentNumberLike(terms)] },
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
})
).map(formatSharedStudentData)
).map(formatStudentForSearch)
}
export const filterStudentnumbersByAccessrights = async (studentnumbers: string[], codes: string[]) =>
@@ -114,7 +114,30 @@ export const getTransferredCredits = async (provider: string, since: Date) =>
},
})
export const getThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
export const getStudyRightThesisCredits = async (thesisType: string[], studyRightIds: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
model: Course,
attributes: [],
where: {
course_unit_type: {
[Op.in]: thesisType,
},
},
},
where: {
credittypecode: CreditTypeCode.PASSED,
isStudyModule: {
[Op.not]: true,
},
studyright_id: {
[Op.in]: studyRightIds,
},
},
})
export const getOrganizationThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
await Credit.findAll({
attributes: ['attainment_date', 'student_studentnumber'],
include: {
@@ -7,7 +7,7 @@ import { DegreeProgrammeType, ExtentCode, Phase, SemesterEnrollment, StudyTrack
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getThesisCredits } from './creditGetters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
import {
defineYear,
@@ -101,19 +101,35 @@ const getGraduationTimeAndThesisWriterStats = async ({
thesisTableStats: tableStats,
}
}
const doCombo = await shouldIncludeComboStats(studyProgramme)
const graduationTimes: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const graduationTimesCombo: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
const { semesters } = await getSemestersAndYears()
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const thesisType = await getThesisType(studyProgramme)
const thesisCredits = await getThesisCredits(mapToProviders([studyProgramme])[0], thesisType, studentNumbers)
const thesisWriterMap = thesisCredits.reduce<Record<string, Date>>((acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
}, {})
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
const studyRightIds = studyRights.map(studyRight => studyRight.id)
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
const studyRightThesisCredits = await getStudyRightThesisCredits(thesisType, studyRightIds)
const studyRightThesisStudentNumbers = studyRightThesisCredits.map(item => item.student_studentnumber)
const organizationStudentNumbers = studentNumbers.filter(student => !studyRightThesisStudentNumbers.includes(student))
const organizationThesisCredits = await getOrganizationThesisCredits(
mapToProviders([studyProgramme])[0],
thesisType,
organizationStudentNumbers
)
const thesisWriterMap = [...studyRightThesisCredits, ...organizationThesisCredits].reduce<Record<string, Date>>(
(acc, credit) => {
acc[credit.student_studentnumber] = credit.attainment_date
return acc
},
{}
)
for (const studyRight of studyRights) {
const correctStudyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
+3 -3
View File
@@ -6,8 +6,8 @@ import { serviceProvider } from '../config'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { User } from '../models/user'
import { Role } from '../shared/types'
import { DetailedProgrammeRights, ExpandedUser, FormattedUser, IamAccess } from '../types'
import { DetailedProgrammeRights, Role } from '../shared/types'
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
@@ -34,7 +34,7 @@ const isRole = (role: string) => {
return (roles as readonly string[]).includes(role)
}
export const modifyAccess = async (username: string, rolesOfUser: Record<string, boolean>) => {
export const modifyAccess = async (username: string, rolesOfUser: Record<Role, boolean>) => {
const user = await User.findOne({ where: { username } })
if (!user) {
throw new Error(`User ${username} not found`)
-1
View File
@@ -1,7 +1,6 @@
export { CourseWithSubsId, ParsedCourse } from './course'
export { CreditTypeCode } from './creditTypeCode'
export { Criteria } from './criteria'
export { DetailedProgrammeRights } from './detailedProgrammeRights'
export { DegreeProgrammeType } from './degreeProgrammeType'
export { EnrollmentState } from './enrollmentState'
export { EnrollmentType } from './enrollmentType'
+1 -2
View File
@@ -2,8 +2,7 @@ import { InferAttributes } from 'sequelize'
import { User } from '../models/user'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
import { DetailedProgrammeRights } from './detailedProgrammeRights'
import { DetailedProgrammeRights, Role } from '../shared/types'
export type ExpandedUser = InferAttributes<User> & {
iamGroups: string[]
+1 -2
View File
@@ -1,6 +1,5 @@
import { ProgrammeModule } from '../models'
import { Role } from '../shared/types'
import { DetailedProgrammeRights } from '../types'
import { DetailedProgrammeRights, Role } from '../shared/types'
const isObjectWithKey = (obj: unknown, key: string): obj is Record<string, unknown> => {
return typeof obj === 'object' && obj !== null && key in obj
+3
View File
@@ -32,6 +32,9 @@ ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
ARG LANGUAGE_CENTER_VIEW_ENABLED
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
ARG SIS_URL
ENV REACT_APP_SIS_URL=$SIS_URL
COPY ./frontend/package* ./
RUN npm ci --include=dev
COPY ./frontend .
+456 -367
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -11,16 +11,16 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.2.0",
"@mui/material": "^6.2.0",
"@mui/x-date-pickers": "^7.23.2",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/browser": "^8.45.0",
"axios": "^0.29.0",
"@mui/icons-material": "^6.4.4",
"@mui/material": "^6.4.4",
"@mui/x-date-pickers": "^7.27.0",
"@reduxjs/toolkit": "^2.5.1",
"@sentry/browser": "^8.55.0",
"axios": "^1.7.9",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.0.3",
"material-react-table": "^3.2.0",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
@@ -28,10 +28,10 @@
"react-datetime": "^3.3.1",
"react-dom": "^18.3.1",
"react-highcharts": "^16.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^9.0.3",
"react-range": "^1.10.0",
"react-redux": "^9.2.0",
"react-router": "^7.0.2",
"react-router": "^7.1.5",
"scheduler": "^0.25.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^3.0.0-beta.2",
@@ -40,14 +40,14 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/node": "^22.10.2",
"@types/react": "^18.3.16",
"@types/lodash": "^4.17.15",
"@types/node": "^22.13.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.7.2",
"typescript": "^5.7.2",
"vite": "^6.0.3"
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.7.3",
"vite": "^6.1.0"
},
"overrides": {
"react-highcharts": {
@@ -88,5 +88,5 @@ facultyToolTips.studentsOfTheFaculty =
.replace('Transferred away', 'Transferred out of programme')
.replace('Transferred to', 'Transferred into programme')
facultyToolTips.graduatedToggle = studyProgrammeToolTips.graduatedToggle
facultyToolTips.studentToggle = studyProgrammeToolTips.studentToggle
facultyToolTips.studyRightToggle = studyProgrammeToolTips.studyRightToggle
facultyToolTips.yearToggle = studyProgrammeToolTips.yearToggle
@@ -1,45 +1,23 @@
export const filterToolTips = {
enrollmentStatus: {
label: 'Filter students present, absent or passive',
short: `
Suodata opiskelijoita sen mukaan ovatko he joko ilmoittautuneet läsnä- tai poissaoleviksi tai jääneet passiivisiksi kyseiselle lukukaudelle. Huom. osa passiivisista opiskelijoista on jo valmistunut ko. ohjelmasta.
`,
},
studyRightStatus: {
label: 'Rajaa opiskelijoita opiskeluoikeuden statuksen mukaan',
short: `
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
citizenship:
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
courseCredits: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
enrollmentStatus: `
Suodata opiskelijoita sen mukaan, ovatko he ilmoittautuneet **läsnäolevaksi** (present), **poissaolevaksi** (absent) vai **laiminlyöneet ilmoittautumisen** (passive) valituille lukukausille. Jos lukukausia on valittuna enemmän kuin yksi, opiskelijan ilmoittautumisen tulee täsmätä jokaisella valitulla lukukaudella.
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
`,
startingYear:
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
studentNumber:
'Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.',
studyRightStatus: `
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
`,
},
transferred: {
label: 'Include and exclude students from this program',
short: `
Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta
`,
},
courseCredits: {
label: null,
short: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
},
studentNumber: {
label: null,
short: `
Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.
`,
},
startingYear: {
label: null,
short: `
Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.
`,
},
citizenship: {
label: null,
short:
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
},
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
`,
transferred:
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
}
@@ -172,6 +172,11 @@ export const populationStatisticsToolTips = {
- ✓ Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla
- — Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille
- — Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille
- □ Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä
**Modules**
Tällä välilehdellä näkyy, mitä opintokokonaisuuksia opiskelija on valinnut opintosuunnitelmaansa. Näkymä ei huomioi valinnaisia opintokokonaisuuksia.
**Tags**
@@ -208,7 +213,7 @@ export const populationStatisticsToolTips = {
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
`,
programmeDistributionCustomPopulation: `
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
`,
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
@@ -3,7 +3,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
**Calendar year**: Näyttää tilastot kalenterivuosille jaoteltuna (esim. 1.1.–31.12.2024)
**Academic year**: Näyttää tilastot lukuvuosille jaoteltuna (esim. 1.8.2023–31.7.2024)
`,
studentToggle: `
studyRightToggle: `
**All study rights**: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat ja ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
**Special study rights excluded**: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
`,
@@ -0,0 +1,16 @@
export const updaterToolTips = {
updaterSection: `
**Update meta**: Updates organisations, study modules, course units, study levels, education types, credit types
**Update students**: Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
**Update curriculums**: Updates all study programmes and their curriculums. This takes a few minutes, and breaks the curriculum features for that time, so do not run in production unnecessarily.
**Refresh updater redis**: Refreshes maps (COUNTRIES, EDUCATION_TYPES, ORGANISATION_ID_TO_CODE, etc.) stored in updater redis. Check \`loadMapsOnDemand\` function in updater for more information.
`,
refreshDataSection: `
**Refresh all teacher leaderboards**: Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
**Refresh teacher leaderboards of current and previous year**: Refresh teacher leaderboard statistics for current and previous academic year.
**Refresh faculties**: Refresh data for all faculties for all tabs (time consuming).
**Refresh study programmes**: Refresh data for new study programmes for basic and studytrack tabs (time consuming).
**Refresh language center data**: Refresh data for language center view.
**Refresh close to graduation data**: Refresh data for close to graduation view.
`,
}
@@ -1,23 +1,19 @@
export const userToolTips = {
iamGroupBasedAccess: `
*Currently this description is more like how things should be, not how they are. In particular, full rights based on IAM groups are not implemented.*
The exclamation mark means that the user has **limited rights** to the study programme. The check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
Both of these icons are displayed: the relevant one is green, the irrelevant one is greyed out.
The orange exclamation mark means that the user has **limited rights** to the study programme.
The green check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
**Limited rights** are granted to the following people:
- member of the steering group (johtoryhmä) of the study programme.
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty.
- The following people get limited rights to **all** study programmes:
- member of the steering group (johtoryhmä) of the study programme
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty
- the following people get limited rights to **all** study programmes:
- heads of academic affairs (opintoasiainpäälliköt)
- employees of ospa (opetuksen strategiset palvelut)
- rector, vice-rectors and director of administration (rehtori, vararehtorit ja hallintojohtaja)
- members of the academic affairs council (opintoasiainneuvosto)
**Full rights** are granted to the following people:
- education planning officer (koulutussuunnittelija) of the corresponding faculty.
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty.
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja).
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja)
`,
}
-308
View File
@@ -1,308 +0,0 @@
import { isEqual } from 'lodash'
import qs from 'query-string'
import { useCallback, useEffect, useRef, useState } from 'react'
import { SEARCH_HISTORY_VERSION } from '@/constants'
import { useGetCurriculumPeriodsQuery } from '@/redux/curriculumPeriods'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
export const useTabChangeAnalytics = () => {
const previousTabIndex = useRef(0)
const handleTabChange = useCallback(
(_, data) => {
const { activeIndex } = data
if (previousTabIndex.current !== activeIndex) {
previousTabIndex.current = activeIndex
}
},
[previousTabIndex]
)
return { handleTabChange }
}
// Use @/hooks/tabs for MUI components instead
export const useTabs = (id, initialTab, { location, replace }) => {
const [tab, setTab] = useState(-1)
const [didMount, setDidMount] = useState(false)
const pushToUrl = newTab => {
replace({
pathname: location.pathname,
search: qs.stringify({ ...qs.parse(location.search), [id]: newTab }),
})
}
useEffect(() => {
const params = qs.parse(location.search)
const queryTab = params[id]
setTab(queryTab === undefined ? initialTab : JSON.parse(queryTab))
setDidMount(true)
}, [])
useEffect(() => {
if (tab !== undefined && didMount) pushToUrl(tab)
}, [tab])
return [
tab,
(_, { activeIndex }) => {
setTab(activeIndex)
},
]
}
export const useSearchHistory = (id, capacity = 5) => {
const [searchHistory, setSearchHistory] = useState([])
const [didMount, setDidMount] = useState(false)
const getSearchHistoryStore = () => JSON.parse(localStorage.getItem('searchHistoryStore')) || {}
const saveSearchHistoryStore = newStore => localStorage.setItem('searchHistoryStore', JSON.stringify(newStore))
const saveSearchHistory = () => {
const searchHistoryStore = getSearchHistoryStore()
searchHistoryStore[id] = searchHistory
saveSearchHistoryStore(searchHistoryStore)
}
useEffect(() => {
if (localStorage.getItem('searchHistoryVersion') !== SEARCH_HISTORY_VERSION) {
saveSearchHistoryStore({})
localStorage.setItem('searchHistoryVersion', SEARCH_HISTORY_VERSION)
}
setSearchHistory(getSearchHistoryStore()[id] || [])
setDidMount(true)
}, [])
useEffect(() => {
if (didMount) {
saveSearchHistory()
}
}, [searchHistory])
const addItem = item => {
const filteredSearchHistory = searchHistory.filter(sh => sh.text !== item.text)
setSearchHistory(
filteredSearchHistory.concat({ ...item, timestamp: new Date(), id: new Date().getTime() }).slice(-capacity)
)
}
const updateItem = item => {
const updatedSearchHistory = [{ ...item, timestamp: new Date() }].concat(
searchHistory.filter(s => s.id !== item.id)
)
setSearchHistory(updatedSearchHistory)
}
return [searchHistory, addItem, updateItem]
}
const useInterval = (callback, delay) => {
const savedCallback = useRef()
const savedId = useRef()
const clear = () => {
if (savedId.current) {
clearInterval(savedId.current)
}
}
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
const tick = () => savedCallback.current()
clear()
if (delay !== null) {
savedId.current = setInterval(tick, delay)
}
return clear
}, [delay])
}
export const useDidMount = () => {
const [didMount, setDidMount] = useState(false)
useEffect(() => {
setDidMount(true)
}, [])
return didMount
}
export const useProgress = loading => {
const didMount = useDidMount()
const [progress, setProgress] = useState(100)
const [delay, setDelay] = useState(null)
const amountToProgress = delay ? Math.ceil(Math.random() * 4) : 0
useInterval(() => {
setProgress(progress + amountToProgress > 50 ? 50 : progress + amountToProgress)
}, delay)
useEffect(() => {
if (delay && progress >= 50) {
setDelay(null)
}
}, [progress])
const restartProgress = () => {
setProgress(0)
setDelay(500)
}
const finishProgress = () => {
setDelay(null)
setTimeout(() => setProgress(100), 0)
}
useEffect(() => {
if (loading) restartProgress()
else if (didMount) finishProgress()
}, [loading])
const onProgress = progress => {
if (progress > 0) {
setProgress(50 + Math.floor(progress / 2))
}
}
return {
progress,
onProgress,
}
}
export const useTitle = title => {
useEffect(() => {
document.title = title ? `${title} - Oodikone` : 'Oodikone'
return () => {
document.title = 'Oodikone'
}
}, [title])
}
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = useCallback(
value => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.log(error) // eslint-disable-line no-console
}
},
[key]
)
return [storedValue, setValue]
}
export const useDebounce = (value, timeout, onChange) => {
const [innerValue, setInnerValue] = useState(value)
const [dirty, setDirty] = useState(false)
const timeoutRef = useRef(null)
useEffect(() => {
if (!isEqual(value, innerValue)) {
setInnerValue(value)
setDirty(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [value])
const setValue = useCallback(
value => {
setInnerValue(value)
setDirty(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
timeoutRef.current = setTimeout(() => {
onChange(value)
setDirty(false)
}, timeout)
},
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
)
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onChange(innerValue)
setDirty(false)
}, [timeoutRef, onChange, innerValue])
return [innerValue, setValue, flush, dirty]
}
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(value => !value)
}, [])
return [value, toggle]
}
/**
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
*/
export const useDeepMemo = (factory, dependencies) => {
const ref = useRef()
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
ref.current = { deps: dependencies, result: factory() }
}
return ref.current.result
}
export const useCurrentSemester = () => {
const { data: semesterData } = useGetSemestersQuery()
if (!semesterData) return null
return Object.values(semesterData.semesters).find(
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
)
}
export const useCurrentCurriculumPeriod = () => {
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
return curriculumPeriods.find(
curriculumPeriod =>
new Date(curriculumPeriod.startDate) <= new Date() && new Date(curriculumPeriod.endDate) >= new Date()
)
}
export const useDegreeProgrammeTypes = programmeCodes => {
const { data: degreeProgrammes } = useGetProgrammesQuery()
if (!degreeProgrammes) return {}
return programmeCodes.reduce((acc, programmeCode) => {
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
return acc
}, {})
}
+2 -56
View File
@@ -1,4 +1,4 @@
import { filter, intersection, maxBy, orderBy, range } from 'lodash'
import { filter, maxBy, orderBy, range } from 'lodash'
import moment from 'moment'
import irtomikko from '@/assets/irtomikko.png'
@@ -17,30 +17,6 @@ export const images = {
irtomikko,
}
export const checkUserAccess = (requiredRoles, roles) => {
return intersection(requiredRoles, roles).length > 0
}
export const getGraduationGraphTitle = (studyProgramme, doCombo = false) => {
if (!studyProgramme) return ''
if (['MH30_001', 'MH30_003'].includes(studyProgramme)) return 'Licenciate study right'
if (doCombo && studyProgramme === 'MH90_001') return 'Bachelor + licentiate study right'
if (doCombo && studyProgramme.includes('MH')) return 'Bachelor + master study right'
if (studyProgramme.includes('KH')) return 'Bachelor study right'
if (studyProgramme.includes('MH')) return 'Master study right'
return 'Doctoral study right'
}
export const getUnifiedProgrammeName = (bachelor, masterLicentiate, language) => {
if (language === 'fi')
return `${bachelor} ja ${
masterLicentiate?.includes('lisensiaatin') ? 'lisensiaatin koulutusohjelma' : 'maisterin koulutusohjelma'
}`
if (language === 'en') return `${bachelor?.split(' ')[0]} and ${masterLicentiate}`
if (language === 'sv') return `${bachelor?.split('programmet')[0]}- och ${masterLicentiate}`
return bachelor
}
export const isFall = semester => semester % 2 === 1
export const getStudentTotalCredits = (student, includeTransferredCredits = true) => {
@@ -288,7 +264,7 @@ export const getCreditCategories = (
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
}
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
@@ -299,33 +275,6 @@ export const getCurrentSemester = allSemesters => {
)
}
/**
* Returns a sorting function that can be used to sort strings so that Finnish alphabetical order is respected.
*
* @param {string} field - The field to sort by (optional: if not given, the function will sort by the strings themselves)
*/
export const createLocaleComparator = (field = null) => {
if (!field) {
return (val1, val2) => val1.localeCompare(val2, 'fi', { sensitivity: 'accent' })
}
return (val1, val2) => val1[field]?.localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
}
export const createPinnedFirstComparator = pinnedProgrammes => {
const localeComparator = createLocaleComparator('code')
return (programmeA, programmeB) => {
const pinnedA = pinnedProgrammes.includes(programmeA.code)
const pinnedB = pinnedProgrammes.includes(programmeB.code)
if (pinnedA && !pinnedB) {
return -1
}
if (!pinnedA && pinnedB) {
return 1
}
return localeComparator(programmeA, programmeB)
}
}
const interpolateColor = (color1, color2, factor) =>
color1.map((component, index) => Math.round(component + factor * (color2[index] - component)))
@@ -352,9 +301,6 @@ export const generateGradientColors = steps => {
return gradientColors
}
export const getFullStudyProgrammeRights = programmeRights =>
programmeRights ? programmeRights.filter(({ limited }) => !limited).map(({ code }) => code) : []
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
/**
+22 -8
View File
@@ -8,9 +8,12 @@ import moment from 'moment'
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
import { LanguageProvider } from '@/components/LanguagePicker/useLanguage'
import { AccessDenied } from '@/components/material/AccessDenied'
import { Footer } from '@/components/material/Footer'
import { NavigationBar } from '@/components/material/NavigationBar'
import { StatusNotification } from '@/components/material/StatusNotification'
import { StatusNotificationProvider } from '@/components/material/StatusNotificationContext'
import { Routes } from '@/components/Routes'
import { SegmentDimmer } from '@/components/SegmentDimmer'
import { isProduction } from '@/conf'
@@ -19,11 +22,17 @@ import { theme } from '@/theme'
// TODO: What other options should be set globally?
HighCharts.setOptions({
credits: {
enabled: false,
},
plotOptions: {
series: {
animation: false,
},
},
title: {
text: '',
},
})
moment.updateLocale('en', {
@@ -47,14 +56,19 @@ const Layout = ({ children }) => (
minHeight: '100vh',
}}
>
<LocalizationProvider dateAdapter={AdapterMoment}>
<ThemeProvider theme={theme}>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<Footer />
</ThemeProvider>
</LocalizationProvider>
<LanguageProvider>
<LocalizationProvider dateAdapter={AdapterMoment}>
<ThemeProvider theme={theme}>
<StatusNotificationProvider>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<StatusNotification />
<Footer />
</StatusNotificationProvider>
</ThemeProvider>
</LocalizationProvider>
</LanguageProvider>
</div>
)
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Divider, Loader, Tab } from 'semantic-ui-react'
import { getCurrentSemester } from '@/common'
import { useTitle } from '@/common/hooks'
import { useTitle } from '@/hooks/title'
import { useGetSemestersQuery } from '@/redux/semesters'
import { ColorizedCoursesTableContext } from './common'
import { FacultiesTab } from './FacultiesTab'
@@ -2,11 +2,11 @@ import { maxBy } from 'lodash'
import moment from 'moment'
import { Loader, Tab, Table } from 'semantic-ui-react'
import { useCurrentSemester } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist'
import { InfoBox } from '@/components/InfoBox'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router'
import { Form, Header, Input, Segment } from 'semantic-ui-react'
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '@/common'
import { useCurrentSemester, useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import {
@@ -26,6 +25,9 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -1,19 +0,0 @@
.courseNameCell {
font-weight: 600;
}
.courseNameCell > span {
font-weight: initial;
font-size: smaller;
color: grey;
}
.courseRealisationCell {
padding-left: 2em !important;
}
.foldControlCell {
cursor: pointer;
padding-right: 0 !important;
width: 1em !important;
}
@@ -1,70 +0,0 @@
import { bool, func } from 'prop-types'
import { useState } from 'react'
import { Table, Icon } from 'semantic-ui-react'
import { courseDataWithRealisationsType } from '@/constants/types'
import './foldableRow.css'
export const FoldableRow = ({ courseData, onClickFn, userHasAccessToAllStats }) => {
const [isUnfolded, setIsUnfolded] = useState(true)
const { id, category, realisations } = courseData
const hasRealisations = realisations.length && realisations.length > 0
const showCourseRealisations = hasRealisations && isUnfolded
const getCell = (content, obfuscated) => (
<Table.Cell content={obfuscated ? '5 or fewer students' : content} style={{ color: obfuscated && 'gray' }} />
)
const getRow = (rowId, rowData, isMainRow = true) => {
const { passed, failed, passrate, realisation, obfuscated } = rowData
const showFoldIcon = isMainRow && hasRealisations
return (
<Table.Row className={!isMainRow ? 'subRow' : ''} key={rowId}>
<Table.Cell
className={showFoldIcon ? 'foldControlCell' : ''}
onClick={() => isMainRow && setIsUnfolded(isUnfolded => !isUnfolded)}
>
{showFoldIcon ? <Icon name={`angle ${isUnfolded ? 'down' : 'right'}`} /> : null}
</Table.Cell>
<Table.Cell
className={isMainRow ? 'courseNameCell' : 'courseRealisationCell'}
content={
isMainRow ? (
<>
{category}
{!userHasAccessToAllStats && <strong>*</strong>} <span>{id}</span>
</>
) : (
realisation
)
}
onClick={() => onClickFn(id)}
style={{ color: obfuscated && 'gray' }}
/>
{getCell(passed, obfuscated)}
{getCell(failed, obfuscated)}
{getCell(`${passrate || 0} %`, obfuscated)}
</Table.Row>
)
}
return (
<>
{getRow(id, courseData)}
{showCourseRealisations
? realisations.map(r => {
const { realisation } = r
const realisationId = `${id}-${realisation}`
return getRow(realisationId, r, false)
})
: null}
</>
)
}
FoldableRow.propTypes = {
courseData: courseDataWithRealisationsType.isRequired,
onClickFn: func.isRequired,
userHasAccessToAllStats: bool.isRequired,
}
@@ -1,40 +0,0 @@
import { arrayOf, bool, func, string } from 'prop-types'
import { Table } from 'semantic-ui-react'
import { courseDataWithRealisationsType } from '@/constants/types'
import { FoldableRow } from './foldableRow'
const getHeader = categoryName => {
const getHeaderCell = content => <Table.HeaderCell key={content}>{content}</Table.HeaderCell>
const headerLabels = [categoryName, 'Passed', 'Failed', 'Pass rate']
return (
<Table.Header>
<Table.Row>
<Table.HeaderCell />
{headerLabels.map(getHeaderCell)}
</Table.Row>
</Table.Header>
)
}
export const AttemptsTable = ({ categoryName, data, onClickCourse, userHasAccessToAllStats }) => (
<Table className="fixed-header" selectable style={{ cursor: 'pointer' }}>
{getHeader(categoryName)}
<Table.Body>
{data.map(course => (
<FoldableRow
courseData={course}
key={course.id}
onClickFn={onClickCourse}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
))}
</Table.Body>
</Table>
)
AttemptsTable.propTypes = {
categoryName: string.isRequired,
data: arrayOf(courseDataWithRealisationsType).isRequired,
onClickCourse: func.isRequired,
userHasAccessToAllStats: bool.isRequired,
}
@@ -1,9 +0,0 @@
.controlIcon,
.controlIcon:active {
box-shadow: none !important;
background: none !important;
}
#selected-table {
padding: 0 !important;
}
@@ -1,68 +0,0 @@
import { sortBy } from 'lodash'
import { arrayOf, bool, func, shape, string } from 'prop-types'
import { memo } from 'react'
import { Table } from 'semantic-ui-react'
import { getActiveYears } from '@/components/CourseStatistics/courseStatisticsUtils'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import './courseTable.css'
const CourseTable = ({ courses, onSelectCourse, hidden, title, controlIcon }) => {
const { getTextIn } = useLanguage()
const noContent = courses.length === 0
const sortCourses = courses => sortBy(courses, course => getTextIn(course.name))
const getEmptyListRow = () => (
<Table.Row>
<Table.Cell colSpan="3" content="No results." />
</Table.Row>
)
const toCourseRow = course => (
<Table.Row
key={course.id}
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
style={{ cursor: 'pointer' }}
>
<Table.Cell width={10}>
<div>{getTextIn(course.name)}</div>
<div>{getActiveYears(course)}</div>
</Table.Cell>
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
</Table.Row>
)
return (
!hidden && (
<Table className="fixed-header" selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{title}</Table.HeaderCell>
<Table.HeaderCell>Code</Table.HeaderCell>
{controlIcon ? <Table.HeaderCell /> : null}
</Table.Row>
</Table.Header>
<Table.Body>{noContent ? getEmptyListRow() : sortCourses(courses).map(toCourseRow)}</Table.Body>
</Table>
)
)
}
CourseTable.propTypes = {
courses: arrayOf(shape({ code: string, name: shape({}), seleted: bool })).isRequired,
onSelectCourse: func.isRequired,
hidden: bool.isRequired,
title: string.isRequired,
controlIcon: string,
}
const areEqual = (prevProps, nextProps) => {
if (prevProps.courses.length !== nextProps.courses.length) {
return false
}
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
}
export const MemoizedCourseTable = memo(CourseTable, areEqual)
@@ -1,118 +0,0 @@
import { flatten, uniq } from 'lodash'
import { number, string } from 'prop-types'
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { Dropdown, Form, Header, Table } from 'semantic-ui-react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
const CourseTableRow = ({ facultyCode, students, credits, facultyName }) => (
<Table.Row>
<Table.Cell width={13}>
{facultyCode}, {facultyName}
</Table.Cell>
<Table.Cell width={2}>{students}</Table.Cell>
<Table.Cell width={1}>{credits}</Table.Cell>
</Table.Row>
)
const CourseTable = ({ course, courseInstance }) => {
const { getTextIn } = useLanguage()
const rows = courseInstance ? (
[
...Object.entries(courseInstance.faculties)
.sort(([facultyCodeA], [facultyCodeB]) => facultyCodeA.localeCompare(facultyCodeB))
.map(([facultyCode, instanceFaculty]) => (
<CourseTableRow
credits={instanceFaculty.credits}
facultyCode={facultyCode}
facultyName={getTextIn(instanceFaculty.name)}
key={`${course.coursecode}-${facultyCode}`}
students={instanceFaculty.students.length}
/>
)),
<Table.Row key={`${course.coursecode}-total`} style={{ backgroundColor: '#f9fafb', fontWeight: 'bold' }}>
<Table.Cell width={13}>Total</Table.Cell>
<Table.Cell width={2}>{courseInstance.allStudents.length}</Table.Cell>
<Table.Cell width={1}>{courseInstance.allCredits}</Table.Cell>
</Table.Row>,
]
) : (
<Table.Row>
<Table.Cell width={13}>No data for selected year</Table.Cell>
</Table.Row>
)
return (
<>
<Header>
{getTextIn(course.name)} ({course.coursecode})
</Header>
<Table compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell width={13}>Faculty</Table.HeaderCell>
<Table.HeaderCell width={2}>Students</Table.HeaderCell>
<Table.HeaderCell width={1}>Credits</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{rows}</Table.Body>
</Table>
</>
)
}
export const FacultyLevelStatistics = () => {
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const courseStats = useSelector(state => state.courseStats.data)
const yearCodes = uniq(
flatten(Object.values(courseStats).map(course => Object.keys(course[openOrRegular].facultyStats)))
)
.sort()
.reverse()
const [selectedYear, setSelectedYear] = useState(yearCodes[0])
const dropdownOptions = yearCodes.map(yearCode => ({
key: yearCode,
text: `${1949 + Number(yearCode)}-${1950 + Number(yearCode)}`,
value: yearCode,
}))
const yearsCourseStats = Object.values(courseStats)
.map(course => ({
course,
courseInstance: course[openOrRegular].facultyStats[selectedYear],
}))
.sort((a, b) => (a.courseInstance == null) - (b.courseInstance == null))
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
<CourseTable course={course[openOrRegular]} courseInstance={courseInstance} key={course.unifyStats.coursecode} />
))
return (
<>
<Form>
<Form.Field>
<label>Select academic year</label>
<Dropdown
defaultValue={selectedYear}
onChange={(_, { value }) => setSelectedYear(value)}
options={dropdownOptions}
selection
/>
</Form.Field>
</Form>
{courseTables}
</>
)
}
CourseTableRow.propTypes = {
facultyCode: string.isRequired,
students: number.isRequired,
credits: number.isRequired,
facultyName: string.isRequired,
}
@@ -1,27 +0,0 @@
import { Label, Popup } from 'semantic-ui-react'
interface DropdownItemProps {
code: string
description: string
name: string
size: number
}
export const DropdownItem = ({ code, description, name, size }: DropdownItemProps) => (
<Popup
content={description}
disabled={!['ALL', 'OTHER', 'EXCLUDED'].includes(code)}
size="small"
trigger={
<div style={{ display: 'flex' }}>
<div style={{ flexGrow: 1 }}>
<Label content={size} icon="user" size="tiny" />
<span style={{ paddingLeft: '1rem' }}>{name}</span>
</div>
<div style={{ flexGrow: 0 }}>
<Label content={code} size="tiny" />
</div>
</div>
}
/>
)
@@ -1,49 +0,0 @@
import { orderBy } from 'lodash'
import { Form } from 'semantic-ui-react'
import { DropdownItem } from './DropdownItem'
type Option = {
description: string
key: string
name: string
size: number
students: string[]
text: string
value: string
}
interface ProgrammeDropdownProps {
label: string
name: string
onChange: () => void
options: Option[]
placeholder?: string
value: string[]
}
export const ProgrammeDropdown = ({ label, name, onChange, options, placeholder, value }: ProgrammeDropdownProps) => {
return (
<Form.Dropdown
fluid
label={label}
multiple
name={name}
onChange={onChange}
options={orderBy(options, ['size'], ['desc']).map(option => ({
key: option.key,
content: (
<DropdownItem code={option.key} description={option.description} name={option.text} size={option.size} />
),
text: option.text,
value: option.value,
}))}
placeholder={placeholder}
search
selectOnBlur={false}
selectOnNavigation={false}
selection
value={value}
/>
)
}
@@ -1,76 +0,0 @@
import qs from 'query-string'
import { useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router'
import { Segment, Tab } from 'semantic-ui-react'
import { useProgress, useTabs } from '@/common/hooks'
import { getCourseStats } from '@/redux/coursestats'
import { AttemptsPane } from './panes/AttemptsPane'
import { StudentsPane } from './panes/StudentsPane'
import './resultTabs.css'
export const ResultTabs = ({ primary, comparison, separate, availableStats }) => {
const navigate = useNavigate()
const location = useLocation()
const replace = useCallback(options => navigate(options, { replace: true }), [navigate])
const [tab, setTab] = useTabs('cs_tab', 0, { location, replace })
const { userHasAccessToAllStats } = primary
const courseStats = useSelector(({ courseStats }) => courseStats)
const { pending: loading } = courseStats
const { onProgress } = useProgress(loading)
const dispatch = useDispatch()
const handleTabChange = (...params) => {
setTab(...params)
}
const updateSeparate = separate => {
// Only proceed if we're still on the coursestatistics page
if (!location.pathname.includes('coursestatistics')) {
return
}
const { courseCodes, ...params } = qs.parse(location.search)
const query = {
...params,
courseCodes: JSON.parse(courseCodes),
separate,
}
dispatch(getCourseStats(query, onProgress))
const queryToString = { ...query, courseCodes: JSON.stringify(query.courseCodes) }
navigate({ search: qs.stringify(queryToString) }, { replace: true })
}
const paneTypes = [
{
label: 'Students',
icon: 'user',
component: StudentsPane,
},
{
label: 'Attempts',
icon: 'redo',
component: AttemptsPane,
},
]
const panes = paneTypes.map(({ icon, label, component: Component }) => ({
menuItem: { icon, content: label, key: label },
render: () => (
<Component
availableStats={availableStats}
datasets={[primary, comparison]}
separate={separate}
updateQuery={updateSeparate}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
}))
return (
<Segment loading={loading}>
<Tab activeIndex={tab} id="CourseStatPanes" onTabChange={handleTabChange} panes={panes} />
</Segment>
)
}
@@ -1,15 +0,0 @@
import { Pane } from './Pane'
import { AttemptsTable } from './tables/AttemptsTable'
export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
return (
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'ATTEMPTS', separate, splitDirection: 'row' }}
tableComponent={AttemptsTable}
updateQuery={updateQuery}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
)
}
@@ -1,179 +0,0 @@
import { useState } from 'react'
import { Segment, Tab } from 'semantic-ui-react'
import { GradeDistributionChart } from './charts/GradeDistributionChart'
import { PassRateChart } from './charts/PassRateChart'
import { ChartSettings } from './settings/ChartSettings'
import { TableSettings } from './settings/TableSettings'
const TableSection = ({
availableStats,
datasets,
settings,
setSettings,
setSplitDirection,
tableComponent: Table,
toggleSeparate,
styleContainer,
styleData,
userHasAccessToAllStats,
}) => {
return (
<>
<div style={{ marginBottom: '1em' }}>
<TableSettings
availableStats={availableStats}
datasets={datasets}
onChange={setSettings}
onSeparateChange={toggleSeparate}
setSplitDirection={setSplitDirection}
value={settings}
/>
</div>
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<Table data={data} settings={settings} userHasAccessToAllStats={userHasAccessToAllStats} />
</div>
))}
</div>
</>
)
}
const PassRateChartSection = ({
datasets,
isRelative,
setIsRelative,
styleContainer,
styleData,
userHasAccessToAllStats,
viewMode,
}) => {
return (
<>
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="PassRate" viewMode={viewMode} />
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<PassRateChart
data={data}
isRelative={isRelative}
userHasAccessToAllStats={userHasAccessToAllStats}
viewMode={viewMode}
/>
</div>
))}
</div>
</>
)
}
const GradeDistributionChartSection = ({
datasets,
isRelative,
setIsRelative,
styleContainer,
styleData,
userHasAccessToAllStats,
}) => {
return (
<>
<ChartSettings isRelative={isRelative} setIsRelative={setIsRelative} tab="GradeDistribution" />
<div style={styleContainer}>
{datasets
.filter(data => data)
.map(data => (
<div key={data.name} style={styleData}>
<GradeDistributionChart
data={data}
isRelative={isRelative}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
</div>
))}
</div>
</>
)
}
/**
* A generalized pane component that is used on the Students and the Attempts tabs.
* Contains settings and table for the corresponding tab and the pass rate chart.
*
* @param tableComponent - Should be either StudentsTable or AttemptsTable
*/
export const Pane = ({
availableStats,
datasets,
initialSettings,
tableComponent,
updateQuery,
userHasAccessToAllStats,
}) => {
const [settings, setSettings] = useState(initialSettings)
const setSplitDirection = splitDirection => {
setSettings({ ...settings, splitDirection })
}
const toggleSeparate = separate => {
setSettings({ ...settings, separate })
updateQuery(separate)
}
const halfWidth = datasets.filter(dataset => dataset).length > 1 && settings.splitDirection === 'row'
const styleContainer = {
display: 'flex',
flexDirection: settings.splitDirection,
justifyContent: 'space-between',
}
const styleData = {
flexGrow: 1,
flexBasis: 1,
marginBottom: halfWidth ? '0px' : '20px',
maxWidth: halfWidth ? '49%' : '100%',
}
return (
<Tab.Pane>
<Segment basic>
<TableSection
availableStats={availableStats}
datasets={datasets}
setSettings={setSettings}
setSplitDirection={setSplitDirection}
settings={settings}
styleContainer={styleContainer}
styleData={styleData}
tableComponent={tableComponent}
toggleSeparate={toggleSeparate}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
{settings.showGrades ? (
<GradeDistributionChartSection
datasets={datasets}
isRelative={settings.isRelative}
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
styleContainer={styleContainer}
styleData={styleData}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
) : (
<PassRateChartSection
datasets={datasets}
isRelative={settings.isRelative}
setIsRelative={isRelative => setSettings({ ...settings, isRelative })}
styleContainer={styleContainer}
styleData={styleData}
userHasAccessToAllStats={userHasAccessToAllStats}
viewMode={settings.viewMode}
/>
)}
</Segment>
</Tab.Pane>
)
}
@@ -1,15 +0,0 @@
import { Pane } from './Pane'
import { StudentsTable } from './tables/StudentsTable'
export const StudentsPane = ({ availableStats, datasets, separate, userHasAccessToAllStats, updateQuery }) => {
return (
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'STUDENTS', separate, splitDirection: 'row' }}
tableComponent={StudentsTable}
updateQuery={updateQuery}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
)
}
@@ -1,215 +0,0 @@
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import {
absoluteToRelative,
getDataObject,
getGradeSpread,
getMaxValueOfSeries,
getSeriesType,
getThesisGradeSpread,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
const getGradeGraphOptions = (
colors: string[],
maxGradeValue: number,
isRelative: boolean,
statYears: string[],
title: string
) => ({
chart: {
type: 'column',
},
colors,
credits: {
enabled: false,
},
title: {
text: title,
},
legend: {
enabled: false,
},
xAxis: {
categories: statYears,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max: maxGradeValue,
floor: -maxGradeValue,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 2,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const calculateSumAll = (newSeries: Record<string, number[]>) => {
return Object.values(newSeries)[0].map((_, index) =>
Object.values(newSeries)
.map(series => series[index])
.reduce((a, b) => a + b, 0)
)
}
const getGradeSeries = (series: Array<Record<string, number>>) => {
const seriesType = getSeriesType(series)
const newSeries =
seriesType === 'thesis'
? (getThesisGradeSpread(series) as Record<string, number[]>)
: (getGradeSpread(series) as Record<string, number[]>)
const sumAll = calculateSumAll(newSeries)
if (seriesType === 'thesis') {
return {
absolute: [
getDataObject('I', newSeries.I, 'a'),
getDataObject('A', newSeries.A, 'b'),
getDataObject('NSLA', newSeries.NSLA, 'c'),
getDataObject('LUB', newSeries.LUB, 'd'),
getDataObject('CL', newSeries.CL, 'e'),
getDataObject('MCLA', newSeries.MCLA, 'f'),
getDataObject('ECLA', newSeries.ECLA, 'g'),
getDataObject('L', newSeries.L, 'h'),
],
relative: [
getDataObject('I', newSeries.I.map(absoluteToRelative(sumAll)), 'a'),
getDataObject('A', newSeries.A.map(absoluteToRelative(sumAll)), 'b'),
getDataObject('NSLA', newSeries.NSLA.map(absoluteToRelative(sumAll)), 'c'),
getDataObject('LUB', newSeries.LUB.map(absoluteToRelative(sumAll)), 'd'),
getDataObject('CL', newSeries.CL.map(absoluteToRelative(sumAll)), 'e'),
getDataObject('MCLA', newSeries.MCLA.map(absoluteToRelative(sumAll)), 'f'),
getDataObject('ECLA', newSeries.ECLA.map(absoluteToRelative(sumAll)), 'g'),
getDataObject('L', newSeries.L.map(absoluteToRelative(sumAll)), 'h'),
],
}
}
if (seriesType === 'second-national-language') {
return {
absolute: [
getDataObject('0', newSeries[0], 'a'),
getDataObject('TT', newSeries.TT, 'b'),
getDataObject('HT', newSeries.HT, 'c'),
getDataObject('Hyv.', newSeries['Hyv.'], 'd'),
],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'b'),
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'c'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'd'),
],
}
}
if (seriesType === 'pass-fail') {
return {
absolute: [getDataObject('0', newSeries[0], 'a'), getDataObject('Hyv.', newSeries['Hyv.'], 'b')],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'b'),
],
}
}
return {
absolute: [
getDataObject('0', newSeries[0], 'a'),
getDataObject('1', newSeries[1], 'b'),
getDataObject('2', newSeries[2], 'c'),
getDataObject('3', newSeries[3], 'd'),
getDataObject('4', newSeries[4], 'e'),
getDataObject('5', newSeries[5], 'f'),
getDataObject('HT', newSeries.HT, 'g'),
getDataObject('TT', newSeries.TT, 'h'),
getDataObject('Hyv.', newSeries['Hyv.'], 'i'),
],
relative: [
getDataObject('0', newSeries[0].map(absoluteToRelative(sumAll)), 'a'),
getDataObject('1', newSeries[1].map(absoluteToRelative(sumAll)), 'b'),
getDataObject('2', newSeries[2].map(absoluteToRelative(sumAll)), 'c'),
getDataObject('3', newSeries[3].map(absoluteToRelative(sumAll)), 'd'),
getDataObject('4', newSeries[4].map(absoluteToRelative(sumAll)), 'e'),
getDataObject('5', newSeries[5].map(absoluteToRelative(sumAll)), 'f'),
getDataObject('HT', newSeries.HT.map(absoluteToRelative(sumAll)), 'g'),
getDataObject('TT', newSeries.TT.map(absoluteToRelative(sumAll)), 'h'),
getDataObject('Hyv.', newSeries['Hyv.'].map(absoluteToRelative(sumAll)), 'i'),
],
}
}
const getGrades = students => {
const grades = { ...students.grades }
const enrolledWithNoGrade = students.enrolledStudentsWithNoGrade || 0
grades[0] = (grades[0] || 0) + enrolledWithNoGrade
return grades
}
export const GradeDistributionChart = ({ data, isRelative, userHasAccessToAllStats }) => {
const stats = data.stats.filter(stat => stat.name !== 'Total' || isRelative)
const statYears = stats.map(year => year.name)
const grades = stats.map(year => getGrades(year.students))
const gradeGraphSeries = getGradeSeries(grades)
const seriesType = getSeriesType(grades)
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(gradeGraphSeries.absolute)
const title = `Grades for group ${data.name}`
const colors = {
other: [
color.red,
chartColor.blue,
chartColor.blue,
chartColor.blue,
chartColor.blue,
chartColor.blue,
color.green,
color.green,
color.green,
],
'pass-fail': [color.red, color.green],
'second-national-language': [color.red, color.green, color.green, color.green],
thesis: [chartColor.blue],
}
const primaryDistributionOptions = getGradeGraphOptions(
colors[seriesType],
maxGradeValue,
isRelative,
statYears,
title
)
return (
<div>
<ReactHighcharts
config={{
...primaryDistributionOptions,
series: isRelative ? gradeGraphSeries.relative : gradeGraphSeries.absolute,
}}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
)}
</div>
)
}
@@ -1,182 +0,0 @@
import accessibility from 'highcharts/modules/accessibility'
import exportData from 'highcharts/modules/export-data'
import exporting from 'highcharts/modules/exporting'
import ReactHighcharts from 'react-highcharts'
import {
absoluteToRelative,
getDataObject,
getMaxValueOfSeries,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
import { chartColor, color } from '@/styles/colors'
exporting(ReactHighcharts.Highcharts)
exportData(ReactHighcharts.Highcharts)
accessibility(ReactHighcharts.Highcharts)
const colorsRelative = [color.green, color.red, chartColor.redLight]
const colors = [chartColor.blue, ...colorsRelative]
const passRateAttemptGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
chart: {
type: 'column',
},
colors: isRelative ? colorsRelative : colors,
credits: {
enabled: false,
},
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 3,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const passRateStudentGraphOptions = (isRelative: boolean, categories: string[], max: number, title: string) => ({
chart: {
type: 'column',
},
colors: isRelative ? colorsRelative : colors,
credits: {
enabled: false,
},
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: isRelative ? 'Share of students' : 'Number of students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal' as const,
borderRadius: 1,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
const getPassRateAttemptSeriesFromStats = stats => {
const all: number[] = []
const passed: number[] = []
const failed: number[] = []
const enrolledNoGrade: number[] = []
stats.forEach(year => {
const { passed: p, failed: f } = year.attempts.categories
const { totalEnrollments } = year.attempts
const enrolledWithNoGrade = totalEnrollments > 0 ? totalEnrollments - p - f : 0
all.push(totalEnrollments || p + f)
passed.push(p)
failed.push(f)
enrolledNoGrade.push(enrolledWithNoGrade)
})
return {
absolute: [
getDataObject('all', all, 'a'),
getDataObject('passed', passed, 'b'),
getDataObject('failed', failed, 'c'),
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
],
relative: [
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
],
}
}
const getPassRateStudentSeriesFromStats = stats => {
const all: number[] = []
const passed: number[] = []
const failed: number[] = []
const enrolledNoGrade: number[] = []
stats.forEach(year => {
all.push(year.students.total || 0)
passed.push(year.students.totalPassed || 0)
failed.push(year.students.totalFailed || 0)
enrolledNoGrade.push(year.students.enrolledStudentsWithNoGrade || 0)
})
return {
absolute: [
getDataObject('all', all, 'a'),
getDataObject('passed', passed, 'b'),
getDataObject('failed', failed, 'c'),
getDataObject('enrolled, no grade', enrolledNoGrade, 'c'),
],
relative: [
getDataObject('passed', passed.map(absoluteToRelative(all)), 'a'),
getDataObject('failed', failed.map(absoluteToRelative(all)), 'a'),
getDataObject('enrolled, no grade', enrolledNoGrade.map(absoluteToRelative(all)), 'a'),
],
}
}
interface PassRateChartProps {
data: any
isRelative: boolean
userHasAccessToAllStats: boolean
viewMode: 'ATTEMPTS' | 'STUDENTS'
}
export const PassRateChart = ({ data, isRelative, userHasAccessToAllStats, viewMode }: PassRateChartProps) => {
const stats = data.stats.filter(stat => stat.name !== 'Total')
const statYears = stats.map(year => year.name)
const isAttemptsMode = viewMode === 'ATTEMPTS'
const passRateGraphSeries = isAttemptsMode
? getPassRateAttemptSeriesFromStats(stats)
: getPassRateStudentSeriesFromStats(stats)
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(passRateGraphSeries.absolute)
const graphOptions = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudentGraphOptions
const primaryGraphOptions = graphOptions(isRelative, statYears, maxPassRateVal, `Pass rate for group ${data.name}`)
return (
<div>
<ReactHighcharts
config={{
...primaryGraphOptions,
series: isRelative ? passRateGraphSeries.relative : passRateGraphSeries.absolute,
}}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are shown as 0 in the chart</span>
)}
</div>
)
}
@@ -1,26 +0,0 @@
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { Setting } from './common/Setting'
export const ChartSettings = ({ isRelative, setIsRelative, tab, viewMode }) => {
return (
<div style={{ alignItems: 'center', display: 'flex', marginTop: '20px' }}>
<SegmentGroup horizontal>
<Segment>
<Setting labelText="Show relative">
<Radio checked={isRelative} onChange={() => setIsRelative(!isRelative)} toggle />
</Setting>
</Segment>
</SegmentGroup>
<div style={{ marginLeft: '20px' }}>
{tab === 'GradeDistribution' ? (
<InfoBox content={courseStatisticsToolTips.gradeDistribution} popup />
) : (
<InfoBox content={courseStatisticsToolTips.passRate[viewMode]} popup />
)}
</div>
</div>
)
}
@@ -1,49 +0,0 @@
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { courseStatisticsToolTips } from '@/common/InfoToolTips'
import { InfoBox } from '@/components/InfoBox'
import { DirectionToggle } from './common/DirectionToggle'
import { ProviderOrganization } from './common/ProviderOrganization'
import { Setting } from './common/Setting'
export const TableSettings = ({ availableStats, datasets, onChange, onSeparateChange, setSplitDirection, value }) => {
const { showGrades, separate, splitDirection, viewMode } = value
const settings = [
<Setting key="gradeToggle" labelText="Show grades">
<Radio
checked={showGrades}
data-cy="gradeToggle"
onChange={() => onChange({ ...value, showGrades: !showGrades })}
toggle
/>
</Setting>,
<Setting key="separateToggle" labelText="Separate by semesters">
<Radio checked={separate} data-cy="separateToggle" onChange={() => onSeparateChange(!separate)} toggle />
</Setting>,
<Setting key="providerOrganization" labelText="Provider organization(s)">
<ProviderOrganization availableStats={availableStats} />
</Setting>,
]
if (datasets.filter(dataset => dataset).length > 1) {
settings.push(
<Setting key="splitDirection" labelText="Split direction">
<DirectionToggle setSplitDirection={setSplitDirection} splitDirection={splitDirection} />
</Setting>
)
}
return (
<div style={{ alignItems: 'center', display: 'flex' }}>
<SegmentGroup horizontal>
{settings.map(setting => (
<Segment key={setting.key}>{setting}</Segment>
))}
</SegmentGroup>
<div style={{ marginLeft: '20px' }}>
<InfoBox content={courseStatisticsToolTips.tables[viewMode]} popup />
</div>
</div>
)
}
@@ -1,14 +0,0 @@
import { Icon, Menu } from 'semantic-ui-react'
export const DirectionToggle = ({ setSplitDirection, splitDirection }) => {
return (
<Menu>
<Menu.Item active={splitDirection === 'row'} onClick={() => setSplitDirection('row')}>
<Icon name="arrows alternate horizontal" />
</Menu.Item>
<Menu.Item active={splitDirection === 'column'} onClick={() => setSplitDirection('column')}>
<Icon name="arrows alternate vertical" />
</Menu.Item>
</Menu>
)
}
@@ -1,53 +0,0 @@
import { useDispatch, useSelector } from 'react-redux'
import { Checkbox, Segment, SegmentGroup } from 'semantic-ui-react'
import { toggleOpenAndRegularCourses } from '@/redux/coursesearch'
export const ProviderOrganization = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const toggleValue = (_event, { value }) => {
const options = {
regularStats: {
regularStats: 'openStats',
openStats: 'unifyStats',
unifyStats: 'openStats',
},
openStats: {
openStats: 'regularStats',
regularStats: 'unifyStats',
unifyStats: 'regularStats',
},
}
return dispatch(toggleOpenAndRegularCourses(options[value][openOrRegular]))
}
return (
<SegmentGroup horizontal style={{ margin: 0, padding: 0 }}>
<Segment>
<Checkbox
checked={openOrRegular === 'regularStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxUniversity"
disabled={!availableStats.university}
label={availableStats.university ? 'University' : 'University (no data)'}
name="radioGroup"
onChange={toggleValue}
value="regularStats"
/>
</Segment>
<Segment>
<Checkbox
checked={openOrRegular === 'openStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxOpenUni"
disabled={!availableStats.open}
label={availableStats.open ? 'Open uni' : 'Open uni (no data)'}
name="radioGroup"
onChange={toggleValue}
value="openStats"
/>
</Segment>
</SegmentGroup>
)
}
@@ -1,17 +0,0 @@
export const Setting = ({ children, labelText }) => {
return (
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
<label style={{ marginBottom: '10px' }}>{labelText}</label>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '60px',
justifyContent: 'center',
}}
>
{children}
</div>
</div>
)
}
@@ -1,187 +0,0 @@
import { mapValues, uniq } from 'lodash'
import qs from 'query-string'
import { useSelector } from 'react-redux'
import { Link } from 'react-router'
import { Header, Icon, Item } from 'semantic-ui-react'
import {
getGradeSpread,
getSortableColumn,
getThesisGradeSpread,
isThesisGrades,
resolveGrades,
} from '@/components/CourseStatistics/ResultTabs/panes/util'
import { SortableTable, row } from '@/components/SortableTable'
import { getCourseAlternatives } from '@/selectors/courseStats'
const getTableData = (stats, useThesisGrades, isRelative) =>
stats.map(stat => {
const {
name,
code,
attempts: { grades, totalEnrollments },
coursecode,
rowObfuscated,
} = stat
const attemptsWithGrades = Object.values(grades).reduce((cur, acc) => acc + cur, 0)
const attempts = totalEnrollments || attemptsWithGrades
const gradeSpread = useThesisGrades
? getThesisGradeSpread([grades], isRelative)
: getGradeSpread([grades], isRelative)
const mapped = {
name,
code,
coursecode,
passed: stat.attempts.categories.passed,
failed: stat.attempts.categories.failed,
totalEnrollments: stat.attempts.totalEnrollments,
passRate: stat.attempts.passRate,
attempts: stat.attempts.totalAttempts || attempts,
rowObfuscated,
...mapValues(gradeSpread, x => x[0]),
}
if (mapped.name === 'Total') {
return row(mapped, { ignoreFilters: true })
}
return mapped
})
const getGradeColumns = grades => {
return grades.map(({ key, title }) =>
getSortableColumn({
key,
title,
getRowVal: s => (s.rowObfuscated ? 'NA' : s[key] || 0),
})
)
}
export const AttemptsTable = ({
data: { stats, name },
settings: { showGrades, separate, isRelative },
userHasAccessToAllStats,
headerVisible = false,
}) => {
const {
attempts: { grades },
} = stats[0]
const useThesisGrades = isThesisGrades(grades)
const alternatives = useSelector(getCourseAlternatives)
const unifyCourses = useSelector(state => state.courseSearch.openOrRegular)
const showPopulation = (yearCode, years, unifyCourses) => {
const queryObject = {
from: yearCode,
to: yearCode,
coursecodes: JSON.stringify(uniq(alternatives)),
years,
separate,
unifyCourses,
}
const searchString = qs.stringify(queryObject)
return `/coursepopulation?${searchString}`
}
const timeColumn = {
key: 'TIME-PARENT',
merge: true,
mergeHeader: true,
children: [
getSortableColumn({
key: 'TIME',
title: 'Time',
filterType: 'range',
cellProps: {},
getRowVal: s => s.code + 1949,
getRowExportVal: s => s.name,
getRowContent: s => (
<div>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
</div>
),
}),
{
key: 'TIME-ICON',
export: false,
getRowContent: s =>
s.name !== 'Total' &&
userHasAccessToAllStats && (
<Item as={Link} to={showPopulation(s.code, s.name, unifyCourses)}>
<Icon name="level up alternate" />
</Item>
),
},
],
}
let columns = [
timeColumn,
getSortableColumn({
key: 'ATTEMPTS',
title: 'Total\nattempts',
getRowVal: s => (s.rowObfuscated ? '5 or fewer students' : s.attempts),
}),
getSortableColumn({
key: 'PASSED',
title: 'Passed',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passed),
}),
getSortableColumn({
key: 'FAILED',
title: 'Failed',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.failed),
}),
getSortableColumn({
key: 'PASSRATE',
title: 'Pass rate',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.passRate),
getRowContent: s => (s.rowObfuscated ? 'NA' : `${Number(s.passRate || 0).toFixed(2)} %`),
}),
getSortableColumn({
key: 'ENROLLMENTS',
title: 'Enrollments',
helpText: 'All enrollments with enrolled state',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.totalEnrollments),
}),
]
if (showGrades) {
columns = [
timeColumn,
getSortableColumn({
key: 'ATTEMPTS',
title: 'Total\nattempts',
getRowVal: s => (s.rowObfuscated ? '5 or fewer students' : s.attempts),
}),
...getGradeColumns(resolveGrades(stats)),
]
}
const data = getTableData(stats, useThesisGrades, isRelative)
return (
<>
{headerVisible && (
<Header as="h3" textAlign="center">
{name}
</Header>
)}
<SortableTable
columns={columns}
data={data}
defaultSort={['TIME', 'desc']}
featureName="yearly_attempts"
maxHeight="40vh"
title={`Yearly attempt statistics for group ${name}`}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
)}
</>
)
}
@@ -1,213 +0,0 @@
import { cloneDeep, uniq } from 'lodash'
import qs from 'query-string'
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router'
import { Header, Icon, Item } from 'semantic-ui-react'
import { isDefaultServiceProvider } from '@/common'
import { defineCellColor, getSortableColumn, resolveGrades } from '@/components/CourseStatistics/ResultTabs/panes/util'
import { SortableTable, row } from '@/components/SortableTable'
import { getCourseAlternatives } from '@/selectors/courseStats'
const formatPercentage = passRate => (Number.isNaN(passRate) ? '–' : `${(passRate * 100).toFixed(2)} %`)
const getGradeColumns = grades => {
return grades.map(({ key, title }) =>
getSortableColumn({
key,
title,
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.grades[key] || 0),
onlyInGradeView: true,
})
)
}
const getColumns = (stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses) => {
const showPopulation = (yearcode, years) => {
const queryObject = {
from: yearcode,
to: yearcode,
coursecodes: JSON.stringify(uniq(alternatives.map(course => course.code))),
years,
separate,
unifyCourses,
}
const searchString = qs.stringify(queryObject)
return `/coursepopulation?${searchString}`
}
const toskaColumns = [
{
key: 'TIME_PARENT',
merge: true,
mergeHeader: true,
children: [
{
key: 'TIME',
title: 'Time',
filterType: 'range',
getRowVal: s => s.code + 1949,
getRowExportVal: s => s.name,
getRowContent: s => (
<div style={{ whiteSpace: 'nowrap' }}>
{s.name}
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
</div>
),
},
{
key: 'TIME_ICON',
export: false,
getRowContent: s => {
if (s.name !== 'Total' && userHasAccessToAllStats) {
return (
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
<Icon name="level up alternate" />
</Item>
)
}
return null
},
},
],
},
{
key: 'TOTAL_STUDENTS',
title: 'Total\nstudents',
helpText: 'Total count of students, including enrolled students with no grade',
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 5 : s.students.total),
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : s.students.total),
getCellProps: s => defineCellColor(s.rowObfuscated),
},
{
key: 'TOTAL_PASSED',
title: 'Passed',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalPassed || 0),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
hideWhenGradesVisible: true,
},
{
key: 'TOTAL_FAILED',
title: 'Failed',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.totalFailed || 0),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
hideWhenGradesVisible: true,
},
...getGradeColumns(resolveGrades(stats)),
{
key: 'ENROLLED_NO_GRADE',
title: 'Enrolled,\nno grade',
filterType: 'range',
helpText: 'Total count of students with a valid enrollment and no passing or failing grade',
getRowVal: s => (s.rowObfuscated ? 5 : s.students.enrolledStudentsWithNoGrade),
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : s.students.enrolledStudentsWithNoGrade),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
},
{
key: 'PASS_RATE',
title: 'Pass rate',
getRowVal: s => (s.rowObfuscated ? 0 : s.students.passRate * 100),
getRowContent: s => (s.rowObfuscated ? '5 or fewer students' : formatPercentage(s.students.passRate)),
filterType: 'range',
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
},
{
key: 'FAIL_RATE',
title: 'Fail rate',
filterType: 'range',
getRowVal: s => (s.rowObfuscated ? 'NA' : (s.students.failRate || 0) * 100),
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.failRate || 0)),
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
},
]
const fdColums = cloneDeep(toskaColumns)
const index = fdColums.findIndex(o => o.key === 'TIME_PARENT')
if (index !== -1) {
fdColums[index].children = fdColums[index].children.filter(o => o.key === 'TIME')
}
const columns = isDefaultServiceProvider() ? toskaColumns : fdColums
return columns.filter(column => {
if (showGrades && column.onlyInGradeView) return true
if (showGrades && column.hideWhenGradesVisible) return false
return !column.onlyInGradeView
})
}
export const StudentsTable = ({
data: { name, stats },
settings: { separate, showGrades },
userHasAccessToAllStats,
headerVisible = false,
}) => {
const alternatives = useSelector(getCourseAlternatives)
const unifyCourses = useSelector(state => state.courseSearch.openOrRegular)
const columns = useMemo(
() => getColumns(stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses),
[stats, showGrades, userHasAccessToAllStats, alternatives, separate, unifyCourses]
)
const data = stats.map(stats => {
if (stats.name === 'Total') {
return row(stats, { ignoreFilters: true })
}
return stats
})
return (
<div>
{headerVisible && (
<Header as="h3" textAlign="center">
{name}
</Header>
)}
<SortableTable
columns={columns}
data={data}
defaultSort={['TIME', 'desc']}
featureName="group_statistics"
maxHeight="40vh"
title={`Student statistics for group ${name}`}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
)}
</div>
)
}
@@ -1,254 +0,0 @@
import { flatten } from 'lodash'
import { calculatePercentage } from '@/common'
type Series = {
name: string
data: number[]
stack: string
type: 'column'
}
export const getDataObject = (name: string, data: number[], stack: string): Series => {
return {
name,
data,
stack,
type: 'column' as const,
}
}
export const getMaxValueOfSeries = (series: Series[]) => {
return Object.values(series).reduce((acc, cur) => {
const curMax = Math.max(...cur.data.filter(n => !Number.isNaN(n)).map(Math.abs))
return curMax >= acc ? curMax : acc
}, 0)
}
const THESIS_GRADES = ['I', 'A', 'NSLA', 'LUB', 'CL', 'MCLA', 'ECLA', 'L']
const SECOND_NATIONAL_LANGUAGE_GRADES = ['TT', 'HT']
const PASS_FAIL_GRADES = ['0', 'Hyv.']
const NUMERIC_GRADES = ['1', '2', '3', '4', '5']
export const isThesisGrades = (grades: Record<string, number>) => {
return Object.keys(grades).some(grade => THESIS_GRADES.includes(grade))
}
const isThesisSeries = (series: Array<Record<string, number>>) => {
return series?.some(record => isThesisGrades(record))
}
const isSecondNationalLanguageSeries = (series: Array<Record<string, number>>) => {
return series?.every(record => {
const grades = Object.keys(record)
const hasPassFailGrades = grades.some(grade => PASS_FAIL_GRADES.includes(grade))
const hasNumericGrades = grades.some(grade => NUMERIC_GRADES.includes(grade))
const hasSecondNationalLanguageGrades = grades.some(grade => SECOND_NATIONAL_LANGUAGE_GRADES.includes(grade))
return !hasNumericGrades && hasPassFailGrades && hasSecondNationalLanguageGrades
})
}
const isPassFailSeries = (series: Array<Record<string, number>>) => {
return series?.every(record => {
const grades = Object.keys(record)
const hasPassFailGrades = grades.some(grade => PASS_FAIL_GRADES.includes(grade))
const hasNumericGrades = grades.some(grade => NUMERIC_GRADES.includes(grade))
const hasSecondNationalLanguageGrades = grades.some(grade => SECOND_NATIONAL_LANGUAGE_GRADES.includes(grade))
return !hasNumericGrades && hasPassFailGrades && !hasSecondNationalLanguageGrades
})
}
export const getSeriesType = (series: Array<Record<string, number>>) => {
if (isThesisSeries(series)) {
return 'thesis'
}
if (isSecondNationalLanguageSeries(series)) {
return 'second-national-language'
}
if (isPassFailSeries(series)) {
return 'pass-fail'
}
return 'other'
}
export const absoluteToRelative = (all: number[]) => (p: number, i: number) => {
return parseFloat(calculatePercentage(p, all[i]).slice(0, -1))
}
const gradesOrder = {
0: 0,
1: 10,
2: 20,
3: 30,
4: 40,
5: 50,
'Hyv.': 60,
I: 0,
A: 1,
LUB: 2,
NSLA: 3,
CL: 4,
MCLA: 5,
ECLA: 6,
L: 7,
} as const
const sortGrades = (a: string, b: string) => gradesOrder[a] - gradesOrder[b]
const FAILED_GRADES = ['eisa', 'hyl.', 'hyl', '0', 'luop']
const OTHER_PASSED_GRADES = ['hyv.', 'hyv']
const getSortedGrades = (grades: string[]) => {
return grades.sort(sortGrades).map(grade => {
if (grade === '0') {
return { key: grade, title: 'Failed' }
}
if (OTHER_PASSED_GRADES.includes(grade.toLowerCase())) {
return { key: grade, title: 'Other\npassed' }
}
return { key: grade, title: grade.charAt(0).toUpperCase() + grade.slice(1) }
})
}
export const resolveGrades = stats => {
const allGrades = [
'0',
...flatten(
stats.map(({ students }) =>
[...Object.keys(students.grades)].map(grade => {
const parsedGrade = Number(grade) ? Math.round(Number(grade)).toString() : grade
if (FAILED_GRADES.includes(parsedGrade.toLowerCase())) {
return '0'
}
if (parsedGrade === 'LA') {
return 'LUB'
}
return parsedGrade
})
)
),
] as string[]
if (allGrades.filter(grade => NUMERIC_GRADES.includes(grade)).length) {
allGrades.push(...['1', '2', '3', '4', '5'])
}
const grades = [...new Set(allGrades)]
return getSortedGrades(grades)
}
export const getSortableColumn = opts => ({
filterType: 'range',
cellProps: s => ({
style: {
textAlign: 'right',
color: s.rowObfuscated ? 'gray' : 'inherit',
},
}),
...opts,
})
const accumulateGrades = (
series: Array<Record<string, number>>,
baseAccumulator: Record<string, number[]>,
getMergedKey: (key: string) => string,
failedKeys: string[] = []
) => {
return series.reduce(
(acc, cur, i) => {
const currentEntries = Object.entries(cur)
let failed = 0
currentEntries.forEach(([k, v]) => {
const mergedKey = getMergedKey(k)
if (failedKeys.includes(mergedKey.toLowerCase())) {
failed += v
} else {
acc[mergedKey].push(v)
}
})
if (failed > 0 && acc[0]) {
acc[0].push(failed)
}
Object.entries(acc).forEach(([k, v]) => {
if (v.length < i + 1) {
acc[k].push(0)
}
})
return acc
},
{ ...baseAccumulator } as Record<string, number[]>
)
}
const calculateTotal = (series: Record<string, number[]>) => {
return Object.keys(series).reduce((acc, curr) => {
const numOfGrades = series[curr][0]
return acc + numOfGrades
}, 0)
}
const calculateRelative = (series: Record<string, number[]>, total: number) => {
return Object.keys(series).reduce(
(acc, curr) => {
acc[curr] = `${Math.round((series[curr][0] / total) * 10000) / 100}%`
return acc
},
{} as Record<string, string>
)
}
export const getThesisGradeSpread = (series: Array<Record<string, number>>, isRelative?: boolean) => {
const thesisGradeAccumulator = {
L: [],
ECLA: [],
MCLA: [],
CL: [],
LUB: [],
NSLA: [],
A: [],
I: [],
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
'Hyv.': [],
} as Record<string, number[]>
const getMergedKey = (key: string) => (key === 'LA' ? 'LUB' : key)
const newSeries = accumulateGrades(series, thesisGradeAccumulator, getMergedKey)
const total = calculateTotal(newSeries)
return isRelative ? calculateRelative(newSeries, total) : newSeries
}
export const getGradeSpread = (series: Array<Record<string, number>>, isRelative?: boolean) => {
const gradeAccumulator = {
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
HT: [],
TT: [],
'Hyv.': [],
} as Record<string, number[]>
const failedKeys = ['eisa', 'hyl.', 'hyl', '0', 'luop']
const getMergedKey = (key: string) => (Number(key) ? Math.round(Number(key)).toString() : key)
const newSeries = accumulateGrades(series, gradeAccumulator, getMergedKey, failedKeys)
const total = calculateTotal(newSeries)
return isRelative ? calculateRelative(newSeries, total) : newSeries
}
export const defineCellColor = (rowObfuscated: boolean) => {
return rowObfuscated && { style: { color: 'gray' } }
}
@@ -1,30 +0,0 @@
.modeSelectorRow {
margin-top: 1em !important;
padding-bottom: 0 !important;
}
.modeSelectorContainer {
margin-left: 2em;
width: 100%;
}
.chartToggleContainer {
display: flex;
margin-bottom: 1.5em;
}
.toggleContainer {
display: flex;
align-items: center;
}
.toggleLabel {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
}
.totalsDisclaimer {
font-style: italic;
color: gray;
}
@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from 'semantic-ui-react'
import { Timeout } from '@/components/Timeout'
enum Timeouts {
FETCH = 'fetch',
SEARCH = 'search',
}
interface AutoSubmitSearchInputProps {
cypress: string
doSearch: (searchTerm: string) => void
inputValue: string
loading: boolean
onChange: (searchTerm: string) => void
setTimeout: (timeout: Timeouts, callback: () => void, latency: number) => void
placeholder: string
}
const AutoSubmitSearchInput = ({
cypress,
doSearch,
inputValue,
loading,
onChange,
setTimeout,
placeholder,
}: AutoSubmitSearchInputProps) => {
const [input, setInput] = useState(inputValue)
const latency = 250
const minSearchLength = 0
const executeSearch = (searchTerm: string) => {
clearTimeout(Timeouts.FETCH)
setTimeout(Timeouts.FETCH, () => doSearch(searchTerm), latency)
}
useEffect(() => {
if ((input || input === '') && input.length >= minSearchLength) {
executeSearch(input)
}
}, [input])
const handleSearchChange = (_event, { value }) => {
clearTimeout(Timeouts.SEARCH)
if (value.length >= 0) {
onChange(value)
setTimeout(Timeouts.SEARCH, () => setInput(value), latency)
} else {
onChange('')
}
}
return (
<Input
data-cy={cypress}
fluid
icon="search"
loading={loading}
onChange={handleSearchChange}
placeholder={placeholder}
value={inputValue}
/>
)
}
export const TimeoutAutoSubmitSearchInput = Timeout(AutoSubmitSearchInput)
@@ -1,363 +0,0 @@
import { sortBy } from 'lodash'
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useLocation } from 'react-router'
import { Form, Header, Message, Popup, Radio, Segment } from 'semantic-ui-react'
import { validateInputLength } from '@/common'
import { useSearchHistory, useToggle } from '@/common/hooks'
import { MemoizedCourseTable as CourseTable } from '@/components/CourseStatistics/CourseTable'
import { TimeoutAutoSubmitSearchInput as AutoSubmitSearchInput } from '@/components/CourseStatistics/SearchForm/AutoSubmitSearchInput'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { SearchHistory } from '@/components/SearchHistory'
import { clearCourses, findCoursesV2 } from '@/redux/coursesearch'
import { getCourseStats, clearCourseStats } from '@/redux/coursestats'
import { getCourseSearchResults } from '@/selectors/courses'
const INITIAL = {
courseName: '',
courseCode: '',
selectedCourses: {},
separate: false,
}
// For now, let's allow more courses because it's necessary and doesn't necessarily fail if the courses
// have small populations (this used to be limited to 40)
const MAX_SELECTED_COURSES = 99999
const searchBoxStyle = {
border: '2px solid black',
borderRadius: '0.3em',
padding: '12px',
boxShadow: '0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%)',
}
export const SearchForm = ({ onProgress }) => {
const { getTextIn } = useLanguage()
const dispatch = useDispatch()
const location = useLocation()
const navigate = useNavigate()
const isLoading = useSelector(state => state.courseStats.pending)
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
const [selectMultipleCoursesEnabled, toggleSelectMultipleCoursesEnabled] = useToggle(false)
const matchingCourses = useSelector(state => getCourseSearchResults(state, combineSubstitutions))
const [state, setState] = useState({ ...INITIAL })
const { courseName, courseCode, selectedCourses, separate } = state
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
const parseQueryFromUrl = () => {
const {
courseCodes,
separate,
unifyOpenUniCourses,
combineSubstitutions: combineSubstitutionsFromUrl,
...rest
} = qs.parse(location.search)
const query = {
...INITIAL,
...rest,
courseCodes: JSON.parse(courseCodes),
separate: JSON.parse(separate),
combineSubstitutions: combineSubstitutionsFromUrl ?? JSON.parse(combineSubstitutions),
}
return query
}
const fetchStatisticsFromUrlParams = () => {
const query = parseQueryFromUrl()
setState({ ...state, ...query })
dispatch(getCourseStats(query, onProgress))
}
useEffect(() => {
if (!location.search) {
dispatch(clearCourses())
dispatch(clearCourseStats())
}
}, [])
useEffect(() => {
if (location.search) {
fetchStatisticsFromUrlParams()
}
}, [location.search])
const onSelectCourse = course => {
course.selected = !course.selected
const isSelected = !!selectedCourses[course.code]
if (isSelected) {
const { [course.code]: omit, ...rest } = selectedCourses
setState({
...state,
selectedCourses: rest,
})
} else {
setState({
...state,
selectedCourses: {
...selectedCourses,
[course.code]: { ...course, selected: true },
},
})
}
}
const pushQueryToUrl = query => {
const { courseCodes, ...rest } = query
const queryObject = {
...rest,
courseCodes: JSON.stringify(courseCodes),
combineSubstitutions: JSON.stringify(combineSubstitutions),
}
const searchString = qs.stringify(queryObject)
navigate({ search: searchString })
}
const onSearchHistorySelected = historyItem => {
pushQueryToUrl(historyItem)
}
const onSubmitFormClick = () => {
const codes = sortBy(Object.keys(selectedCourses))
const params = {
courseCodes: codes,
separate,
combineSubstitutions,
}
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
addItemToSearchHistory({
text: searchHistoryText.join(', '),
params,
})
pushQueryToUrl(params)
}
const fetchCourses = () => {
const isValidName = validateInputLength(courseName, 5)
const isValidCode = validateInputLength(courseCode, 2)
if (isValidName || isValidCode) {
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
}
if (courseName.length < 5 && courseCode.length < 2) {
dispatch(clearCourses())
}
return Promise.resolve()
}
const clearSelectedCourses = () => {
setState({ ...state, selectedCourses: {} })
}
useEffect(() => {
if (Object.keys(selectedCourses).length === 0 || selectMultipleCoursesEnabled) return
onSubmitFormClick()
}, [selectedCourses])
useEffect(() => {
const fetchData = async () => {
await fetchCourses()
}
fetchData()
clearSelectedCourses()
}, [combineSubstitutions])
useEffect(() => {
if (selectMultipleCoursesEnabled) return
clearSelectedCourses()
}, [selectMultipleCoursesEnabled])
const courses = matchingCourses.filter(course => !selectedCourses[course.code])
const selected = Object.values(selectedCourses).map(course => ({ ...course, selected: true }))
const noSelectedCourses = selected.length === 0
const disabled = isLoading || noSelectedCourses || selected.length > MAX_SELECTED_COURSES
const renderFetchStatisticsButton = () => {
const FetchStatisticsButton = (
<Form.Button
basic
content="Fetch statistics"
data-cy="fetch-stats-button"
disabled={disabled}
fluid
onClick={onSubmitFormClick}
positive
primary
size="huge"
type="button"
/>
)
if (noSelectedCourses) {
return null
}
if (disabled) {
return (
<Popup
content={getTextIn({
fi: `Olet valinnut ${selected.length} kurssia. Voit valita tarkasteltavaksi enintään ${MAX_SELECTED_COURSES} kurssia kerrallaan. Tarkenna hakua tai poista valittuja kursseja.`,
en: `You have selected ${selected.length} courses. You can select up to ${MAX_SELECTED_COURSES} courses at a time. Refine your search or remove selected courses.`,
})}
on="hover"
position="top center"
trigger={<span>{FetchStatisticsButton}</span>}
/>
)
}
return FetchStatisticsButton
}
const addAllCourses = () => {
courses.forEach(course => {
course.selected = true
})
const newSelectedCourses = courses.reduce((newSelected, course) => {
newSelected[course.code] = { ...course }
return newSelected
}, {})
setState({
...state,
selectedCourses: {
...selectedCourses,
...newSelectedCourses,
},
})
}
return (
<>
<Segment loading={isLoading}>
<Form>
<Header>Search for courses</Header>
<div style={{ marginBottom: '15px' }}>
<Form.Group>
<Form.Field width={7}>
<label>Name</label>
<AutoSubmitSearchInput
cypress="course-name-input"
doSearch={fetchCourses}
inputValue={courseName}
loading={isLoading}
onChange={courseName => setState({ ...state, courseName })}
placeholder="Search by course name"
/>
</Form.Field>
<Form.Field width={4}>
<label>Code</label>
<AutoSubmitSearchInput
cypress="course-code-input"
doSearch={fetchCourses}
inputValue={courseCode}
loading={isLoading}
onChange={courseCode => setState({ ...state, courseCode })}
placeholder="Search by course code"
/>
</Form.Field>
<Form.Field>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '20px',
marginLeft: '10px',
}}
>
<Popup
content={getTextIn({
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={combineSubstitutions}
data-cy="combine-substitutions-toggle"
label="Combine substitutions"
onChange={toggleCombineSubstitutions}
toggle
/>
}
/>
<Popup
content={getTextIn({
fi: 'Jos "Select multiple courses" on valittuna, voit valita tarkasteltavaksi useita kursseja.',
en: 'If "Select multiple courses" is on, you can select multiple courses to view statistics for.',
})}
on="hover"
position="top center"
size="tiny"
trigger={
<Radio
checked={selectMultipleCoursesEnabled}
data-cy="select-multiple-courses-toggle"
label="Select multiple courses"
onChange={toggleSelectMultipleCoursesEnabled}
toggle
/>
}
/>
</div>
</Form.Field>
</Form.Group>
{selectMultipleCoursesEnabled && (
<Message>
<b style={{ color: 'red', fontSize: '14pt' }}>Notice:</b> Selecting many courses may fail if there are
too many students in total. If the feature does not work, try again with fewer courses. <br /> <br />
<b>Currently selected: {Object.keys(state.selectedCourses).length || 0}</b>
</Message>
)}
<div style={!noSelectedCourses ? searchBoxStyle : null}>
<CourseTable
controlIcon="trash alternate outline"
courses={selected}
hidden={noSelectedCourses}
onSelectCourse={onSelectCourse}
selectedTable
title="Selected courses"
/>
{renderFetchStatisticsButton()}
</div>
<CourseTable
courses={courses}
hidden={isLoading || (Object.keys(courses).length === 0 && Object.keys(selectedCourses).length > 0)}
onSelectCourse={onSelectCourse}
title="Searched courses"
/>
{courses.length && selectMultipleCoursesEnabled ? (
<div className="select-all-container">
<Form.Button
basic
color="green"
content="Select all search results"
onClick={addAllCourses}
size="large"
type="button"
/>
</div>
) : null}
</div>
</Form>
</Segment>
<SearchHistory
disabled={isLoading}
handleSearch={onSearchHistorySelected}
items={searchHistory.map(item => {
item.timestamp = new Date(item.timestamp)
return item
})}
updateItem={updateItemInSearchHistory}
/>
</>
)
}
@@ -1,39 +0,0 @@
import { DropdownProps, Form } from 'semantic-ui-react'
interface YearFilterProps {
years: Array<{ key: number; text: string; value: number }>
fromYear: number
toYear: number
handleChange: (event: React.SyntheticEvent, data: DropdownProps) => void
}
export const YearFilter = ({ years, fromYear, toYear, handleChange }: YearFilterProps) => (
<Form>
<Form.Group inline>
<Form.Dropdown
inline
label="From:"
name="fromYear"
onChange={handleChange}
options={toYear ? years.filter(({ value }) => value <= toYear) : years}
placeholder="Select academic year"
selectOnBlur={false}
selectOnNavigation={false}
selection
value={fromYear}
/>
<Form.Dropdown
inline
label="To:"
name="toYear"
onChange={handleChange}
options={fromYear ? years.filter(({ value }) => value >= fromYear) : years}
placeholder="Select academic year"
selectOnBlur={false}
selectOnNavigation={false}
selection
value={toYear}
/>
</Form.Group>
</Form>
)
@@ -1,70 +0,0 @@
import { useDispatch, useSelector } from 'react-redux'
import { Divider, Form, Header, Label, Segment } from 'semantic-ui-react'
import { SingleCourseStats } from '@/components/CourseStatistics/SingleCourseStats'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { setSelectedCourse } from '@/redux/singleCourseStats'
import { getAvailableStats, getCourses, getCourseStats } from '@/selectors/courseStats'
const CourseSelector = ({ courses, selected, setSelected }) => {
const dispatch = useDispatch()
const onCourseChange = (_, { value }) => {
setSelected(value)
dispatch(setSelectedCourse(value))
}
return (
<>
<Header as="h4">Select course</Header>
<Form>
<Form.Select data-cy="course-selector" fluid onChange={onCourseChange} options={courses} value={selected} />
</Form>
<Divider />
</>
)
}
const CourseLabel = ({ code, name, primary }) => (
<Label color={primary ? 'blue' : undefined} size="large" style={{ marginBottom: 5, marginRight: 5 }}>
{code} {name}
</Label>
)
export const SingleCourseTab = ({ selected, setSelected, userHasAccessToAllStats }) => {
const { getTextIn } = useLanguage()
const stats = useSelector(getCourseStats)
const availableStats = useSelector(getAvailableStats)
const courses = useSelector(getCourses).map(({ code, name }) => ({
key: code,
value: code,
text: `${getTextIn(name)} (${code})`,
}))
if (!stats[selected]) {
return null
}
const hasSubstitutions = stats[selected].alternatives.length > 1
return (
<div>
<Segment>
<Header as="h4">{hasSubstitutions ? 'Selected courses' : 'Selected course'}</Header>
{courses.length > 1 && <CourseSelector courses={courses} selected={selected} setSelected={setSelected} />}
{hasSubstitutions && <Header as="h5">Course</Header>}
<CourseLabel code={selected} key={selected} name={getTextIn(stats[selected].name)} primary />
{hasSubstitutions && <Header as="h5">Substitutions</Header>}
{stats[selected].alternatives
.filter(course => course.code !== selected)
.map(course => (
<CourseLabel code={course.code} key={course.code} name={getTextIn(course.name)} />
))}
</Segment>
<SingleCourseStats
availableStats={availableStats[selected]}
stats={stats[selected]}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
</div>
)
}
@@ -1,37 +0,0 @@
import { Button, Icon } from 'semantic-ui-react'
import { utils, writeFile } from 'xlsx'
import { getTimestamp } from '@/util/timeAndDate'
export const DataExport = ({ data }) => {
const jsonItems = data.reduce((arr, cur) => {
const { passed: Passed, failed: Failed, passrate: Passrate, category: Title } = cur
const years = cur.realisations.map(r => ({
Passed: r.passed,
Failed: r.failed,
Passrate: r.passrate,
Title: r.realisation,
obfuscated: r.obfuscated,
}))
const rows = [{ Title, Passed, Failed, Passrate }, ...years]
const obfuscatedRows = rows.map(row => {
if (row.obfuscated) {
const obf = '5 or fewer students'
return { Title: row.Title, Passed: obf, Failed: obf, Passrate: obf }
}
const { obfuscated, ...rest } = row
return rest
})
arr.push(...obfuscatedRows)
return arr
}, [])
const worksheet = utils.json_to_sheet(jsonItems)
const workbook = utils.book_new()
utils.book_append_sheet(workbook, worksheet)
return (
<Button onClick={() => writeFile(workbook, `oodikone_course_statistics_${getTimestamp()}.xlsx`)}>
<Icon name="save" />
Export to Excel
</Button>
)
}
@@ -1,113 +0,0 @@
import { flatten } from 'lodash'
import { useDispatch, useSelector } from 'react-redux'
import { Form, Header, Label, Segment } from 'semantic-ui-react'
import { getFullStudyProgrammeRights } from '@/common'
import { AttemptsTable } from '@/components/CourseStatistics/AttemptsTable'
import { userHasAccessToAllCourseStats } from '@/components/CourseStatistics/courseStatisticsUtils'
import { ProgrammeDropdown } from '@/components/CourseStatistics/ProgrammeDropdown'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { setValue } from '@/redux/coursesSummaryForm'
import { ALL, getAllStudyProgrammes, getQueryInfo, summaryStatistics } from '@/selectors/courseStats'
import { DataExport } from './DataExport'
// Certified JavaScript moment but basically this was crashing
// since sometimes object like {en: ..., fi: ...., sv: ....}
// was being passed to React which is not legal but then again
// it works sometimes? so doing this to make sure while fixing
// the crash the realisations that worked will keep working
const unObjectifyProperty = ({ obj, property }) => {
const suspectField = obj[property]
if (typeof suspectField === 'object' && suspectField !== null) {
if (suspectField.en) {
return { ...obj, [property]: suspectField.en }
}
throw Error(`Invalid object being tried to pass to React: ${JSON.stringify(suspectField)}`)
}
return { ...obj, [property]: suspectField }
}
export const SummaryTab = ({ onClickCourse }) => {
const { roles, programmeRights } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
const dispatch = useDispatch()
const programmes = useSelector(state => getAllStudyProgrammes(state))
const form = useSelector(({ courseSummaryForm }) => courseSummaryForm)
const statistics = useSelector(state => summaryStatistics(state, userHasAccessToAllStats))
const queryInfo = useSelector(state => getQueryInfo(state))
const { getTextIn } = useLanguage()
const handleChange = (_, { name, value }) => {
let selected = [...value].filter(v => v !== ALL.value)
if ((!form.programmes.includes(ALL.value) && value.includes(ALL.value)) || value.length === 0) {
selected = [ALL.value]
}
dispatch(setValue(name, selected))
}
const data = statistics.map(stat => {
const { coursecode, name, realisations, summary } = stat
const { passed, failed, passrate } = summary
return {
id: coursecode,
category: getTextIn(name),
passed,
failed,
passrate,
realisations: realisations.map(obj => {
return unObjectifyProperty({ obj, property: 'realisation' })
}),
}
})
const options = programmes
.map(programme => ({ ...programme, size: new Set(flatten(Object.values(programme.students))).size }))
.filter(programme => programme.size > 0)
.map(({ text, ...rest }) => ({ text: typeof text === 'string' ? text : getTextIn(text), ...rest }))
.map(programme => ({ ...programme, name: programme.text }))
return (
<div>
<Segment>
<Form>
{userHasAccessToAllStats && (
<>
<Header as="h4">Filter statistics by study programmes</Header>
<ProgrammeDropdown
label="Study programmes"
name="programmes"
onChange={handleChange}
options={options}
value={form.programmes}
/>
</>
)}
<Form.Field>
<label>Timeframe</label>
<Label.Group>
{queryInfo.timeframe.map(objBeforeUbObjectifying => {
const obj = unObjectifyProperty({ obj: objBeforeUbObjectifying, property: 'name' })
const { code, name } = obj
return <Label content={name} key={code} />
})}
</Label.Group>
</Form.Field>
</Form>
</Segment>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<DataExport data={data} />
</div>
<AttemptsTable
categoryName="Course"
data={data}
onClickCourse={onClickCourse}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
{!userHasAccessToAllStats && (
<span className="totalsDisclaimer">* Years with 5 students or fewer are NOT included in the total</span>
)}
</div>
)
}
@@ -1,39 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.yearSelectorGroup {
align-items: center !important;
}
.yearSelect {
padding-right: 0 !important;
}
.yearSelectInput input {
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.yearControl {
padding-top: 22px !important;
padding-left: 0 !important;
}
.yearControlButtonGroup {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
.yearControlButton {
font-size: 8px !important;
}
.select-all-container {
width: 100%;
display: flex;
justify-content: space-around;
}
@@ -1,161 +0,0 @@
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useLocation } from 'react-router'
import { Header, Message, Segment, Tab } from 'semantic-ui-react'
import { checkUserAccess, getFullStudyProgrammeRights } from '@/common'
import { useProgress, useTitle } from '@/common/hooks'
import { ProgressBar } from '@/components/ProgressBar'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { getCourseStats } from '@/redux/coursestats'
import { userHasAccessToAllCourseStats } from './courseStatisticsUtils'
import { FacultyLevelStatistics } from './FacultyLevelStatistics'
import { SearchForm } from './SearchForm'
import { SingleCourseTab } from './SingleCourseTab'
import { SummaryTab } from './SummaryTab'
import './courseStatistics.css'
const MENU = {
SUM: 'Summary',
COURSE: 'Course',
QUERY: 'New query',
FACULTY: 'Faculty statistics',
}
export const CourseStatistics = () => {
const navigate = useNavigate()
const location = useLocation()
const dispatch = useDispatch()
const { programmeRights, roles } = useGetAuthorizedUserQuery()
const { pending: loading, data: courseStatsData } = useSelector(({ courseStats }) => courseStats)
const courses = Object.keys(courseStatsData)
const statsIsEmpty = courses.length === 0
const singleCourseStats = courses.length === 1
const initCourseCode = courses[0] || ''
const [activeIndex, setActiveIndex] = useState(0)
const [selected, setSelected] = useState(initCourseCode)
const { onProgress, progress } = useProgress(loading)
useTitle('Course statistics')
useEffect(() => {
setSelected(initCourseCode)
}, [initCourseCode])
useEffect(() => {
const { courseCodes, ...params } = qs.parse(location.search)
if (!courseCodes) return
const query = {
...params,
courseCodes: JSON.parse(courseCodes),
}
dispatch(getCourseStats(query, onProgress))
}, [location.search])
useEffect(() => {
if (statsIsEmpty) {
setSelected(initCourseCode)
setActiveIndex(0)
}
}, [statsIsEmpty])
const switchToCourse = coursecode => {
setActiveIndex(1)
setSelected(coursecode)
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const userHasAccessToAllStats = userHasAccessToAllCourseStats(roles, fullStudyProgrammeRights)
const getPanes = () => {
let panes = [
{
menuItem: MENU.SUM,
render: () => <SummaryTab onClickCourse={switchToCourse} userHasAccessToAllStats={userHasAccessToAllStats} />,
},
{
menuItem: MENU.COURSE,
render: () => (
<SingleCourseTab
selected={selected}
setSelected={setSelected}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
]
if (userHasAccessToAllStats) {
panes = [
...panes,
{
menuItem: MENU.FACULTY,
render: () => <FacultyLevelStatistics />,
},
]
}
panes = [
...panes,
{
menuItem: {
key: 'query',
content: MENU.QUERY,
icon: 'search',
position: 'right',
onClick: () => navigate('/coursestatistics'),
},
render: () => null,
},
]
return !singleCourseStats ? panes : panes.filter(pane => pane.menuItem !== MENU.SUM)
}
const handleTabChange = (_, { activeIndex, panes }) => {
if (panes[activeIndex].menuItem.key !== 'query') {
setActiveIndex(activeIndex)
}
}
if (!checkUserAccess(['courseStatistics', 'admin', 'fullSisuAccess'], roles) && fullStudyProgrammeRights.length < 1)
return (
<div className="segmentContainer">
<Message
color="red"
error
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
/>
</div>
)
const panes = getPanes()
const getContent = () => {
if (statsIsEmpty || location.search === '') {
return <SearchForm onProgress={onProgress} />
}
return (
<Tab
activeIndex={activeIndex}
menu={{ attached: false, borderless: false }}
onTabChange={handleTabChange}
panes={panes}
/>
)
}
return (
<div className="segmentContainer">
<Header className="segmentTitle" size="large">
Course statistics
</Header>
<Segment className="contentSegment">
{getContent()}
<ProgressBar fixed progress={progress} />
</Segment>
</div>
)
}
@@ -8,9 +8,9 @@ import ReactHighstock from 'react-highcharts/ReactHighstock'
import { Button, Radio } from 'semantic-ui-react'
import { getStudyRightElementTargetDates } from '@/common'
import { useDeepMemo } from '@/common/hooks'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useDeepMemo } from '@/hooks/deepMemo'
import { reformatDate } from '@/util/timeAndDate'
import { CreditGraphTooltip } from './CreditGraphTooltip'
@@ -50,9 +50,6 @@ const createGraphOptions = ({
}
return {
credits: {
enabled: false,
},
plotOptions: {
series: {
findNearestPointBy: 'xy',
@@ -313,8 +310,8 @@ const addGraduation = (points, graduationDate, notFirst) => {
points.sort((a, b) => a.x - b.x)
}
const findGraduationsByCodes = (student, programmeCodes, showFullStudyPath) => {
if (showFullStudyPath) {
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
if (showBachelorAndMaster) {
return (
student.studyRights
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
@@ -340,12 +337,12 @@ const createStudentCreditLines = (
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showFullStudyPath
showBachelorAndMaster
) =>
students.map(student => {
const { studyrightStart } = student
let startDate = singleStudent ? selectedStartDate : studyrightStart
if (showFullStudyPath) {
if (showBachelorAndMaster) {
startDate =
student.studyRights.find(studyRight =>
studyRight.studyRightElements.some(element => element.code === programmeCodes[0])
@@ -363,7 +360,7 @@ const createStudentCreditLines = (
courses => courses.reduce(reduceCreditsToPoints, { credits: 0, points: [], singleStudent })
)(student.courses)
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showFullStudyPath) : []
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
if (points?.length > 0) {
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
@@ -414,7 +411,7 @@ export const CreditAccumulationGraphHighCharts = ({
customPopulation = false,
studyPlanFilterIsActive,
selectedStudyPlan,
showFullStudyPath,
showBachelorAndMaster,
}) => {
const chartRef = useRef()
const { getTextIn } = useLanguage()
@@ -434,7 +431,7 @@ export const CreditAccumulationGraphHighCharts = ({
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showFullStudyPath
showBachelorAndMaster
),
[
students,
@@ -445,7 +442,7 @@ export const CreditAccumulationGraphHighCharts = ({
cutStudyPlanCredits,
programmeCodes,
selectedStudyPlan,
showFullStudyPath,
showBachelorAndMaster,
]
)
@@ -2,8 +2,8 @@ import moment from 'moment'
import { useState } from 'react'
import { Message, Icon, Loader } from 'semantic-ui-react'
import { useTitle } from '@/common/hooks'
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
import { useTitle } from '@/hooks/title'
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
@@ -1,12 +1,12 @@
import moment from 'moment'
import { getNewestProgrammeOfStudentAt } from '@/common'
import { useCurrentSemester } from '@/common/hooks'
import { FilterToggleIcon } from '@/components/common/FilterToggleIcon'
import { ProgressBarWithLabel } from '@/components/common/ProgressBarWithLabel'
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
import { useFilters } from '@/components/FilterView/useFilters'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useCurrentSemester } from '@/hooks/currentSemester'
import { useGetSemestersQuery } from '@/redux/semesters'
import { SearchResultTable } from './SearchResultTable'
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { Button, Form, Modal, TextArea } from 'semantic-ui-react'
import { extractItems, textAndDescriptionSearch } from '@/common'
import { useTitle } from '@/common/hooks'
import { SearchHistory } from '@/components/SearchHistory'
import { useTitle } from '@/hooks/title'
import {
useCreateCustomPopulationSearchMutation,
useDeleteCustomPopulationSearchMutation,
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Form, Header, Input, Label, Message, Segment } from 'semantic-ui-react'
import { useProgress, useTitle } from '@/common/hooks'
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
import { PanelView } from '@/components/common/PanelView'
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
@@ -24,6 +23,8 @@ import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/Po
import { PopulationStudentsContainer as PopulationStudents } from '@/components/PopulationStudents'
import { ProgressBar } from '@/components/ProgressBar'
import { RightsNotification } from '@/components/RightsNotification'
import { useProgress } from '@/hooks/progress'
import { useTitle } from '@/hooks/title'
import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourses'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import { useGetSemestersQuery } from '@/redux/semesters'
@@ -10,7 +10,6 @@ export const FilterTray = () => {
allStudents,
filters,
withoutFilter,
filterOptions,
setFilterOptions,
resetFilter,
resetFilters,
@@ -23,7 +22,7 @@ export const FilterTray = () => {
const filterSet = filters
.sort((a, b) => (a.title ?? a.key).localeCompare(b.title ?? b.key))
.map(filter => {
const { key, title, isActive, render, info } = filter
const { key, render } = filter
const ctx = getContextByKey(key)
const props = {
@@ -36,14 +35,7 @@ export const FilterTray = () => {
return (
<div key={key}>
<FilterCard
active={isActive(filterOptions[key])}
filter={filter}
info={info}
onClear={() => resetFilter(key)}
options={ctx.options}
title={title ?? key}
>
<FilterCard filter={filter} onClear={() => resetFilter(key)} options={ctx.options}>
{render(props, ctx)}
</FilterCard>
</div>

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