Compare commits

...
540 Commits
Author SHA1 Message Date
Rajamäki Santeri J 86a3081a1c [Population statistics] Fixed return data encryption step for multi year populations 2025-03-09 13:33:23 +02:00
Santeri Rajamäki 01b39926b6 Converted "encrypt" utility to typescript and added "EncryptedData" type 2025-03-07 15:00:24 +02:00
Valtteri KantanenandGitHub 790e3d3afd Merge pull request #4743 from UniversityOfHelsinkiCS/renovate/cron-4.x
Update dependency cron to v4
2025-03-06 16:14:57 +02:00
renovate[bot]andGitHub b4fbe1b47d Update dependency cron to v4 2025-03-06 14:02:26 +00:00
Valtteri Kantanen 0d2701269a [Updater] Update hourly job to also run on Saturdays and Sundays
This will decrease the number of students to be updated on Monday
2025-03-06 14:09:38 +02:00
Valtteri Kantanen 8c6a634df0 [Updater] Set timezone to Europe/Helsinki in all Dockerfiles 2025-03-06 14:03:30 +02:00
Joonas Ahovalli a0117c0de1 [Class statistics] Make studentnumber column sticky in modules tab 2025-03-06 13:00:58 +02:00
Joonas Ahovalli 3e12a4df14 [Course statistics] Fix courses excel import not containing hops information 2025-03-06 12:03:15 +02:00
Valtteri KantanenandGitHub 5aaa343720 Merge pull request #4742 from UniversityOfHelsinkiCS/renovate/all-minor-patch
Update all non-major dependencies
2025-03-06 11:50:53 +02:00
renovate[bot]andGitHub f2f8d5d6d0 Update all non-major dependencies 2025-03-06 09:41:43 +00:00
Valtteri Kantanen ede3748c09 [SortableTable] Remove unnecessary eslint-disable comments for hooks
These will be unnecessary after the waiting dependency update is merged.
2025-03-06 11:38:21 +02:00
Valtteri KantanenandGitHub 6cc230512c Merge pull request #4744 from UniversityOfHelsinkiCS/renovate/globals-16.x
Update dependency globals to v16
2025-03-06 10:52:16 +02:00
renovate[bot]andGitHub 24e42c3b63 Update dependency globals to v16 2025-03-06 08:42:55 +00:00
Valtteri Kantanen c3e222c155 [Course statistics] Fix parsing of 'separate' query param to handle undefined values 2025-03-06 10:40:39 +02:00
Valtteri KantanenandGitHub 1fb81f4bbf Merge pull request #4753 from UniversityOfHelsinkiCS/renovate/getsentry-action-release-3.x
Update getsentry/action-release action to v3
2025-03-06 10:40:05 +02:00
Valtteri Kantanen e3e0a24b9a [Updater] Update scheduler's README to clarify update processes 2025-03-06 09:01:01 +02:00
Joonas Ahovalli 66bed7f88a [Backend] Improve class statistics load times for large populations 2025-03-04 16:09:14 +02:00
Riku Rauhala c59b0a3b58 [Study programme] Add keys to tabs to disable console warnings 2025-03-04 15:09:15 +02:00
Riku RauhalaandGitHub 37a5b17578 Merge pull request #4754 from UniversityOfHelsinkiCS/material-ui-study-programme-tags
Material UI: Study programme overview - Tags tab
2025-03-04 14:36:09 +02:00
Riku Rauhala 16d938a423 [Study programme] Update tests 2025-03-04 14:20:05 +02:00
Riku Rauhala 0132a98733 [Study programme] Update tests 2025-03-04 10:45:35 +02:00
Riku Rauhala d99f3b326f [Updater] Import tooltips from index 2025-03-03 21:34:55 +02:00
Riku Rauhala 61bc346923 [Tags] Fix one more thing 2025-03-03 21:27:19 +02:00
Riku Rauhala 358eac4de2 [Study programme] Add status messages for Tabs tag functionality 2025-03-03 21:27:18 +02:00
Riku Rauhala 863a7ac157 [Tags] Fix functionality 2025-03-03 21:27:18 +02:00
Riku Rauhala 0faef48179 [Study programme] Rewrite Tags tab with MUI 2025-03-03 21:27:18 +02:00
Riku Rauhala b29eb0c8e6 [Backend] Refactor tags routes 2025-03-03 21:27:18 +02:00
Riku Rauhala 2f8b38617b [Redux] Type tags API 2025-03-03 21:27:18 +02:00
Riku Rauhala 0489155da7 [InfoToolTips] Add study programme tags 2025-03-03 21:27:18 +02:00
Riku Rauhala 8bf1da6047 [InfoToolTips] Export updaterToolTips from index
For consistency with other exports
2025-03-03 21:27:18 +02:00
Riku Rauhala e0bb37a75a [Shared] Add types Tag and NewTag 2025-03-03 21:26:15 +02:00
Riku Rauhala 27d4962fcb [PopulationLink] Add option for a link with tags 2025-03-03 21:26:15 +02:00
Riku Rauhala 6ca346a00a [Resources] Add MUI X Date pickers link 2025-03-03 21:26:15 +02:00
Riku Rauhala d1bbd9966e [Frontend] Install MUI DatePickers 2025-03-03 21:26:15 +02:00
Riku Rauhala 25e21ecb14 [Redux] Convert tags into TypeScript 2025-03-03 21:26:15 +02:00
Riku Rauhala 72cdbcb871 [Study programme] Convert TagsTab into TypeScript 2025-03-03 21:26:15 +02:00
Valtteri Kantanen 1a9759f77f [Documentation] Update READMEs of updater 2025-03-03 16:58:20 +02:00
Joonas Ahovalli b1e65803d3 [Class statistics] Fix criteria modifying link pointing to wrong page on students->progress tab 2025-03-03 16:04:59 +02:00
Santeri Rajamäki e19697a9c0 [Study guidance groups] Fix inflated response sizes 2025-03-03 14:34:38 +02:00
renovate[bot]andGitHub a584581141 Update getsentry/action-release action to v3 2025-03-03 11:27:38 +00:00
Valtteri Kantanen b7a98483e4 [Updater] Include student in hourly updates if there are new enrollments and ignore study right primalities 2025-03-03 13:26:51 +02:00
Valtteri Kantanen 20c197f3a7 [Updater] Update job removal options in queue configuration to use age limits 2025-03-03 13:12:16 +02:00
Valtteri Kantanen d07ba795df [Updater] Log more info when a new job is started 2025-03-03 13:07:06 +02:00
Valtteri Kantanen b17b714be2 [Updater] Add missing models to tableToModel 2025-03-03 13:06:19 +02:00
Valtteri Kantanen 22a5e58fe3 [Updater] Remove unused env variables and define all env variables in the config file 2025-03-03 13:01:54 +02:00
Valtteri Kantanen 9a0fbd50bd [Updater] Remove postUpdate function and refactor updateMsgHandler to handle student updates directly 2025-03-03 12:53:47 +02:00
Valtteri Kantanen 51572ebeed Remove sis-updater-nats service from Docker Compose files 2025-03-03 12:05:40 +02:00
Valtteri Kantanen 139e253d6a [Updater] Uninstall node-nats-streaming 2025-03-03 12:04:26 +02:00
Valtteri Kantanen 0df918b47a [Updater] Remove NATS related code 2025-03-03 11:59:53 +02:00
Joonas Ahovalli 781bc5da3d [Class statistics] Only show include substitutions -toggle in courses tab under students 2025-03-01 18:50:40 +02:00
Joonas Ahovalli 67297e536c [Class statistics] Rewrite includeSubstitutionsToggle in MUI 2025-03-01 18:46:05 +02:00
Valtteri Kantanen dcdacc7187 [Updater] Remove unused dependency 2025-02-28 16:43:25 +02:00
Valtteri Kantanen 684a90c60c [Frontend] Make "Stop updating" section on Updater page clearer 2025-02-28 16:40:39 +02:00
Valtteri Kantanen 0c1a1e81cb [Backend] Return detailed response message from abortUpdate endpoint 2025-02-28 16:39:25 +02:00
Valtteri Kantanen 8c8eb34a05 [Frontend] Fix ExternalLink taking up full row by using inline-flex instead of flex 2025-02-28 16:37:39 +02:00
Valtteri Kantanen f1ebbeb0e9 [Updater] Fix error logging messages for prepurge and purge scheduling 2025-02-28 16:35:27 +02:00
Valtteri Kantanen c176552326 [Updater] Add sis_study_right_elements and sis_study_rights to purge tables 2025-02-28 16:34:21 +02:00
Valtteri Kantanen 7419cc169d [Updater] Fix sendToSlack call to await its completion 2025-02-28 16:33:51 +02:00
Valtteri Kantanen c21ffe9a74 [Updater] Implement reloading redis cache and removing waiting jobs with BullMQ 2025-02-28 15:59:53 +02:00
Valtteri Kantanen 2b15168342 [Updater] Implement updating meta and students with BullMQ 2025-02-28 15:03:22 +02:00
Valtteri Kantanen 3e296e33bd [Class statistics] Fix number of HOPS credits not showing in the Studens table 2025-02-28 13:21:41 +02:00
Valtteri Kantanen 37df5276f7 [Updater] Use REDIS_HOST env variable instead of REDIS_URI 2025-02-28 12:38:24 +02:00
Valtteri Kantanen 7a26501d72 [Updater] Use similar loggers in updater-scheduler and updater-worker 2025-02-28 10:47:00 +02:00
Valtteri Kantanen 561e41cab1 [Updater] Implement prepurge and purge logic with BullMQ 2025-02-28 10:45:10 +02:00
Valtteri Kantanen 4409f60195 Disable deprecated ESLint rule
See https://eslint.org/docs/latest/rules/no-return-await
2025-02-28 10:12:05 +02:00
Valtteri Kantanen 323aae2600 [Backend] Fix attempt number for BullMQ "started job" logs 2025-02-28 10:10:40 +02:00
Valtteri Kantanen fa28d86638 [Faculties] Initialize classSizes[level] correctly to prevent undefined property access 2025-02-27 19:24:00 +02:00
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
Valtteri Kantanen 42409d555e [Students] Add better styles to study rights table 2025-01-02 16:14:52 +02:00
Valtteri Kantanen 23b1a02d42 [Filters] Fix Programme filter showing wrong numbers
This happened in those rare cases where a student had multiple study rights for the same programme
2025-01-02 15:51:20 +02:00
Valtteri Kantanen 1be53368c6 Delete old JS version of Routes 2024-12-19 16:35:23 +02:00
Valtteri Kantanen 52e0d28a4d Remove unused static text file 2024-12-19 16:25:33 +02:00
Valtteri Kantanen 83114a26f9 [Frontend] Simplify route handling 2024-12-19 15:56:01 +02:00
Valtteri KantanenandGitHub bdc09f86e6 Merge pull request #4708 from UniversityOfHelsinkiCS/mui-completed-courses-search
Material UI: Completed courses search
2024-12-19 15:31:24 +02:00
Valtteri Kantanen 6dd9679267 [Completed courses search] Delete old components 2024-12-19 15:07:28 +02:00
Valtteri Kantanen 5fb7168d17 [Completed courses search] Update tests 2024-12-19 15:07:11 +02:00
Valtteri Kantanen 6ac4b6fb3d [Completed courses search] Rewrite with MUI 2024-12-19 15:01:17 +02:00
Valtteri Kantanen 1a8de4344c [Frontend] Rewrite SearchHistory with MUI 2024-12-19 15:00:29 +02:00
Valtteri Kantanen 1493725699 [Frontend] Rewrite RightsNotification with MUI 2024-12-19 14:57:02 +02:00
Valtteri Kantanen 6cff94e2ca [Completed courses search] Add RTK Query tag for CompletedCoursesSearchList 2024-12-19 14:37:29 +02:00
Valtteri KantanenandGitHub d51a70e259 Merge pull request #4706 from UniversityOfHelsinkiCS/frontend-build-more-max-old-space-size
[Frontend] Added max-old-space-size to package.json build
2024-12-18 13:15:26 +02:00
Valtteri Kantanen 7f84c0b9f0 Update documentation on access rights 2024-12-18 13:12:49 +02:00
Valtteri Kantanen df555ca93f Remove all remaining references to katselmusViewer role 2024-12-18 13:08:01 +02:00
Matti Räsänen b68b98b62c [Frontend] Added max-old-space-size to package.json build 2024-12-17 13:12:29 +02:00
renovate[bot]andGitHub 640dc741a4 Update all non-major dependencies 2024-12-17 08:19:50 +00:00
Valtteri Kantanen 5d02b839ee Update tests after updating students in the test db 2024-12-17 08:34:51 +02:00
Valtteri Kantanen ae60a38445 Handle cases where a student has multiple citizenships 2024-12-16 16:02:20 +02:00
Valtteri Kantanen 83d4bd26c4 Reset document title on component unmount 2024-12-16 15:56:55 +02:00
Valtteri Kantanen 481b351276 [Populations] Remove unnecessary state 2024-12-16 15:27:59 +02:00
Valtteri Kantanen 23cb8c8ee5 [Class statistics] Fix credit accumulation graph 2024-12-16 15:27:20 +02:00
Valtteri Kantanen 9bcdd81772 Update ESLint configuration 2024-12-16 15:26:58 +02:00
Valtteri KantanenandGitHub 53e443142d Merge pull request #4704 from UniversityOfHelsinkiCS/mui-students
Material UI: Students
2024-12-16 15:18:21 +02:00
Valtteri Kantanen da72107eb9 Fix tests 2024-12-16 15:02:59 +02:00
Valtteri Kantanen d2cbb89eb9 Add data-cy attribute to CurriculumPicker 2024-12-16 14:00:21 +02:00
Valtteri Kantanen 3bf0c60378 [Students] Fix tests 2024-12-16 13:58:36 +02:00
Valtteri Kantanen 59a9854ed5 Add optional cypress prop to ExternalLink 2024-12-16 13:54:58 +02:00
Valtteri Kantanen b70bc09359 [Students] Enhance student search with uninitialized state alert 2024-12-16 13:52:52 +02:00
Valtteri Kantanen 8ef82d01b1 [Students] Remove old Semantic UI components 2024-12-16 12:13:59 +02:00
Valtteri Kantanen ac683175a8 [Students] Sort students by date of university enrollment in search results 2024-12-16 11:59:03 +02:00
Valtteri Kantanen 5e86967627 Use correct components in the /students route 2024-12-16 11:58:09 +02:00
Valtteri Kantanen d971bb993f [Students] Rewrite student page with MUI 2024-12-16 11:55:54 +02:00
Valtteri Kantanen 2328f2deff [Students] Rewrite search page with MUI 2024-12-16 11:53:15 +02:00
Valtteri Kantanen ddeb65abef Rewrite CurriculumPicker with MUI 2024-12-16 11:50:59 +02:00
Valtteri Kantanen 0871d8ecbd Remove minWidth from TableHeaderWithTooltip component 2024-12-16 11:49:35 +02:00
Valtteri Kantanen 3b8aec2fef Add variant prop to ExternalLink component for customizable typography 2024-12-16 11:48:36 +02:00
Valtteri Kantanen 71dc06728f Create StyledTable 2024-12-16 11:36:55 +02:00
Valtteri Kantanen 9484ecb456 Create StyledAccordion 2024-12-16 11:36:25 +02:00
Valtteri Kantanen 25319d1c8f Rewrite StudentNameVisibilityToggle with MUI 2024-12-16 11:35:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Valtteri Kantanen
e7fd54b443 Update all non-major dependencies (#4703)
* Update all non-major dependencies

* Fix ESLint warnings

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Valtteri Kantanen <valtteri.kantanen@iki.fi>
2024-12-16 09:24:15 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
aed2bc154b Update dependency axios to ^0.29.0 (#4684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:59:19 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
09a991669c Update dependency scheduler to ^0.25.0 (#4694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:58:24 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
41325bfa12 Update dependency @types/node to v22 (#4701)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 15:51:51 +02:00
Ilkka LappeteläinenandGitHub 17a1d3718f Updated react-router-dom from v5 to v7 (#4700) 2024-12-15 15:04:42 +02:00
Valtteri Kantanen 8b9c5b8789 Remove schedule from Renovate config 2024-12-12 14:12:42 +02:00
Valtteri Kantanen e11609c335 [Faculties] Students by starting year: Put cell content always on one line 2024-12-12 14:02:32 +02:00
Valtteri Kantanen 3a5b3c2d65 [Faculties] Move state from parent to child 2024-12-12 14:00:15 +02:00
Valtteri Kantanen 33916312b5 [Frontend] Make the toggle with two labels always have the same colors 2024-12-12 13:59:36 +02:00
Riku RauhalaandGitHub dab8a2679f Merge pull request #4698 from UniversityOfHelsinkiCS/faculties-accordion
[Faculties] Wrap progress sections into accordions
2024-12-12 12:49:51 +02:00
Riku Rauhala 390334c820 [Faculties] Update tests 2024-12-12 12:08:36 +02:00
Riku Rauhala 356ffc953e [Faculties] Wrap progress sections into accordions 2024-12-12 11:58:10 +02:00
Riku Rauhala a2f3a5de61 [Class statistics] Credit statistics: Change default sorting order to descending 2024-12-12 10:56:33 +02:00
Riku Rauhala 635a3fbcbc [Toggles] Extract component ToggleContainer
Wrapping all Toggles for a consistent layout
2024-12-12 10:51:41 +02:00
Riku Rauhala 7ebcb86924 [Toggle] Use bold font for the active option 2024-12-12 10:44:16 +02:00
Riku Rauhala eb1781e6af [Faculties] Add missing Code title and tweak margin 2024-12-11 15:05:55 +02:00
Riku Rauhala 6df983642b [Faculties] Add mini info button for sorting the data table 2024-12-11 14:41:52 +02:00
Riku Rauhala 634c4c9a97 [Navigation bar] Improve nav button visibility 2024-12-11 14:08:35 +02:00
Riku Rauhala 9371c7d2ab [Frontend] Rearrange Material components
Let's move components that do not have other files associated with them away from index.tsx files
2024-12-11 09:33:41 +02:00
Riku RauhalaandGitHub f6678739ad Merge pull request #4695 from UniversityOfHelsinkiCS/material-ui-faculties
Material UI: Faculties
2024-12-10 15:40:41 +02:00
Riku Rauhala 2882dd25c6 [Faculties] Skip export tests 2024-12-10 15:23:09 +02:00
Riku Rauhala ed3166d08f [Faculties] Update tests 2024-12-10 15:00:14 +02:00
Valtteri Kantanen 39340108f2 [Updater] Replace country_fi, country_sv, and country_en with citizenships array 2024-12-10 13:39:24 +02:00
Riku Rauhala 94f037b6af [Faculties] Remove Semantic UI components
Saving legacy GraduationTimes for now as it is still being used in Study track overview
2024-12-09 20:09:36 +02:00
Riku Rauhala 090e81a432 [Faculties] Add missing info boxes 2024-12-09 20:01:04 +02:00
Riku Rauhala 6e8ae9b320 [FacultyStudentDataTable] Rewrite with MUI 2024-12-09 19:45:50 +02:00
Riku Rauhala 0db094655e [PopulationLink] Rewrite with MUI 2024-12-09 17:44:25 +02:00
Riku Rauhala c437ed4e96 [Faculties] Refactor and fix exports 2024-12-09 16:55:42 +02:00
Riku Rauhala ed5a778e50 [Faculties] Extract utils 2024-12-09 16:21:30 +02:00
Riku Rauhala 13633ce11a [Faculties] Extract type DegreeProgramme 2024-12-09 15:13:24 +02:00
Riku Rauhala eab47657e6 [Backend] Fix type imports 2024-12-09 15:09:32 +02:00
Riku Rauhala 35267e8b6d [Faculties] Rename BasicInformationTab for consistency 2024-12-09 15:06:03 +02:00
Riku Rauhala 15cc9cef4a [Faculties] Rewrite the Progress tab with MUI 2024-12-09 14:30:34 +02:00
Riku Rauhala f5e1e7267f [Faculties] Rewrite Students by starting year tab with MUI 2024-12-09 13:51:51 +02:00
Riku Rauhala c6eaa18a1f [Faculties] Rewrite the Update statistics tab with MUI 2024-12-09 11:13:52 +02:00
Riku Rauhala 63e16e4332 [Faculties] Rewrite Graduation times tab with MUI 2024-12-09 09:13:58 +02:00
Riku Rauhala 074e41de59 [Section] Add optional Export button
Also, let's specify a color (deep purple) to be associated with export functionality
2024-12-06 16:24:27 +02:00
Riku Rauhala 46b6686f4b [Faculty graduations] Fix types 2024-12-06 15:46:13 +02:00
Riku Rauhala ede94708d5 [Faculties] Type faculty RTK API 2024-12-05 21:19:13 +02:00
Riku Rauhala f20953c8d2 [FacultyGraduations] Generalize name
The name refers to names of faculties in the university page and to programmes in faculties page
2024-12-05 20:29:43 +02:00
Riku Rauhala 1e728593ea [University] Fix layout 2024-12-05 19:36:14 +02:00
Riku Rauhala 21f7fcc6d2 [Faculties] Rewrite Basic information tab with MUI 2024-12-05 13:45:23 +02:00
Riku Rauhala 20c026aa56 [Section] Remove margin
The section should not have margin but instead the spacing/gap between sections should be handled in each component with Stack
2024-12-04 16:45:40 +02:00
Riku Rauhala 24eb824163 [FacultyProgressTable] Extract component ExpandableRow
This can be used in faculty statistics too, reducing copy-paste
2024-12-04 16:38:15 +02:00
Riku Rauhala 2065afe852 [Section] Make title optional and improve error message
The error message now matches the loading skeleton better -> no layout shift in case of error
2024-12-04 11:46:00 +02:00
Riku Rauhala d60480d1ae [LineGraph] Rewrite for Material UI 2024-12-04 10:52:42 +02:00
Riku Rauhala 1be0f9090e [Highcharts] Disable animations for all charts 2024-12-02 22:00:29 +02:00
Riku Rauhala f9dad0a183 [Faculties] Rewrite faculty list with MUI 2024-12-02 20:08:46 +02:00
Riku Rauhala 597f1f0c0b [University] Extract tabs to a new hook 2024-12-02 18:21:02 +02:00
Riku Rauhala c26728d24a [Navigation bar] Fix issues 2024-12-02 15:33:38 +02:00
Riku Rauhala 4c1e11692a [Navigation bar] Make the tab clickable
The tab was technically clickable but would not trigger the link. The user would have to hit the text to navigate which was not ideal
2024-12-02 14:29:41 +02:00
Riku Rauhala 132a21e113 [Navigation bar] Fix active status for subpaths 2024-12-02 12:42:18 +02:00
Riku Rauhala 324fcb71a9 [Backend] Remove oodikone-shared
Looks like we forgot to remove this one
2024-12-02 11:46:20 +02:00
Riku Rauhala 55111201e6 [Backend] Fix broken imports 2024-12-02 11:45:13 +02:00
Riku Rauhala 067040d92f [Backend] Extract faculty param types to shared 2024-12-02 11:25:05 +02:00
Riku Rauhala 0d857d716a [ESLint] Ignore no-unused-modules for shared 2024-12-02 10:59:50 +02:00
Riku Rauhala ec9b0b22b0 [Faculties] Only send relevant fields to frontend 2024-12-02 10:41:02 +02:00
Riku Rauhala 32cf9ed185 [Shared] Add GetFacultiesResponse 2024-12-02 10:32:15 +02:00
Riku Rauhala 1702e086a3 [Shared] Move API types to types/api 2024-12-02 09:38:25 +02:00
Riku Rauhala 8246818a94 [Navigation bar] Fix active path 2024-12-02 09:03:52 +02:00
Riku RauhalaandGitHub e1ee00a331 Merge pull request #4693 from UniversityOfHelsinkiCS/responsive-nav-bar
Responsive navigation bar
2024-11-29 17:57:38 +02:00
Riku Rauhala 6e22cb5e58 [Changelog] Add version number 2024-11-29 17:28:26 +02:00
Riku Rauhala f4ebb3d547 [Footer] Tweak gaps 2024-11-29 17:27:15 +02:00
Riku Rauhala 5683fbc3cb [Navigation bar] Switch to a tab based layout 2024-11-29 17:17:24 +02:00
Riku Rauhala 0c78f1c7e2 [Changelog] Increase the number of visible items to 20 2024-11-29 10:27:43 +02:00
Valtteri Kantanen 5051bbdded [Close to graduation] Fix old view with the new one 2024-11-28 17:47:32 +02:00
Valtteri Kantanen c7b86856fb [Backend] Fix study guidance group fetching when the user has no sisPersonId 2024-11-28 15:45:45 +02:00
Valtteri Kantanen afcf72e29f Improve quality on Cypress videos 2024-11-28 15:45:12 +02:00
Valtteri Kantanen a83482179a [Close to graduation] Fix excel exports 2024-11-28 15:24:11 +02:00
Riku RauhalaandGitHub 1e7a198dec Merge pull request #4692 from UniversityOfHelsinkiCS/material-ui-access-denied
Material UI: Access denied
2024-11-28 14:55:35 +02:00
Riku Rauhala 41646985e4 [Access denied] Remove old Semantic UI component 2024-11-28 14:24:23 +02:00
Valtteri Kantanen 80f6c7b1fb [Close to graduation] Use correct filters for columns 2024-11-28 14:15:43 +02:00
Valtteri Kantanen cc9465f141 Update default material-react-table options 2024-11-28 14:15:01 +02:00
Riku Rauhala d56c472e77 [Access denied] Rewrite in Material UI 2024-11-28 13:50:51 +02:00
Riku Rauhala 68e5daae13 [University] Revert to old graduation time colors 2024-11-28 11:09:15 +02:00
Riku Rauhala b259f30093 [University] Remove message about MH90_001 being excluded
This appears to no longer be the case
2024-11-28 10:51:15 +02:00
Riku Rauhala 804f5df55f [InfoBox] Switch to contained variant and increase width 2024-11-28 10:44:15 +02:00
Riku Rauhala 96ec7000fa [Faculty progress] Reduce copy-paste 2024-11-28 10:44:14 +02:00
550 changed files with 19299 additions and 19058 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ jobs:
run: docker cp frontend:/opt/app-root/src/build ./build
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
@@ -132,7 +132,7 @@ jobs:
run: docker cp backend:/opt/app-root/src/dist ./dist
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
+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
+1
View File
@@ -70,3 +70,4 @@ environments/
# Target of shared symlink
services/**/src/shared
+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 =>
+4 -4
View File
@@ -9,7 +9,7 @@ services:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
sis-db:
<<: *defaults
@@ -18,7 +18,7 @@ services:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
shm_size: 2gb
sis-importer-db:
@@ -28,7 +28,7 @@ services:
PGDATA: /data
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
user-db:
<<: *defaults
@@ -37,4 +37,4 @@ services:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
+82 -60
View File
@@ -1,7 +1,7 @@
/// <reference types="cypress" />
const openCompletedCoursesModal = () => {
cy.get('button').contains('Search completed courses of students').click()
cy.contains('button', 'Search completed courses of students').click()
}
const hasLanded = () => {
@@ -15,22 +15,40 @@ const openSearch = () => {
cy.init('/completedcoursessearch')
hasLanded()
}
const selectSavedCourselist = name => {
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type('{enter}')
cy.get('[data-cy=history-search]').click()
cy.get('[data-cy=history-search]').type(name)
cy.get('[data-cy=history-search]').type('{downarrow}')
cy.get('[data-cy=history-search]').type('{enter}')
}
const deleteAllSearches = () => {
cy.contains('Saved courselists')
.get('.dropdown')
.then(dropdown => {
const searchItems = dropdown.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
cy.get('button').contains('Delete').click()
const createCourseList = (courseCodes, courseListName) => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
cy.get('[data-cy=search-name]').type(courseListName)
cy.get('[data-cy=save-courselist]').click()
}
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
const deleteAllPreviousSearches = () => {
cy.get('[data-cy=history-search]').click()
cy.get('.MuiAutocomplete-popper li').then($options => {
if ($options.length > 0) {
cy.wrap($options).each(($option, index, $list) => {
cy.wrap($option).click()
cy.get('[data-cy=delete-courselist]').click()
if (index < $list.length - 1) {
cy.get('[data-cy=history-search]').click()
}
}
})
})
} else {
cy.contains('You have no previous searches.')
}
})
}
describe('When search modal is opened', () => {
@@ -39,8 +57,7 @@ describe('When search modal is opened', () => {
hasLanded()
cy.url().should('include', '/completedcoursessearch')
cy.contains('Search completed courses of students')
cy.get('[data-cy="open-completed-courses-modal-button"]').click()
deleteAllSearches()
cy.get('[data-cy=open-completed-courses-modal-button]').click()
})
it('Modal opens correctly', () => {
@@ -49,26 +66,33 @@ describe('When search modal is opened', () => {
openCompletedCoursesModal()
cy.contains('Search completed courses of students')
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
cy.contains('Insert one or more courses, separated by a space, a newline, or a comma.')
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
cy.contains('Insert name for this course list if you wish to save it')
})
it('Modal gets the correct course codes and student numbers from the URL', () => {
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
hasLanded()
openCompletedCoursesModal()
cy.contains('[data-cy=student-no-input]', '433237, 457144')
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
})
describe('When a search is executed with invalid input', () => {
it('Returns empty table and notification', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.get('[data-cy="student-no-input"]').type('1')
cy.get('[data-cy="course-list-input"]').type('1')
cy.get('[data-cy="completed-courses-search-button"]').click()
cy.get('[data-cy="rights-notification"]').should(
cy.get('[data-cy=student-no-input]').type('1')
cy.get('[data-cy=course-list-input]').type('1')
cy.get('[data-cy=completed-courses-search-button]').click()
cy.get('[data-cy=rights-notification]').should(
'contain.text',
'The following students information could not be displayed'
'The information for the following students could not be displayed'
)
cy.get('[data-cy="rights-notification"]').should('contain.text', '1')
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Completed courses search')
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Student number')
cy.get('[data-cy="completed-courses-table-div"]').should('not.contain', '1')
cy.contains('[data-cy=rights-notification]', '1')
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
})
})
@@ -78,9 +102,9 @@ describe('When search modal is opened', () => {
hasLanded()
openCompletedCoursesModal()
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy="student-no-input"]').type(studentSet1.join('\n'))
cy.get('[data-cy="course-list-input"]').type(coursesSet1.join('\n'))
cy.get('[data-cy="completed-courses-search-button"]').click()
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
cy.get('[data-cy=completed-courses-search-button]').click()
})
})
@@ -95,63 +119,61 @@ describe('When search modal is opened', () => {
it('Finds correct students and courses', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[0])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[1])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[2])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[3])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[4])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[0])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[1])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[2])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[3])
for (const studentNumber of studentSet1) {
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
}
for (const courseCode of coursesSet1) {
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
}
})
})
})
})
const courseListNames = ['TEST-courselist1', 'TEST-courselist2']
describe('Courselist saving-related functions work', () => {
beforeEach(() => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy="course-list-input"]').type('CSM14204\nTKT10004')
cy.get('[data-cy="search-name"]').type(courseListNames[0])
cy.get('button').contains('Save').click()
cy.get('body').then($body => {
if ($body.find('[data-cy=history-search]').length > 0) {
deleteAllPreviousSearches()
} else {
cy.contains('You have no previous searches.')
}
})
})
it('Course list can be saved', () => {
selectSavedCourselist(courseListNames[0])
const courseList = generateCourseListName()
const courses = ['CSM14204', 'TKT10004']
createCourseList(courses, courseList)
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').contains('TKT10004').contains('CSM14204')
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', courses.join(', '))
})
it('Course list can be deleted', () => {
const courseList = generateCourseListName()
createCourseList(['CSM14204', 'TKT10004'], courseList)
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy="search-name"]').type(courseListNames[1])
cy.get('button').contains('Save').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[1])
cy.get('button').contains('Delete').click()
cy.get('[data-cy="history-search"]')
.type(courseListNames[1])
.type('{enter}')
.should('not.contain', courseListNames[1])
selectSavedCourselist(courseList)
cy.get('[data-cy=delete-courselist]').click()
cy.contains('You have no previous searches.')
})
it('Course list can be updated', () => {
const courseList = generateCourseListName()
createCourseList(['CSM14204', 'TKT10004'], courseList)
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').type(',TKT10001')
cy.get('button').contains('Save').click()
selectSavedCourselist(courseList)
cy.get('[data-cy=course-list-input]').type(',TKT10001')
cy.get('[data-cy=save-courselist]').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').contains('TKT10001')
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
})
})
File diff suppressed because it is too large Load Diff
+228 -276
View File
@@ -5,331 +5,283 @@ const path = require('path')
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
const downloadsFolder = Cypress.config('downloadsFolder')
describe('Faculty overview', () => {
describe('Faculty can be selected', () => {
it('Faculties are listed and one can be chosen', () => {
describe('Faculty statistics', () => {
describe('Faculty list', () => {
beforeEach(() => {
cy.init('/faculties')
cy.get('[data-cy=select-faculty]').contains('a', 'Teologinen tiedekunta')
cy.contains('a', 'H99').should('not.exist')
})
it('contains faculty names and faculty codes', () => {
cy.contains('Faculties')
cy.contains('Teologinen tiedekunta')
cy.contains('H10')
cy.contains('Oikeustieteellinen tiedekunta')
cy.contains('H20')
cy.contains('Lääketieteellinen tiedekunta')
cy.contains('H30')
cy.contains('Humanistinen tiedekunta')
cy.contains('H40')
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
cy.contains('Farmasian tiedekunta')
cy.contains('H55')
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
cy.contains('H57')
cy.contains('Kasvatustieteellinen tiedekunta')
cy.contains('H60')
cy.contains('Valtiotieteellinen tiedekunta')
cy.contains('H70')
cy.contains('Svenska social- och kommunalhögskolan')
cy.contains('H74')
cy.contains('Maatalous-metsätieteellinen tiedekunta')
cy.contains('H80')
cy.contains('Eläinlääketieteellinen tiedekunta')
cy.contains('H90')
})
it('contains a working link to faculty page', () => {
cy.contains('Faculties')
cy.contains('a', 'Teologinen tiedekunta').click()
cy.contains('.header', 'Teologinen tiedekunta')
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
cy.contains('Teologinen tiedekunta')
cy.contains('H10')
})
})
describe('Basic information: admin user', () => {
beforeEach(() => {
cy.init('/faculties', 'admin')
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
describe('Basic information tab', () => {
describe('Admin user', () => {
beforeEach(() => {
cy.init('/faculties', 'admin')
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
})
it('Correct tabs are shown', () => {
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')
})
})
it('Credits produced by faculty are shown', () => {
cy.get('[data-cy="Section-creditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
})
describe('Basic user', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
})
it('Update statistics tab is shown', () => {
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Update statistics')
})
})
describe('Info boxes', () => {
it('Students of the faculty', () => {
cy.cs('students-of-the-faculty-info-box-button').click()
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
})
describe('Basic information: basic user', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
})
it('Graduated of the faculty', () => {
cy.cs('graduated-of-the-faculty-info-box-button').click()
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Basic information tab show all graphs and tables', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-ThesisWritersOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-StudentsOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('be.visible')
cy.get('[data-cy="Graph-GraduatedOfTheFaculty"]').should('be.visible')
})
it('Thesis writers of the faculty', () => {
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('Correct tabs are shown', () => {
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Basic information')
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Graduation times')
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Programmes and student populations')
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Update statistics')
})
it('Credits produced by the faculty', () => {
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('Toggle years works', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022')
cy.get('[data-cy="YearToggle"]').click()
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022 - 2023')
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022 - 2023')
})
it('All graphs and tables are shown', () => {
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('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')
})
it('Correct tabs are shown', () => {
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('Students of the faculty infobox works', () => {
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
cy.get('[data-cy="studentsOfTheFaculty-open-info"]').click()
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('be.visible')
cy.get('[data-cy="studentsOfTheFaculty-close-info"]').click()
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
})
it('Toggle years works', () => {
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
})
it('Data can be exported to Excel files', { retries: 3 }, () => {
const sections = [
'StudentsOfTheFaculty',
'GraduatedOfTheFaculty',
'ThesisWritersOfTheFaculty',
'CreditsProducedByTheFaculty',
]
sections.forEach(section => {
cy.get(`[data-cy="Graph-${section}"]`).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`
cy.readFile(path.join(downloadsFolder, downloadedFile))
it('Toggle programmes works', () => {
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.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 }, () => {
// TODO: Fix this test
const sections = [
'StudentsOfTheFaculty',
'GraduatedOfTheFaculty',
'ThesisWritersOfTheFaculty',
'CreditsProducedByTheFaculty',
]
sections.forEach(section => {
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`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
})
})
})
})
describe('Study programme information: users with access', () => {
describe('Students by starting year tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Maatalous-metsätieteellinen tiedekunta').click()
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Students by starting year').click()
})
it('Study programme credit information is not visible in the beginning', () => {
cy.get('table[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
it('Export button', { retries: 2 }, () => {
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('Study programme credit information can be toggled', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-3"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
cy.get('[data-cy="Button-Hide-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
it('Info box', () => {
cy.cs('faculty-student-table-info-box-button').click()
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
})
it('Graph stays open when sorted', () => {
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
cy.get('[data-cy="Menu-CreditsProducedByTheFaculty-Total"]').click()
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
it.skip('Study right toggle', () => {
// TODO: Implement
})
it.skip('Graduated toggle', () => {
// TODO: Implement
})
it.skip('Hoverable country list', () => {
// TODO: Implement
})
it.skip('Expandable rows', () => {
// TODO: Implement
})
it.skip('Population links', () => {
// TODO: Implement
})
it('Percentage toggle works', () => {
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 %')
})
})
describe('Average graduation times', () => {
describe('Progress tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Progress').click()
})
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
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.cs('FacultyProgress-info-box-button').click()
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
// TODO: Click open the accordion first
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.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.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', () => {
// TODO: Implement
})
it.skip('Study right toggle', () => {
// TODO: Implement
})
})
describe('Graduation times tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Graduation times').click()
})
it('User can view graduation graphs', () => {
cy.get('[data-cy="Section-AverageGraduationTimes"]').should('be.visible')
cy.get('[data-cy="Section-bachelor"]').should('be.visible')
cy.get('[data-cy="Section-master"]').should('be.visible')
cy.get('[data-cy="Section-master"]').within(() => {
cy.contains('text.highcharts-axis-title', 'Graduation year')
cy.contains('.message', "Click a bar to view that year's programme level breakdown").should('be.visible')
})
})
it('Graphs display data', () => {
cy.get('[data-cy="Section-bachelor"]').within(() => {
cy.get('div[class="faculty-breakdown-graph"]')
})
cy.get('[data-cy="GraduationTimeToggle"]').click()
cy.get('[data-cy="Section-bachelor"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('44 graduated').trigger('mouseover')
cy.contains('44 students graduated in year 2023')
cy.contains('median study time: 37.5 months')
cy.contains('21 graduated on time')
cy.contains('11 graduated max year overtime')
cy.contains('12 graduated over year late')
cy.contains('44 graduated').click()
cy.contains('Year 2023 by graduation year')
cy.get('div[class="programmes-graph"]').should('be.visible')
cy.get('div[class="programmes-graph"]').within(() => {
cy.contains('MAT')
cy.get('[aria-label="MAT, 36."]').trigger('mouseover')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('KH50_001')
cy.contains('26 students graduated in year 2023')
cy.contains('median study time: 36 months')
cy.contains('14 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('6 graduated over year late')
})
})
cy.get('[data-cy="Section-master"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('4 graduated').trigger('mouseover')
cy.contains('4 students graduated in year 2023')
cy.contains('median study time: 23 months')
cy.contains('3 graduated on time')
cy.contains('0 graduated max year overtime')
cy.contains('1 graduated over year late')
cy.contains('4 graduated').click()
cy.contains('Year 2023 by graduation year')
cy.get('div[class="programmes-graph"]').should('be.visible')
cy.get('div[class="programmes-graph"]').within(() => {
cy.contains('MAST')
cy.get('[aria-label="MAST, 23."]').trigger('mouseover')
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
cy.contains('MH50_001')
cy.contains('3 students graduated in year 2023')
cy.contains('median study time: 23 months')
cy.contains('2 graduated on time')
cy.contains('0 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
it('Graduation times grouping and time types can be toggled', () => {
cy.get('[data-cy="GraduationTimeToggle"]').click()
cy.get('[data-cy="GroupByToggle"]').click()
cy.get('[data-cy="Section-master"]').should('be.visible')
cy.get('[data-cy="Section-bachelor"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('24 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 24/37 students have graduated')
})
cy.get('[data-cy="GraduationTimeToggle"]').click()
cy.get('[data-cy="Section-bachelor"]').within(() => {
cy.get('[aria-label="2018 - 2019, 29. On time."]').click()
cy.get('div[class="programmes-breakdown-graph"]').should('be.visible')
cy.get('div[class="programmes-breakdown-graph"]').within(() => {
cy.contains('Year 2018 - 2019 by start year')
cy.contains('MAT')
cy.get('[aria-label="MAT, 20. Max. year overtime."]').trigger('mouseover')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('KH50_001')
cy.contains('Max. year overtime: 20')
})
})
})
it('Data can be exported to an Excel file', () => {
cy.get('[data-cy="DownloadButton-AverageGraduationTimes"]').click()
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
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))
})
})
describe('Faculty Progress and student population tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Programmes and student populations').click()
it('Info boxes', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
})
it('User can view studentdata table without %', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '0 %')
it.skip('Study times toggle', () => {
// TODO: Implement
})
it('User can view studentdata table with %', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="HidePercentagesToggle"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '0 %')
it.skip('Year toggle', () => {
// TODO: Implement
})
it('Percentage Graphs exists', () => {
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
cy.get('[data-cy="Graph-FacultyBachelorMastersProgress"]').should('be.visible')
cy.get('[data-cy="Graph-FacultyMastersProgress"]').should('be.visible')
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
it.skip('Study programme toggle', () => {
// TODO: Implement
})
it('Graduations can be excluded', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="StudentToggle"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
})
it('Transfers can be excluded', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
cy.get('[data-cy="GraduatedToggle"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
})
it('Progress tables exists', () => {
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')
})
it('Progress tables can be toggled', () => {
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
cy.get('[data-cy="Button-Show-FacultyBachelorsProgressTable-5"]').click()
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-0"]').should('not.be.visible')
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-1"]').should('not.be.visible')
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-2"]').should('not.be.visible')
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-3"]').should('not.be.visible')
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('be.visible')
cy.get('[data-cy="Button-Hide-FacultyBachelorsProgressTable-5"]').click()
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
})
it('Studentstats table can be toggled', () => {
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', 'TKT')
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
})
it('Progress infobox can be toggled', () => {
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
cy.get('[data-cy="InfoFacultyProgress-open-info"]').click()
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('be.visible')
cy.get('[data-cy="InfoFacultyProgress-close-info"]').click()
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
it('Graphs are visible', () => {
cy.cs('bachelor-graduation-times-section').should('be.visible')
// ! No test data for bachelor + master
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()
})
})
})
})
+3 -3
View File
@@ -65,7 +65,7 @@ describe('When language center is opened', () => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', 15)
cy.get('td').eq(0).contains('All courses total')
const numbers = [36, 9, 34, 755, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + 1)
@@ -76,12 +76,12 @@ describe('When language center is opened', () => {
it('Faculties tab "exceeding" button works', () => {
cy.get('[data-cy="difference-button"]').click()
checkNumbers([...new Array(12).fill(0), 1, 227], 15)
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([28, 6, 28, 495, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
})
})
+240 -189
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,86 +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().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().contains('2020–2023').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(true)
})
cy.get('[data-cy=curriculum-picker]').click().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.
@@ -196,121 +117,249 @@ 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.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
// TODO: This fails in the pipeline, but works locally. Investigate.
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 new tag')
cy.cs('create-button').should('be.disabled')
})
})
})
@@ -328,7 +377,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()
+107 -78
View File
@@ -1,20 +1,24 @@
/// <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.cs('student-search').type(text)
}
const typeStudentNumberAndClick = studentNumber => {
cy.get('.prompt').type(studentNumber)
cy.contains('td a', studentNumber).click()
typeToSearch(studentNumber)
cy.contains('td', studentNumber).click()
}
describe('Students tests', () => {
describe('when using basic user', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init()
cy.contains('Students').click()
@@ -23,94 +27,112 @@ describe('Students tests', () => {
})
it('Students search form is usable', () => {
cy.get('.prompt').type(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', () => {
cy.get('.prompt').type(student.lastname.slice(0, 3))
cy.contains('No search results or search term is not accurate enough')
cy.get('.prompt').type(student.lastname.slice(3))
typeToSearch(student.lastName.slice(0, 3))
cy.contains('Search term is not accurate enough')
typeToSearch(student.lastName.slice(3))
cy.get('table tbody tr').should('have.length', 1)
})
it('can search with studentnumber too', () => {
cy.get('.prompt').type(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', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains('td a', student.studentnumber).click()
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('div.ui.fluid.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()
.last()
.within(() => {
cy.get('.level').click()
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=sisulink] > a')
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('div.ui.fluid.card').within(() => {
cy.cs('student-info-card').within(() => {
cy.contains('Enrollments').click()
cy.get('table tbody tr')
.should('have.length', 3)
@@ -122,52 +144,57 @@ describe('Students tests', () => {
})
})
it('Searching with bad inputs doesnt yield results', () => {
cy.get('.prompt').type('SWAG LITTINEN')
it("Searching with bad name doesn't yield results", () => {
typeToSearch('SWAG LITTINEN')
cy.contains('Student number').should('not.exist')
})
cy.get('.prompt').clear().type('01114')
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.contains('.ui.table', 'Completed').within(() => {
cy.get('i.level.up.alternate.icon').click()
typeStudentNumberAndClick(student.studentNumber)
cy.cs('study-rights-section').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => {
cy.get('a').click()
})
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
})
it('Grade graph works in all three different modes', () => {
typeStudentNumberAndClick(student.studentnumber)
cy.contains('a.item', 'Grade graph').click()
cy.contains('text.highcharts-title', 'Grade plot')
cy.contains('a.item', 'Show group mean').click()
cy.get('.labeled.input').within(() => {
cy.contains('.label', 'Group size')
typeStudentNumberAndClick(student.studentNumber)
cy.contains('button', 'Grade graph').click()
cy.contains('button', 'Show group mean').click()
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.contains('text.highcharts-title', 'Grade plot')
cy.get('.labeled.input').within(() => {
cy.contains('.label', 'Group size')
cy.get('input').clear().type('10')
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
cy.get('input').should('have.value', '10')
})
cy.contains('.highcharts-container text', "Jul '22")
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
cy.contains('a.item', 'Show semester mean').click()
cy.contains('button', 'Show semester mean').click()
cy.contains('.highcharts-container text', "Jan '22")
})
describe('Bachelor Honours section', () => {
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
cy.visit('/students/495976')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.green.tag.label', 'Qualified for Honours')
cy.contains('Main courses and other modules').click()
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
cy.contains('Study modules').click()
cy.get('[data-cy=main-modules] tbody tr')
.should('have.length', 3)
.each(($tr, index) => {
@@ -186,54 +213,54 @@ describe('Students tests', () => {
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
cy.visit('/students/540355')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Did not graduate in time')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
})
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
cy.visit('/students/547934')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Module grades too low')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
})
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
cy.visit('/students/478837')
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.blue.tag.label', 'Might need further inspection')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
})
it("Shows 'Has not graduated' when the student has not graduated", () => {
cy.visit(`students/${student.studentnumber}`)
cy.contains('.divider h4', 'Bachelor Honours')
cy.contains('.red.tag.label', 'Not qualified for Honours')
cy.contains('.red.tag.label', 'Has not graduated')
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')
})
})
})
// Use admin to see all students
describe('when using admin user', () => {
describe('When using admin user', () => {
beforeEach(() => {
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('div.ui.fluid.card').within(() => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student')
})
})
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
cy.visit('/students/453146')
cy.contains('.divider h4', 'Bachelor Honours').should('not.exist')
cy.contains('h2', 'Bachelor Honours').should('not.exist')
})
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
@@ -241,9 +268,11 @@ describe('Students tests', () => {
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)')
.should('have.attr', 'style')
.and('equal', 'background-color: rgb(232, 244, 255);')
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
'background-color',
'rgb(232, 244, 255)'
)
})
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
+2 -2
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'])
})
})
@@ -141,7 +141,7 @@ describe('Study guidance group tests', () => {
'Transferred from',
'Admission type',
'Gender',
'Citizenship',
'Citizenships',
'Curriculum period',
'Latest attainment date',
'Tags',
@@ -1,17 +1,52 @@
/* eslint-disable cypress/unsafe-to-chain-command */
/// <reference types="cypress" />
const { getEmptyYears } = require('../support/commands')
const deleteTag = name => {
cy.contains('td', name).siblings().contains('Delete').click()
const tagName = `tag-${new Date().getTime()}`
const selectYear = year => {
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
cy.contains('Associated start year (optional)').click({ force: true })
cy.get('[placeholder=YYYY]').type(year)
}
const deleteTag = tagName => {
cy.cs(`delete-tag-${tagName}-button`).click()
cy.contains('Delete tag')
cy.contains('Are you sure you want to delete tag')
cy.contains('Confirm').click()
cy.contains('td', name).should('not.exist')
cy.cs('confirm-delete-tag-button').click()
}
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 +54,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 +75,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 +91,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 +108,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 +116,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 +143,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 +175,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 +188,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 +225,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 +240,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 +256,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,39 +270,38 @@ 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', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 0],
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 35, 3],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 1],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 34, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 222, 7],
['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(() => {
@@ -276,7 +310,7 @@ describe('Study programme overview', () => {
const dataForStudyTracks = [
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
]
@@ -302,7 +336,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 +349,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 +358,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 +391,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 +419,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,24 +440,22 @@ 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],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 16, 1],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 87, 2],
['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 +477,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 +486,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 +501,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 +523,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 +538,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 +553,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 +639,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 +688,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,60 +709,80 @@ 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', () => {
const name = `tag-${new Date().getTime()}`
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
cy.contains('Create a new tag').click()
cy.contains(name)
cy.contains('2022')
deleteTag(name)
it('info box', () => {
cy.cs('create-new-tag-info-box-button').click()
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
})
it('can create personal tags', () => {
const name = `tag-${new Date().getTime()}`
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
describe('Adding tags to populations and removing them works', () => {
beforeEach(() => {
cy.contains('This study programme does not have any tags yet')
cy.cs('tag-name-text-field').type(tagName)
})
cy.get('.ui > label').click()
cy.contains('Create a new tag').click()
cy.contains(name)
deleteTag(name)
afterEach(() => {
deleteTag(tagName)
cy.contains('This study programme does not have any tags yet')
})
it('can create and delete tags without start year', () => {
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('No associated start year')
})
it.skip('can create and delete tags with start year', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('Associated start year 2022')
})
it.skip('population link works', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs('population-link-button').click()
cy.contains(`Tagged with: ${tagName}`)
cy.go('back')
})
it('can create personal tags', () => {
cy.cs('personal-tag-toggle').click()
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs(`${tagName}-visibility-icon`)
})
})
describe('Adding tags to students and removing them works', () => {
const name = `tag-${new Date().getTime()}`
const studentInput = '477806,478275; 478953 479239\n 480080'
const studentNumbers = studentInput.match(/[^\s,;]+/g)
it('can add tags to students', () => {
cy.get('.tagNameSelectInput > .ui > input').type(name)
cy.get('.yearSelectInput').type('2022')
cy.contains('Create a new tag').click()
cy.contains(name)
it.skip('can add tags to students', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
cy.cs('tag-name-text-field').type(tagName)
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('Add a tag to students').click()
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
cy.get('textarea').type(studentInput)
cy.contains('Add tags').click()
cy.contains('Successfully added tags to students.')
cy.contains('td', name).get('i.level.up.alternate.icon').click()
cy.cs('add-students-button').click()
cy.cs('add-students-text-field').type(studentInput)
cy.cs('add-students-confirm-button').click()
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains(`Tagged with: ${name}`)
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
.then($parentDiv => {
@@ -742,17 +793,24 @@ describe('Study programme overview', () => {
cy.contains(studentNumber)
}
cy.go('back')
deleteTag(name)
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags')
cy.contains(tagName)
}
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
deleteTag(tagName)
})
it('deleting a tag from tag view also removes it from students', () => {
cy.contains(name).should('not.exist')
cy.contains(tagName).should('not.exist')
for (const studentNumber of studentNumbers) {
cy.contains('Students').click()
cy.get('.prompt').type(studentNumber)
cy.contains('a', studentNumber).click()
cy.contains(name).should('not.exist')
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags').should('not.exist')
cy.contains(tagName).should('not.exist')
}
})
})
@@ -773,25 +831,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 -18
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`)
})
}
@@ -28,7 +28,6 @@ describe('University view', () => {
beforeEach(() => {
cy.init('/university')
cy.contains('University')
cy.contains('Programme MH90_001 (Veterinary medicine bachelor + licentiate) is currently excluded.')
})
describe('Faculty progress tab', () => {
@@ -41,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')
@@ -67,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', () => {
@@ -84,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')
})
})
})
+6 -15
View File
@@ -8,12 +8,8 @@ const visibleLinks = {
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const hasVisibleItems = visibleItems => {
cy.get('[data-cy=nav-bar]').children().should('have.length', visibleItems)
}
const containsLinks = links => {
cy.get('[data-cy=nav-bar]').within(() => {
cy.cs('nav-bar').within(() => {
for (const link of links) {
cy.contains(link)
}
@@ -21,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')
@@ -34,7 +30,6 @@ describe('Users tests', () => {
describe('Using as user with just grp-oodikone-user, no other rights', () => {
it('shows correct tabs', () => {
cy.init('', 'norights')
hasVisibleItems(7)
containsLinks(visibleLinks.norights)
userButtonWorks('norights')
})
@@ -43,7 +38,6 @@ describe('Users tests', () => {
describe('Using as coursestatistics user', () => {
it('shows correct tabs', () => {
cy.init('', 'onlycoursestatistics')
hasVisibleItems(7)
containsLinks(visibleLinks.onlycoursestatistics)
userButtonWorks('onlycoursestatistics')
})
@@ -52,7 +46,6 @@ describe('Users tests', () => {
describe('Using as basic user', () => {
it('shows correct tabs', () => {
cy.init('')
hasVisibleItems(10)
containsLinks(visibleLinks.basic)
userButtonWorks('basic')
})
@@ -64,15 +57,14 @@ describe('Users tests', () => {
})
it('should see more stuff than others', () => {
hasVisibleItems(13)
containsLinks(visibleLinks.admin)
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', () => {
@@ -80,8 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.get('[data-cy=nav-bar-button-studyProgramme]').click()
cy.get('[data-cy=nav-bar-button-class]').click()
cy.visit('/populations')
cy.contains('label', 'Study programme')
.siblings()
.within(() => {
+13
View File
@@ -19,4 +19,17 @@
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('before:browser:launch', (browser = {}, launchOptions) => {
// By manually setting the browser width and height in headless
// mode, screenshots and videos will have a better quality
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
const width = 2560
const height = 1440
if (browser.name === 'chrome' && browser.isHeadless) {
launchOptions.args.push(`--window-size=${width},${height}`)
launchOptions.args.push('--force-device-scale-factor=1')
}
return launchOptions
})
}
+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')
+4 -18
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
@@ -83,7 +83,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
@@ -118,18 +118,15 @@ services:
- redis
- sis-db
- sis-importer-db
- sis-updater-nats
environment:
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
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
profiles:
- updater
@@ -141,28 +138,17 @@ services:
depends_on:
- redis
- sis-importer-db
- sis-updater-nats
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
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
ports:
- 8082:8082
profiles:
- updater
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
profiles:
- updater
+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}
+6 -17
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
@@ -75,7 +76,7 @@ services:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
ports:
- 12349:5432
volumes:
@@ -98,19 +99,13 @@ services:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
volumes:
- sis-db-data:/data
shm_size: 2gb
ports:
- 12345:5432
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
sis-updater-scheduler:
<<: *defaults
build:
@@ -121,17 +116,14 @@ services:
- 'host.docker.internal:host-gateway'
depends_on:
- redis
- 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}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
SERVICE_PROVIDER: fd
ports:
- 8082:8082
@@ -149,20 +141,17 @@ services:
depends_on:
- redis
- sis-db
- sis-updater-nats
extra_hosts:
- 'host.docker.internal:host-gateway'
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
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
ROOT_ORG_ID: ${ROOT_ORG_ID}
SERVICE_PROVIDER: fd
volumes:
@@ -175,7 +164,7 @@ services:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.8
image: postgres:15.10
volumes:
- user-db-data:/data
ports:
+4 -18
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:
@@ -103,14 +103,6 @@ services:
ports:
- 12346:5432
sis-updater-nats:
<<: *defaults
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
container_name: sis-updater-nats
image: nats-streaming:0.25.6
profiles:
- updater
sis-updater-scheduler:
<<: *defaults
build:
@@ -120,16 +112,13 @@ services:
depends_on:
- redis
- sis-importer-db
- 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
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
ports:
- 8082:8082
profiles:
@@ -147,17 +136,14 @@ services:
- redis
- sis-db
- sis-importer-db
- sis-updater-nats
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
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
SIS_NATS_TOKEN: dev
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
profiles:
- updater
volumes:
@@ -189,7 +175,7 @@ services:
jami-db:
<<: *defaults
image: postgres:15.8
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
+2 -5
View File
@@ -12,15 +12,12 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
- studyGuidanceGroups
- teachers
The roles `openUniSearch` and `studyGuidanceGroups` simply enable certain views (_Open uni student population_ and _Guidance groups_ respectively).
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
### courseStatistics
- Can see statistics of every course but year statistics of ≤ 5 students are hidden
### facultyStatistics
- Can see statistics of every course and year
- If the user has [full rights](#programmes) to **any** programme, they can also see exact statistics even if there are ≤ 5 students (the same behavior as with `admin` or `fullSisuAccess` roles)
### teachers
+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`.
+2
View File
@@ -44,6 +44,8 @@ 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/)
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
<!--
+8 -3
View File
@@ -80,7 +80,6 @@ export default [
'no-param-reassign': ['error', { props: false }],
'no-promise-executor-return': 'error',
'no-return-assign': 'error',
'no-return-await': 'error',
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
@@ -90,6 +89,7 @@ export default [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
'react/jsx-sort-props': ['error', { reservedFirst: false }],
'react/no-array-index-key': 'error',
@@ -99,7 +99,7 @@ export default [
'react/prefer-stateless-function': 'error',
'react/prop-types': 'off',
...pluginReactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'off', // TODO: Delete this override
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/consistent-indexed-object-style': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
@@ -132,6 +132,12 @@ export default [
'no-console': 'off',
},
},
{
files: ['services/**/shared/**/*'],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
languageOptions: {
@@ -153,7 +159,6 @@ export default [
'no-await-in-loop': 'off',
'no-param-reassign': 'off',
'no-promise-executor-return': 'off',
'no-return-await': 'off',
},
},
{
+1068 -716
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -38,27 +38,27 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.6",
"@eslint/js": "^9.14.0",
"@cypress/code-coverage": "^3.13.12",
"@eslint/js": "^9.21.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^13.15.2",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"cypress": "^14.1.0",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-cypress": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^15.12.0",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.3.3",
"stylelint": "^16.10.0",
"stylelint-config-standard": "^36.0.1",
"typescript-eslint": "^8.13.0"
"prettier": "3.5.3",
"stylelint": "^16.15.0",
"stylelint-config-standard": "^37.0.0",
"typescript-eslint": "^8.26.0"
},
"dependencies": {
"lodash": "^4.17.21",
+1 -12
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
}
],
@@ -28,7 +19,5 @@
"labels": ["security"]
},
"addLabels": ["dependencies"],
"rangeStrategy": "bump",
"schedule": ["before 5:00am"],
"timezone": "Europe/Helsinki"
"rangeStrategy": "bump"
}
+459 -835
View File
File diff suppressed because it is too large Load Diff
+15 -17
View File
@@ -5,26 +5,24 @@
"build": "tsc"
},
"dependencies": {
"@sentry/node": "^8.37.1",
"@sentry/profiling-node": "^8.37.1",
"axios": "^0.28.1",
"bullmq": "^5.25.4",
"compression": "^1.7.5",
"@sentry/node": "^8.55.0",
"@sentry/profiling-node": "^8.55.0",
"axios": "^1.8.1",
"bullmq": "^5.41.7",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^3.1.9",
"express": "^4.21.1",
"cron": "^4.1.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",
"oodikone-shared": "file:../oodikone-shared",
"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",
"sequelize": "^6.37.6",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
@@ -35,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.16",
"@types/morgan": "^1.9.9",
"@types/node": "^22.9.0",
"@types/node": "^22.13.9",
"@types/validator": "^13.12.2",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
"tsx": "^4.19.3",
"typescript": "^5.8.2"
},
"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()
})
+6 -17
View File
@@ -1,6 +1,7 @@
import { InferAttributes } from 'sequelize'
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import { Name } from '../shared/types'
import { GenderCode } from '../types'
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
@@ -56,23 +57,8 @@ export class Student extends Model<InferAttributes<Student>> {
@Column(DataType.STRING)
phone_number!: string
@Column(DataType.STRING)
country_fi!: string
@Column(DataType.STRING)
country_sv!: string
@Column(DataType.STRING)
country_en!: string
@Column(DataType.STRING)
home_country_fi!: string
@Column(DataType.STRING)
home_country_sv!: string
@Column(DataType.STRING)
home_country_en!: string
@Column(DataType.JSONB)
citizenships!: Name[]
@Column(DataType.STRING)
gender_code!: GenderCode
@@ -83,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
+6 -2
View File
@@ -16,18 +16,21 @@ router.get('/', async (_req: Request, res: Response) => {
const fakeRelease: Release[] = [
{
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
title: 'Release 1',
title: 'Release 3',
time: new Date().toISOString(),
version: '0.0.3',
},
{
description: "Let's not spam the GitHub API in development!",
title: 'Release 2',
time: new Date().toISOString(),
version: '0.0.2',
},
{
description: 'This release should not be visible on the frontpage',
title: 'Release 3',
title: 'Release 1',
time: new Date().toISOString(),
version: '0.0.1',
},
]
return res.status(200).json(fakeRelease)
@@ -37,6 +40,7 @@ router.get('/', async (_req: Request, res: Response) => {
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
+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)
})
+3 -3
View File
@@ -5,7 +5,7 @@ import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/fa
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
import { getFacultyCredits } from '../services/faculty/facultyCredits'
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
import { getFacultiesForFacultyList } from '../services/faculty/facultyHelpers'
import {
getBasicStats,
setBasicStats,
@@ -22,7 +22,7 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../types'
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
import logger from '../util/logger'
// Faculty uses a lot of tools designed for Study programme.
@@ -31,7 +31,7 @@ import logger from '../util/logger'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const faculties = await getSortedFaculties()
const faculties = await getFacultiesForFacultyList()
res.json(faculties)
})
+45 -46
View File
@@ -6,7 +6,7 @@ import { difference, intersection, uniq } from 'lodash'
import { rootOrgId } from '../config'
import { SISStudyRight } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { encrypt, decrypt } from '../services/encrypt'
import { encrypt, decrypt, type EncrypterData } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
@@ -29,12 +29,11 @@ const filterPersonalTags = (population: Record<string, any>, userId: string) =>
}
}
type EncryptedStudent = EncrypterData
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
return (student as EncryptedStudent)?.encryptedData !== undefined
}
type EncryptedStudent = { iv: string; encryptedData: string }
interface PostPopulationStatisticsRequest extends Request {
body: {
year: string
@@ -92,7 +91,7 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
req.body.years.map(year => {
if (req.body.selectedStudentsByYear) {
req.body.selectedStudents = encrypted
? req.body.selectedStudents.filter(isEncryptedStudent).map(decrypt)
? (req.body.selectedStudents as EncryptedStudent[]).filter(isEncryptedStudent).map(decrypt)
: req.body.selectedStudentsByYear[year]
}
const newMonths = (upperYearBound - Number(year)) * 12
@@ -111,7 +110,7 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
}
if (encrypted) {
req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
req.body.selectedStudents = (req.body.selectedStudents as EncryptedStudent[]).map(decrypt)
}
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
@@ -252,6 +251,7 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
req.query.months = '12'
}
let result: { students: any[]; courses: any[] } | { error: string }
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiPopulationStudentPromises = Promise.all(
@@ -268,54 +268,53 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
)
const multiPopulationStudents = await multiPopulationStudentPromises
const result = populationStudentsMerger(multiPopulationStudents)
res.json(filterPersonalTags(result, userId))
result = populationStudentsMerger(multiPopulationStudents)
} else {
const result: any = await optimizedStatisticsOf({
result = await optimizedStatisticsOf({
...req.query,
year: Number(req.query.year),
studyRights: { programme: studyRights.programme },
months: Number(req.query.months),
})
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
// Obfuscate if user has only limited study programme rights
if (
!hasFullAccessToStudentData(roles) &&
!fullProgrammeRights.includes(studyRights.programme) &&
!fullProgrammeRights.includes(studyRights.combinedProgramme)
) {
if (!('students' in result)) {
return
}
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
return {
...student,
firstnames: '',
lastname: '',
phoneNumber: '',
iv,
studentNumber,
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
}
})
}
res.json(filterPersonalTags(result, userId))
}
if ('error' in result && result.error) {
Sentry.captureException(new Error(result.error))
return res.status(400).end()
}
// Obfuscate if user has only limited study programme rights
if (
!hasFullAccessToStudentData(roles) &&
!fullProgrammeRights.includes(studyRights.programme) &&
!fullProgrammeRights.includes(studyRights.combinedProgramme)
) {
if (!('students' in result)) {
return
}
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
return {
...student,
firstnames: '',
lastname: '',
phoneNumber: '',
iv,
studentNumber,
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
}
})
}
return res.json(filterPersonalTags(result, userId))
})
interface GetPopulationStatisticsByCourseRequest extends Request {
@@ -18,7 +18,7 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
import { Graduated, SpecialGroups, YearType } from '../types'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import logger from '../util/logger'
import { logInfoForGrafana } from '../util/logInfoForGrafana'
+55 -59
View File
@@ -1,8 +1,7 @@
import { Request, Response, Router } from 'express'
import { difference } from 'lodash'
import { Tag, TagStudent } from '../models/kone'
import { filterStudentnumbersByAccessrights } from '../services/students'
import { filterStudentNumbersByAccessRights } from '../services/students'
import {
findTagsByStudyTrack,
createNewTag,
@@ -11,20 +10,18 @@ import {
createMultipleStudentTags,
findTagsFromStudyTrackById,
deleteMultipleStudentTags,
TagFromFrontend,
StudentTagFromFrontend,
} from '../services/tags'
import { Role } from '../shared/types'
import { NewTag, Role, StudentTag, Tag } from '../shared/types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
const filterRelevantTags = (tags: Tag[], userId: string) => {
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
return tags.filter(tag => !tag.personalUserId || tag.personalUserId === userId)
}
const filterRelevantStudentTags = (studentTags: TagStudent[], userId: string) => {
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
const filterRelevantStudentTags = (studentTags: StudentTag[], userId: string) => {
return studentTags.filter(({ tag }) => !tag?.personalUserId || tag.personalUserId === userId)
}
const userIsUnauthorized = (programmeRights: string[], programmeCodes: string[], roles: Role[]) => {
@@ -41,15 +38,15 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
interface GetTagsByStudyTrackRequest extends Request {
params: {
studytrack: string
studyTrack: string
}
}
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
const { studytrack } = req.params
router.get('/tags/:studyTrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags
// This is to avoid unnecessary noise in Sentry
@@ -57,149 +54,148 @@ router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Res
return res.json(null)
}
const tags = await findTagsByStudyTrack(studytrack)
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface PostTagRequest extends Request {
body: {
tag: TagFromFrontend
}
body: { tag: NewTag }
}
router.post('/tags', async (req: PostTagRequest, res: Response) => {
const {
tag: { studytrack, tagname, year, personal_user_id },
tag: { name, personalUserId, studyTrack, year },
} = req.body
const { roles, id, programmeRights } = req.user
if (!tagname || !studytrack || !year) {
if (!name || !studyTrack) {
return res.status(400).json({ error: 'Missing required fields' })
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) {
return res.status(403).end()
}
await createNewTag({ studytrack, tagname, year, personal_user_id })
const tags = await findTagsByStudyTrack(studytrack)
await createNewTag({ name, personalUserId, studyTrack, year })
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface DeleteTagRequest extends Request {
body: {
tag: {
studytrack: string
tag_id: string
personal_user_id: string
}
}
body: { tag: Tag }
}
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
const { tag } = req.body
const { roles, id, programmeRights } = req.user
const {
tag: { id, personalUserId, studyTrack },
} = req.body
const { roles, id: userId, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(tag.studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id)) {
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(personalUserId === userId)) {
return res.status(403).end()
}
await deleteTag(tag.tag_id)
const tags = await findTagsByStudyTrack(tag.studytrack)
await deleteTag(id)
const tags = await findTagsByStudyTrack(studyTrack)
res.status(200).json(filterRelevantTags(tags, id))
})
interface GetStudentTagsByStudyTrackRequest extends Request {
params: {
studytrack: string
studyTrack: string
}
}
router.get('/studenttags/:studytrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
const { studytrack } = req.params
router.get('/studenttags/:studyTrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
const { studyTrack } = req.params
const { roles, id, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const programmeCodes = getProgrammeCodes(studytrack)
const programmeCodes = getProgrammeCodes(studyTrack)
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
if (lacksProgrammeAccess && !roles?.includes('admin')) {
return res.json(null)
}
const tags = await getStudentTagsByStudyTrack(studytrack)
res.status(200).json(filterRelevantStudentTags(tags, id))
const tags = await getStudentTagsByStudyTrack(studyTrack)
const relevantTags = filterRelevantStudentTags(tags, id)
res.status(200).json(relevantTags)
})
interface PostStudentTagsRequest extends Request {
body: {
tags: StudentTagFromFrontend[]
studytrack: string
combinedProgramme: string
studentTags: StudentTag[]
studyTrack: string
}
}
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
const { tags, studytrack, combinedProgramme } = req.body
const { combinedProgramme, studentTags, studyTrack } = req.body
const { roles, programmeRights } = req.user
if (!studentTags || !studyTrack) {
return res.status(400).json({ error: 'Missing required fields' })
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
return res.status(403).end()
}
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
const existingTags = await findTagsByStudyTrack(studyTrackCode)
const existingTagids = existingTags.map(tag => tag.tag_id)
const tagIds = [...new Set(tags.map(tag => tag.tag_id))]
if (!tagIds.find(tag => existingTagids.includes(tag))) {
const existingTagIds = existingTags.map(tag => tag.id)
const tagIds = [...new Set(studentTags.map(studentTag => studentTag.tagId))]
if (!tagIds.find(tag => existingTagIds.includes(tag))) {
return res.status(400).json({ error: 'The tag does not exist' })
}
const studentNumbers = tags.map(tag => tag.studentnumber)
const studentFromProgrammes = combinedProgramme ? [studytrack, combinedProgramme] : [studytrack]
const students = await filterStudentnumbersByAccessrights(studentNumbers, studentFromProgrammes)
const studentNumbers = studentTags.map(studentTag => studentTag.studentNumber)
const studentFromProgrammes = combinedProgramme ? [studyTrack, combinedProgramme] : [studyTrack]
const students = await filterStudentNumbersByAccessRights(studentNumbers, studentFromProgrammes)
const missingStudents = difference(studentNumbers, students)
if (missingStudents.length !== 0) {
const errorMessage = `Could not find the following students from the programme: ${missingStudents.join(', ')}`
return res.status(400).json({ error: errorMessage })
}
await createMultipleStudentTags(tags)
await createMultipleStudentTags(studentTags)
res.status(204).end()
})
interface DeleteStudentTagsRequest extends Request {
body: {
tagId: string
studentnumbers: string[]
studytrack: string
combinedProgramme: string
tagId: string
studentNumbers: string[]
studyTrack: string
}
}
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
const { roles, programmeRights } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
return res.status(403).end()
}
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
const tags = await findTagsFromStudyTrackById(studyTrackCode, [tagId])
if (tags.length === 0) {
return res.status(403).json({ error: 'The tag does not exist' })
}
await deleteMultipleStudentTags(tagId, studentnumbers)
await deleteMultipleStudentTags(tagId, studentNumbers)
res.status(204).end()
})
+2 -3
View File
@@ -15,8 +15,7 @@ import {
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import { NameWithCode } from '../shared/types'
import { Graduated } from '../types'
import { Graduated, NameWithCode } from '../shared/types'
import logger from '../util/logger'
const router = Router()
@@ -33,7 +32,7 @@ const getProgrammeNames = (faculties: Organization[]) => {
interface GetProgressStatsRequest extends Request {
query: {
specialsIncluded: 'true' | undefined
specialsIncluded: 'true' | 'false'
graduated: Graduated
}
}
+2 -2
View File
@@ -115,8 +115,8 @@ router.post('/refresh-close-to-graduation', (req: Request, res: Response) => {
})
router.get('/abort', async (_req: Request, res: Response) => {
await abortUpdate()
res.status(200).json()
const response = await abortUpdate()
res.status(200).json(response)
})
router.get('/jobs', async (_req: Request, res: Response) => {
+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,7 +1,7 @@
import moment from 'moment'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { Graduated, SpecialGroups, YearType } from '../types'
import { Graduated, SpecialGroups, YearType } from '../shared/types'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -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
}
@@ -1,10 +1,10 @@
const { createCipheriv, createDecipheriv, randomBytes } = require('crypto')
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
const { CRYPT_KEY } = require('../config')
import { CRYPT_KEY } from '../config'
const algorithm = 'aes-256-cbc'
const encrypt = text => {
export const encrypt = (text: string) => {
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
const iv = randomBytes(16)
const cipher = createCipheriv(algorithm, Buffer.from(CRYPT_KEY, 'hex'), iv)
@@ -17,15 +17,14 @@ const encrypt = text => {
}
}
const decrypt = text => {
export type EncrypterData = { iv: string; encryptedData: string }
export const decrypt = (data: EncrypterData) => {
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
const iv = Buffer.from(text.iv, 'hex')
const encryptedText = Buffer.from(text.encryptedData, 'hex')
const iv = Buffer.from(data.iv, 'hex')
const encryptedText = Buffer.from(data.encryptedData, 'hex')
const decipher = createDecipheriv(algorithm, Buffer.from(CRYPT_KEY, 'hex'), iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
return decrypted.toString()
}
module.exports = { encrypt, decrypt }
@@ -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(),
})
}
}
@@ -1,12 +1,11 @@
import { cloneDeep } from 'lodash'
import { NameWithCode } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { getBasicStats, setBasicStats } from '../analyticsService'
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
import type { ProgrammesOfOrganization } from './faculty'
type ProgrammeName = { code: string; en?: string; fi?: string; sv?: string }
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
const calculateCombinedStats = async (
@@ -19,12 +18,12 @@ const calculateCombinedStats = async (
graphStats: [] as StudyTrackBasicStats['graphStats'],
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
tableStats: [] as StudyTrackBasicStats['tableStats'],
titles: ['', 'Started\nstudying', 'Accepted', 'Graduated'],
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
}
if (includeAllSpecials) {
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
}
const programmeNames: Record<string, ProgrammeName> = {}
const programmeNames: Record<string, NameWithCode> = {}
let years: Array<string | number> = []
for (const programme of facultyProgrammes) {
@@ -143,7 +142,7 @@ const calculateCombinedStats = async (
graphStats: combinedGraphStats,
programmeTableStats: combinedProgrammeTableStats,
tableStats: combinedTableStats,
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
titles: ['Code', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
},
}
}
@@ -171,21 +171,38 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
classSizes.programmes[code] = classSizesByYear
if (!medians[level]) {
medians[level] = cloneDeep(basicStats).map(year => {
if (!classSizes[level]) {
classSizes[level] = {}
}
classSizes[level][year.name] = year.classSize
return { ...omit(year, ['y', 'classSize']), median: year.y }
})
} else {
for (const statsForYear of basicStats) {
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
}
classSizes[level] ??= {}
for (const statsForYear of basicStats) {
classSizes[level][statsForYear.name] ??= 0
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
}
if (level === 'master' && comboStats.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
}
classSizes.bcMsCombo ??= {}
for (const statsForYear of comboStats) {
classSizes.bcMsCombo[statsForYear.name] ??= 0
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
@@ -195,33 +212,6 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
}
}
if (level === 'master' && comboStats.length) {
if (!medians.bcMsCombo) {
medians.bcMsCombo = cloneDeep(comboStats).map(year => {
if (!classSizes.bcMsCombo) {
classSizes.bcMsCombo = {}
}
classSizes.bcMsCombo[year.name] = year.classSize
return { ...omit(year, ['y', 'classSize']), median: year.y }
})
} else {
for (const statsForYear of comboStats) {
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
if (!correctYear) {
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
continue
}
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize
correctYear.times.push(...statsForYear.times)
correctYear.median = getMedian(correctYear.times)
correctYear.amount += statsForYear.amount
correctYear.statistics.onTime += statsForYear.statistics.onTime
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
}
}
}
if (!programmes.medians[level]) {
programmes.medians[level] = {}
}
@@ -1,13 +1,12 @@
import { ignoredFacultyCodes } from '../../config/organizationConstants'
import { Organization } from '../../models'
import { getOrganizations } from '../organizations'
export const getFaculties = async (): Promise<Organization[]> => {
export const getFaculties = async () => {
const faculties = (await getOrganizations()).filter(faculty => !ignoredFacultyCodes.includes(faculty.code))
return faculties
}
export const getSortedFaculties = async (): Promise<Organization[]> => {
export const getSortedFaculties = async () => {
const faculties = await getFaculties()
return faculties.sort((a, b) => {
const nameA = a.name.fi ?? ''
@@ -16,8 +15,11 @@ export const getSortedFaculties = async (): Promise<Organization[]> => {
})
}
const commissionedProgrammes = ['KH50_009', 'MH50_015', 'T923103-N']
export const checkCommissioned = (studyRight: any): boolean => {
return studyRight.studyrightElements.some(element => commissionedProgrammes.includes(element.code))
export const getFacultiesForFacultyList = async () => {
const faculties = await getSortedFaculties()
return faculties.map(faculty => ({
code: faculty.code,
id: faculty.id,
name: faculty.name,
}))
}
@@ -1,7 +1,7 @@
import moment from 'moment'
import { Name } from '../../shared/types'
import { DegreeProgrammeType, Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../types'
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
import { DegreeProgrammeType } from '../../types'
import { redisClient } from '../redis'
import { FacultyProgressData } from './facultyStudentProgress'
@@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash'
import moment from 'moment'
import { rootOrgId } from '../../config'
import { Graduated, SpecialGroups, Unarray } from '../../types'
import { Graduated, SpecialGroups } from '../../shared/types'
import { Unarray } from '../../types'
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -133,7 +133,7 @@ export const combineFacultyThesisWriters = async (
tableStats,
graphStats,
programmeTableStats,
titles: ['', 'All', 'Bachelors', 'Masters'],
titles: ['Code', 'All', 'Bachelors', 'Masters'],
programmeNames,
status: 'Done',
lastUpdated: '',
@@ -1,4 +1,4 @@
import { StatsType } from '../../types'
import { StatsType } from '../../shared/types'
import { setCreditStats } from '../analyticsService'
import { computeCreditsProduced } from '../providerCredits'
import { getDegreeProgrammesOfFaculty } from './faculty'
@@ -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) {
@@ -97,9 +97,7 @@ const getStudents = async (studentNumbers: string[]) => {
'firstnames',
'lastname',
'studentnumber',
'home_country_en',
'home_country_fi',
'home_country_sv',
'citizenships',
'dateofuniversityenrollment',
'creditcount',
'abbreviatedname',
@@ -146,6 +144,7 @@ const getStudents = async (studentNumbers: string[]) => {
attributes: [
'included_courses',
'programme_code',
'includedModules',
'completed_credits',
'curriculum_period_id',
'sis_study_right_id',
@@ -5,11 +5,11 @@ import { getDegreeProgrammeType } from '../../util'
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
import { getStudentsIncludeCoursesBetween } from './getStudentsIncludeCoursesBetween'
import {
Query as FormattedQueryParams,
dateMonthsFromNow,
formatStudentsForApi,
getOptionsForStudents,
parseQueryParams,
Query as FormattedQueryParams,
} from './shared'
import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements'
@@ -54,6 +54,7 @@ export const optimizedStatisticsOf = async (query: Query, studentNumberList?: st
let criteria = {} as Criteria
const degreeProgrammeType = await getDegreeProgrammeType(code)
if (degreeProgrammeType === DegreeProgrammeType.MASTER || degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
optionData = await getOptionsForStudents(studentNumbers, code, degreeProgrammeType)
}
@@ -1,5 +1,4 @@
import { orderBy } from 'lodash'
import moment from 'moment'
import { Op, QueryTypes } from 'sequelize'
import { dbConnections } from '../../database/connection'
@@ -34,43 +33,93 @@ const createEmptyCriteriaYear = (criteria: Criteria, year: string): CriteriaYear
}
}
const getCreditAmount = (
course: ParsedCourse,
hops: Studyplan[],
courseCode: string,
startDate: string,
addition: number
) => {
return moment(course.date).isBetween(moment(startDate), moment(startDate).add(addition, 'year')) &&
hops.length > 0 &&
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
? course.credits
: 0
/**
* @returns A new date with year incremented by the given amount
*/
const dateYearsFromNow = (date: Date, years: number) => {
const newDate = new Date(date)
newDate.setFullYear(newDate.getFullYear() + years)
return newDate
}
/**
* @param date The initial Date as ISOString
* @param months Number of months to add to the start date
* @returns A new date with months incremented by the given amount
*/
export const dateMonthsFromNow = (date: string, months: number) => {
const initialDate = new Date(date)
return new Date(initialDate.setMonth(initialDate.getMonth() + months))
}
/**
* @returns A new Date with amount of days added to the start date
*/
const dateDaysFromNow = (date: Date, days: number) => new Date(date.getTime() + 24 * 60 * 60 * 1000 * days)
const getCreditAmount = (course: ParsedCourse, hops: Studyplan[], courseCode: string, startDate: string) => {
const courseDate = new Date(course.date)
const startDateFromString = new Date(startDate)
const creditAmounts = [0, 0, 0, 0, 0, 0]
for (let i = 1; i <= 6; i++) {
if (
courseDate > startDateFromString &&
courseDate < dateYearsFromNow(startDateFromString, i) &&
hops.length > 0 &&
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
) {
creditAmounts[i - 1] = course.credits
}
}
return creditAmounts
}
const updateCourseByYear = (
criteria: Criteria,
criteriaYear: string,
course: ParsedCourse,
criteriaChecked: Record<string, CriteriaYear>,
yearToAdd: string,
correctCode: string
) => {
// TODO: Clean up this mess
if (
criteria?.courses?.[criteriaYear] &&
(criteria.courses[criteriaYear].includes(course.course_code) ||
criteria.courses[criteriaYear].some((criteriaCourse: string) =>
criteria.allCourses[criteriaCourse]?.includes(course.course_code)
))
) {
if (
!criteriaChecked[yearToAdd].coursesSatisfied[correctCode] ||
new Date(criteriaChecked[yearToAdd].coursesSatisfied[correctCode]) > new Date(course.date)
) {
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
const yearMap = [
{ criteriaYear: 'yearOne', yearToAdd: 'year1' },
{ criteriaYear: 'yearTwo', yearToAdd: 'year2' },
{ criteriaYear: 'yearThree', yearToAdd: 'year3' },
{ criteriaYear: 'yearFour', yearToAdd: 'year4' },
{ criteriaYear: 'yearFive', yearToAdd: 'year5' },
{ criteriaYear: 'yearSix', yearToAdd: 'year6' },
]
const courseSets = yearMap.reduce(
(acc, { criteriaYear }) => {
const yearCourses = criteria?.courses?.[criteriaYear] || []
const expandedCodes = new Set<string>()
yearCourses.forEach((mainCode: string) => {
expandedCodes.add(mainCode)
criteria.allCourses[mainCode]?.forEach((subCode: string) => {
expandedCodes.add(subCode)
})
})
acc[criteriaYear] = expandedCodes
return acc
},
{} as Record<string, Set<string>>
)
yearMap.forEach(({ criteriaYear, yearToAdd }) => {
const yearSet = courseSets[criteriaYear]
if (yearSet?.has(course.course_code)) {
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
if (!currentDate || new Date(currentDate) > new Date(course.date)) {
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
}
}
}
})
}
const updateCreditCriteriaInfo = (
@@ -113,16 +162,18 @@ const formatStudentForPopulationStatistics = (
enrollments: Record<string, Enrollment[]>,
credits: Record<string, Credit[]>,
startDate: string,
startDateMoment: moment.Moment,
endDateMoment: moment.Moment,
endDate: string,
criteria: Criteria,
code: string,
optionData: Record<string, { name: Name }>
) => {
const startDateFromISO = new Date(startDate)
const endDateFromISO = new Date(endDate)
const toCourse = (credit: Credit, normalizeDate: boolean): ParsedCourse => {
const attainmentDateNormalized =
normalizeDate && credit.attainment_date < new Date(startDate)
? startDateMoment.clone().add(1, 'day').toISOString()
normalizeDate && credit.attainment_date < startDateFromISO
? dateDaysFromNow(startDateFromISO, 1).toISOString()
: credit.attainment_date.toISOString()
const passed =
Credit.passed({ credittypecode: credit.credittypecode }) ||
@@ -159,9 +210,7 @@ const formatStudentForPopulationStatistics = (
tags,
birthdate,
sis_person_id,
home_country_fi,
home_country_sv,
home_country_en,
citizenships,
} = student
const criteriaCoursesBySubstitutions = criteria?.allCourses
@@ -195,9 +244,16 @@ const formatStudentForPopulationStatistics = (
correctStudyRightElement!
)
if (hasTransferredToProgramme) {
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element =>
moment(element.endDate).isSame(moment(correctStudyRightElement?.startDate).subtract(1, 'd'), 'day')
)?.code
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
const studyRightEndDate = new Date(element.endDate)
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
return (
studyRightStartDate.getFullYear() === studyRightEndDate.getFullYear() &&
studyRightStartDate.getMonth() === studyRightEndDate.getMonth() &&
studyRightStartDate.getDate() === studyRightEndDate.getDate()
)
})?.code
if (transferredFromProgramme) {
transferredStudyright = true
transferSource = transferredFromProgramme
@@ -217,23 +273,21 @@ const formatStudentForPopulationStatistics = (
}
const academicYears = { first: 0, second: 0, third: 0, fourth: 0, fifth: 0, sixth: 0 }
if (criteria.courses || criteria.credits) {
const courses = credits[studentnumber] ? credits[studentnumber].map(credit => toCourse(credit, true)) : []
courses.forEach(course => {
if (course.passed) {
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
updateCourseByYear(criteria, 'yearOne', course, criteriaChecked, 'year1', correctCode)
updateCourseByYear(criteria, 'yearTwo', course, criteriaChecked, 'year2', correctCode)
updateCourseByYear(criteria, 'yearThree', course, criteriaChecked, 'year3', correctCode)
updateCourseByYear(criteria, 'yearFour', course, criteriaChecked, 'year4', correctCode)
updateCourseByYear(criteria, 'yearFive', course, criteriaChecked, 'year5', correctCode)
updateCourseByYear(criteria, 'yearSix', course, criteriaChecked, 'year6', correctCode)
academicYears.first += getCreditAmount(course, correctStudyplan, correctCode, startDate, 1)
academicYears.second += getCreditAmount(course, correctStudyplan, correctCode, startDate, 2)
academicYears.third += getCreditAmount(course, correctStudyplan, correctCode, startDate, 3)
academicYears.fourth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 4)
academicYears.fifth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 5)
academicYears.sixth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 6)
updateCourseByYear(criteria, course, criteriaChecked, correctCode)
const creditAmounts = getCreditAmount(course, correctStudyplan, correctCode, startDate)
Object.keys(academicYears).forEach((year, index) => {
academicYears[year] += creditAmounts[index]
})
}
})
}
@@ -248,6 +302,7 @@ const formatStudentForPopulationStatistics = (
}
const started = dateofuniversityenrollment
const starting = +startDateFromISO <= +started && +started <= +endDateFromISO
return {
firstnames,
@@ -266,14 +321,12 @@ const formatStudentForPopulationStatistics = (
updatedAt: updatedAt || createdAt,
tags: tags ?? [],
studyrightStart: startDate,
starting: moment(started).isBetween(startDateMoment, endDateMoment, null, '[]'),
starting,
option,
birthdate,
studyplans,
sis_person_id,
home_country_en,
home_country_fi,
home_country_sv,
citizenships,
criteriaProgress: toProgressCriteria(),
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
transferredStudyright,
@@ -281,10 +334,6 @@ const formatStudentForPopulationStatistics = (
}
}
export const dateMonthsFromNow = (date: string, months: number) => {
return new Date(moment(date).add(months, 'months').format('YYYY-MM-DD'))
}
export type Query = {
year: string
months: number
@@ -414,9 +463,6 @@ export const formatStudentsForApi = (
criteria: Criteria,
code: string
) => {
const startDateMoment = moment(startDate)
const endDateMoment = moment(endDate)
const creditsByStudent = credits.reduce(
(acc, credit) => {
acc[credit.student_studentnumber] = acc[credit.student_studentnumber] || []
@@ -441,8 +487,7 @@ export const formatStudentsForApi = (
enrollmentsByStudent,
creditsByStudent,
startDate,
startDateMoment,
endDateMoment,
endDate,
criteria,
code,
optionData
@@ -452,24 +497,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
@@ -486,12 +513,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,
@@ -500,24 +525,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,
@@ -527,21 +560,36 @@ 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(
`
SELECT DISTINCT ON (course.id)
return sequelize.query(
courses.length
? `
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,
@@ -549,35 +597,75 @@ 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
WHERE student_studentnumber IN (:studentnumbers)
AND attainment_date < :beforeDate
GROUP BY course_code
) AS credit
ON credit.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
) AND (enrollment.data IS NOT NULL OR credit.data IS NOT NULL)
`
: `
SELECT
course.code,
course.name,
course.substitutions,
course.main_course_code,
enrollment.data AS enrollments,
credit.data AS credits
FROM course
LEFT 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
LEFT JOIN (
SELECT
course_code,
ARRAY_AGG(JSONB_BUILD_OBJECT(
'grade', grade,
'student_studentnumber', student_studentnumber,
'attainment_date', attainment_date,
'credittypecode', credittypecode,
'course_code', course_code
)) AS data
FROM credit
WHERE student_studentnumber IN (:studentnumbers)
AND attainment_date < :beforeDate
GROUP BY course_code
) AS credit
ON credit.course_code = course.code
WHERE
enrollment.data IS NOT NULL OR credit.data IS NOT NULL
`,
{
replacements: {
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
beforeDate,
courseCodes,
skipCourseCodeFilter: courses.length === 0,
courseCodes: courses.length ? courses : ['DUMMY'],
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({
@@ -9,7 +9,7 @@ const params = { params: { token: SECRET_TOKEN } }
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
return response.data
return response.data?.message
}
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
+26 -7
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,18 +355,19 @@ 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[]) =>
export const filterStudentNumbersByAccessRights = async (studentnumbers: string[], codes: string[]) =>
(
await Student.findAll({
attributes: ['studentnumber'],
@@ -41,7 +41,7 @@ type StudyGuidanceGroup = {
}
const getGroupsFromImporter = async (sisPersonId: string) => {
if (!importerClient) {
if (!importerClient || !sisPersonId) {
return []
}
const answerTimeout = new Promise(resolve => setTimeout(resolve, 2000))
@@ -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)
@@ -35,7 +35,7 @@ export const getStudyRightsInProgramme = async (
if (includeStudentsAndCredits) {
include.push({
model: Student,
attributes: ['gender_code', 'home_country_en'],
attributes: ['gender_code', 'citizenships'],
include: [
{
model: Credit,
@@ -267,16 +267,20 @@ const getMainStatsByTrackAndYear = async (
}
}
const studentHomeCountry = studyRight.student.home_country_en
const studentCitizenships = studyRight.student.citizenships
if (studentHomeCountry === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
if (!yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry]) {
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] = 0
for (const citizenship of studentCitizenships) {
const country = citizenship.en
if (!country) {
continue
}
if (country === 'Finland') {
yearlyStats[year][programmeOrStudyTrack].finnish += 1
} else {
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] ??= 0
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] += 1
}
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] += 1
}
if (!hasGraduated) return
+44 -31
View File
@@ -1,19 +1,39 @@
import { Op } from 'sequelize'
import { Tag, TagStudent } from '../models/kone'
import { Tag as TagModel, TagStudent as TagStudentModel } from '../models/kone'
import { NewTag, StudentTag, Tag } from '../shared/types'
const formatTag = (tag: TagModel): Tag => {
return {
id: tag.tag_id,
name: tag.tagname,
studyTrack: tag.studytrack,
personalUserId: tag.personal_user_id,
year: tag.year,
}
}
const formatStudentTag = (studentTag: TagStudentModel): StudentTag => {
return {
studentNumber: studentTag.studentnumber,
tag: studentTag.tag ? formatTag(studentTag.tag) : null,
tagId: studentTag.tag_id,
}
}
export const findTagsByStudyTrack = async (studyTrack: string) => {
return Tag.findAll({
const tags = await TagModel.findAll({
where: {
studytrack: {
[Op.eq]: studyTrack,
},
},
})
return tags.map(tag => formatTag(tag))
}
export const findTagsFromStudyTrackById = async (studyTrack: string, tagIds: string[]) => {
return Tag.findAll({
return TagModel.findAll({
where: {
studytrack: {
[Op.eq]: studyTrack,
@@ -25,26 +45,17 @@ export const findTagsFromStudyTrackById = async (studyTrack: string, tagIds: str
})
}
export type TagFromFrontend = {
studytrack: string
tagname: string
year: string
personal_user_id: string | null
}
export const createNewTag = async (tag: TagFromFrontend) => {
if (Number.isNaN(tag.year)) {
const newTag = {
...tag,
year: null,
}
return Tag.create(newTag)
}
return Tag.create(tag)
export const createNewTag = async (tag: NewTag) => {
return TagModel.create({
studytrack: tag.studyTrack,
tagname: tag.name,
personal_user_id: tag.personalUserId,
year: Number.isNaN(tag.year) ? null : tag.year,
})
}
export const deleteTag = async (tagId: string) => {
return Tag.destroy({
return TagModel.destroy({
where: {
tag_id: {
[Op.eq]: tagId,
@@ -53,10 +64,10 @@ export const deleteTag = async (tagId: string) => {
})
}
export const getStudentTagsByStudyTrack = (studyTrack: string) => {
return TagStudent.findAll({
export const getStudentTagsByStudyTrack = async (studyTrack: string) => {
const studentTags = await TagStudentModel.findAll({
include: {
model: Tag,
model: TagModel,
attributes: ['tag_id', 'tagname', 'personal_user_id'],
where: {
studytrack: {
@@ -65,19 +76,21 @@ export const getStudentTagsByStudyTrack = (studyTrack: string) => {
},
},
})
return studentTags.map(studentTag => formatStudentTag(studentTag))
}
export type StudentTagFromFrontend = {
tag_id: string
studentnumber: string
}
export const createMultipleStudentTags = async (tags: StudentTagFromFrontend[]) => {
return TagStudent.bulkCreate(tags, { ignoreDuplicates: true })
export const createMultipleStudentTags = async (studentTags: StudentTag[]) => {
return TagStudentModel.bulkCreate(
studentTags.map(studentTag => ({
tag_id: studentTag.tagId,
studentnumber: studentTag.studentNumber,
})),
{ ignoreDuplicates: true }
)
}
export const deleteMultipleStudentTags = async (tagId: string, studentNumbers: string[]) => {
return TagStudent.destroy({
return TagStudentModel.destroy({
where: {
tag_id: {
[Op.eq]: tagId,
+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`)
-5
View File
@@ -1,5 +0,0 @@
export type Graduated = 'GRADUATED_INCLUDED' | 'GRADUATED_EXCLUDED'
export type SpecialGroups = 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED'
export type ProgrammeFilter = 'NEW_STUDY_PROGRAMMES' | 'ALL_PROGRAMMES'
export type YearType = 'ACADEMIC_YEAR' | 'CALENDAR_YEAR'
export type StatsType = 'ALL' | 'CREDITS' | 'STUDENT' | 'THESIS'
-2
View File
@@ -1,12 +1,10 @@
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'
export { ExtentCode } from './extentCode'
export { Graduated, SpecialGroups, StatsType, ProgrammeFilter, YearType } from './faculty'
export { GenderCode } from './genderCode'
export { IamAccess } from './iamAccess'
export { Phase } from './phase'
+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
+2 -1
View File
@@ -26,10 +26,11 @@ export const getUserIamAccess = async (
return {}
}
try {
const getSisuAccess = !isStaging && sisPersonId != null && sisPersonId !== ''
const { data: iamAccess } = await jamiClient.post('/', {
userId: sisPersonId,
iamGroups,
getSisuAccess: !isStaging,
getSisuAccess,
})
const { specialGroup } = iamAccess as { specialGroup: Record<string, boolean> }
delete iamAccess.specialGroup
+2 -2
View File
@@ -5,7 +5,7 @@ const moment = require('moment')
const { redis, concurrentWorkers } = require('../config')
const logger = require('../util/logger')
const { defaultJobOptions, queueName } = require('./queue')
const { queueName } = require('./queue')
const connection = {
host: redis,
@@ -43,6 +43,6 @@ worker.on('failed', (job, error) => {
})
worker.on('active', job => {
const attemptInfo = job.attemptsMade > 1 ? `(attempt ${job.attemptsMade}/${defaultJobOptions.attempts})` : ''
const attemptInfo = job.attemptsStarted > 1 ? `(attempt ${job.attemptsStarted}/${job.opts.attempts})` : ''
logger.info(`Started job: ${job.id} ${attemptInfo}`)
})
+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 .
+766 -992
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -4,35 +4,35 @@
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && NODE_OPTIONS=\"--max-old-space-size=4096\" vite build",
"preview": "vite preview",
"tsc": "tsc"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/x-date-pickers": "^7.22.3",
"@reduxjs/toolkit": "^2.3.0",
"@sentry/browser": "^8.37.1",
"axios": "^0.28.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.4.7",
"@mui/material": "^6.4.7",
"@mui/x-date-pickers": "^7.27.1",
"@reduxjs/toolkit": "^2.6.0",
"@sentry/browser": "^8.55.0",
"axios": "^1.8.1",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.0.1",
"material-react-table": "^3.2.1",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
"react": "^18.3.1",
"react-datetime": "^3.2.0",
"react-datetime": "^3.3.1",
"react-dom": "^18.3.1",
"react-highcharts": "^16.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^9.1.0",
"react-range": "^1.10.0",
"react-redux": "^9.1.2",
"react-router-dom": "^5.3.4",
"scheduler": "^0.23.2",
"react-redux": "^9.2.0",
"react-router": "^7.2.0",
"scheduler": "^0.25.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^3.0.0-beta.2",
"unfuck-spa-shibboleth-session": "^4.1.0",
@@ -40,13 +40,14 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"devDependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.9",
"@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.6.3",
"vite": "^6.0.1"
"@vitejs/plugin-react-swc": "^3.8.0",
"typescript": "^5.8.2",
"vite": "^6.2.0"
},
"overrides": {
"react-highcharts": {
@@ -110,6 +110,7 @@ export const handleRequest = store => next => async action => {
export const RTKApi = createApi({
reducerPath: 'api',
tagTypes: [
'CompletedCoursesSearchList',
'CustomPopulationSearches',
'ProgressCriteria',
'Semester',
@@ -1,10 +1,7 @@
export const closeToGraduationToolTips = `
You can filter students by choosing a degree programme and/or a faculty.
You can also select multiple programmes or faculties.
Please note that if you select a faculty, only the degree programmes of that faculty will be shown.
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
Students are included in this list based on the number of credits they have completed in their primary study plan.
The limits for each study programme are as follows:
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each study programme are as follows:
- All bachelor’s programmes: 140 credits
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
@@ -13,6 +10,5 @@ export const closeToGraduationToolTips = `
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
- Other master’s programmes: 70 credits
If you have access to all student data in Oodikone, you can also see all students here.
Otherwise you will only see the students in your study guidance groups.
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
`
@@ -73,6 +73,9 @@ export const facultyToolTips: Record<string, string> = {
start date of the bachelor programme and at the moment, they do not include any transferred credits.
Thus, in these statistics some students have fewer credits than in reality.
`,
interactiveDataTable: `
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
`,
}
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
@@ -85,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,40 +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.
`,
},
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',
}
@@ -6,4 +6,5 @@ export { filterToolTips } from './filters'
export { populationStatisticsToolTips } from './populationStatistics'
export { studentToolTips } from './students'
export { studyProgrammeToolTips } from './studyProgramme'
export { updaterToolTips } from './updater'
export { userToolTips } from './users'
@@ -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.
`,
@@ -117,7 +117,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Vaikka opiskelija olisi ilmoittautunut toiseen opiskeluoikeuteen läsnäolevaksi, mutta hän on laiminlyönyt ilmoittautumisen tarkasteltavaan koulutusohjelmaan liittyvään opiskeluoikeuteen, hänet lasketaan ryhmään ”Inactive”.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**, **Countries**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon.
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon. **Citizenships**-kategoriat (**Finland** ja **Other**) voivat tuottaa yhteenlaskettuna suuremman tuloksen, koska opiskelijoilla voi olla useampi kuin yksi kansalaisuus.
Jos sivun yläosassa on valittuna ”All study rights” (oletus), mukana ovat myös ohjelmaan siirtyneet ja ohjelmasta pois siirtyneet opiskelijat.
@@ -138,6 +138,11 @@ export const studyProgrammeToolTips: Record<string, string> = {
notCompleted: `
Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
`,
tags: `
Here you can create tags for study programme. You can either create public tags or *personal* tags. Tags can be used to combine students from other starting years.
'Associated start year' means what year you want to use as a start year for the students in that tag. For example with this you can move student from earlier starting year to next year if the student was absent during first year.
However this is optional and you can create a new tag without selecting year.
`,
}
studyProgrammeToolTips.studyTrackOverviewCombinedProgramme = studyProgrammeToolTips.studyTrackOverview
@@ -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)
`,
}
-304
View File
@@ -1,304 +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 }
}
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'
}, [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))
/**
@@ -1,98 +0,0 @@
import { random } from 'lodash'
import { useEffect, useState } from 'react'
import ReactHighchart from 'react-highcharts'
import { Button, Container, Dimmer, Header, Image, Transition } from 'semantic-ui-react'
import { images } from '@/common'
import { useLogoutMutation } from '@/redux/auth'
const names = [
'mluukkai',
'jakousa',
'totutotu',
'sasumaki',
'ikuisma',
'eero3',
'mitiaine',
'rimi',
'esakemp',
'woltsu',
'cxcorp',
'ajhaa',
'joonashak',
'vaahtokarkki',
'otahontas',
'saarasat',
'popalmu',
'ollikehy',
]
const dummyData = names.map(name => ({
name,
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
return acc
}, []),
}))
export const AccessDenied = ({ notEnabled }) => {
const header = notEnabled ? 'Welcome to Oodikone!' : 'Something broke'
const subheader = notEnabled
? "You're currently not allowed to enter but you will get an email when you're authorized"
: 'If this was not intended try refreshing your browser window, pressing log out or contacting grp-toska@helsinki.fi'
const [easterEgg, setEasterEgg] = useState(false)
const [logout] = useLogoutMutation()
useEffect(() => {
if (notEnabled) {
setTimeout(() => setEasterEgg(true), Math.floor(Math.random() * 1800000) + 600000)
}
}, [])
return (
<div>
<Container style={{ alignItems: 'stretch', display: 'flex', height: '100vh', justifyContent: 'space-evenly' }}>
<ReactHighchart
config={{
credits: { enabled: false },
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
series: dummyData,
title: { text: 'Students of Computer Science 2018-2020' },
yAxis: { title: { text: 'Cumulative credits' } },
}}
/>
<ReactHighchart
config={{
chart: { type: 'column' },
credits: { enabled: false },
series: dummyData.map(element => ({ name: element.name, data: [Math.max(...element.data)] })),
title: { text: "Your students' future" },
xAxis: { categories: ['2018'] },
yAxis: { title: { text: 'Cumulative credits' } },
}}
/>
</Container>
<Dimmer active page>
<Image centered size="medium" src={images.toskaLogo} style={{ paddingTop: '2%' }} />
<Header as="h2" inverted>
<p>{header}</p>
<Header.Subheader>{subheader}</Header.Subheader>
<br />
<Button color="pink" onClick={() => logout()}>
Log out
</Button>
</Header>
</Dimmer>
<Transition animation="fly up" duration={10000} visible={easterEgg}>
<Image
inline
size="huge"
src={images.irtomikko}
style={{ position: 'absolute', right: '10px', top: '350px' }}
verticalAlign="top"
/>
</Transition>
</div>
)
}
+42 -7
View File
@@ -1,18 +1,46 @@
import { CssBaseline } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import HighCharts from 'highcharts'
import moment from 'moment'
import { useEffect } from 'react'
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
import { AccessDenied } from '@/components/AccessDenied'
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'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
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', {
week: {
dow: 1, // First day of week is Monday
},
})
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
if (!isProduction || !id || !username) {
return
@@ -28,12 +56,19 @@ const Layout = ({ children }) => (
minHeight: '100vh',
}}
>
<ThemeProvider theme={theme}>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<Footer />
</ThemeProvider>
<LanguageProvider>
<LocalizationProvider dateAdapter={AdapterMoment}>
<ThemeProvider theme={theme}>
<StatusNotificationProvider>
<CssBaseline />
<NavigationBar />
<main style={{ flex: 1 }}>{children}</main>
<StatusNotification />
<Footer />
</StatusNotificationProvider>
</ThemeProvider>
</LocalizationProvider>
</LanguageProvider>
</div>
)
@@ -1,398 +0,0 @@
import { range } from 'lodash'
import { useState } from 'react'
import { Divider, Dropdown, Form, Icon, Loader, Message, Radio, Tab } from 'semantic-ui-react'
import { createLocaleComparator, getCurrentSemester, getEnrollmentTypeTextForExcel, isFall } from '@/common'
import { useTitle } from '@/common/hooks'
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
import { InfoBox } from '@/components/InfoBox'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
import { PaginatedSortableTable } from '@/components/SortableTable/PaginatedSortableTable'
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useGetSemestersQuery } from '@/redux/semesters'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { reformatDate } from '@/util/timeAndDate'
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
const getColumns = (
getTextIn,
namesVisible,
semesterEnrollmentsVisible,
studyTrackVisible,
allSemestersMap,
bachelorStudentsAreDisplayed,
semesterEnrollmentFunctions
) => {
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = semesterEnrollmentFunctions
const currentSemesterCode = getCurrentSemester(allSemestersMap)?.semestercode
const semestersToInclude = range(
isFall(currentSemesterCode)
? currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 2
: currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 1,
isFall(currentSemesterCode) ? currentSemesterCode + 2 : currentSemesterCode + 1
)
return [
{
key: 'studentNumber',
title: 'Student number',
getRowVal: row => row.student.studentNumber,
getRowContent: row => <StudentInfoItem showSisuLink student={row.student} />,
filterable: false,
},
{
key: 'name',
title: 'Name',
getRowVal: row => row.student.name,
filterable: false,
displayColumn: namesVisible,
},
{
key: 'phoneNumber',
title: 'Phone number',
getRowVal: row => row.student.phoneNumber,
displayColumn: false,
},
{
key: 'email',
title: 'Email',
getRowVal: row => row.student.email,
displayColumn: false,
},
{
key: 'secondaryEmail',
title: 'Secondary email',
getRowVal: row => row.student.secondaryEmail,
displayColumn: false,
},
{
key: 'programme',
title: 'Programme',
getRowVal: row => getTextIn(row.programme.name),
filterable: false,
},
{
key: 'studytrack',
title: 'Study track',
getRowVal: row => getTextIn(row.programme.studyTrack),
displayColumn: studyTrackVisible,
},
{
key: 'startOfStudyright',
title: 'Start of\nstudy right',
getRowVal: row => row.studyright.startDate,
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
filterType: 'date',
},
{
key: 'startedInProgramme',
title: 'Started in programme',
getRowVal: row => row.programme.startedAt,
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
filterType: 'date',
displayColumn: !bachelorStudentsAreDisplayed,
helpText:
"For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme).",
},
{
key: 'credits',
title: 'Completed credits',
children: [
{
key: 'creditsHops',
title: 'HOPS',
getRowVal: row => row.credits.hops,
filterType: 'range',
helpText: "The credits earned from courses in the student's primary study plan",
},
{
key: 'creditsTotal',
title: 'Total',
getRowVal: row => row.credits.all,
filterType: 'range',
helpText: 'The total number of credits the student has earned at the university',
},
],
},
{
key: 'isBaMa',
title: 'Continued\nfrom\nbachelor’s',
getRowVal: row => (row.studyright.isBaMa ? 'Continued from bachelor’s' : 'Not continued from bachelor’s'),
getRowContent: row => (row.studyright.isBaMa ? <Icon color="green" name="check" /> : null),
getRowExportVal: row => (row.studyright.isBaMa ? '1' : null),
cellProps: () => ({ style: { textAlign: 'center' } }),
displayColumn: !bachelorStudentsAreDisplayed,
helpText: 'Indicates whether the student has continued their studies from a bachelor’s degree',
},
{
key: 'curriculumPeriod',
title: 'Curriculum\nperiod',
getRowVal: row => row.curriculumPeriod,
helpText: 'The curriculum period the student has chosen for their primary study plan',
},
{
key: 'semesterEnrollments',
title: 'Semesters\npresent',
filterType: 'range',
displayColumn: semesterEnrollmentsVisible,
export: false,
getRowContent: row => getSemesterEnrollmentsContent(row.student, row.studyright),
getRowVal: row => getSemesterEnrollmentsVal(row.student, row.studyright),
},
{
key: 'semestersAbsent',
title: 'Semesters\nabsent',
getRowVal: row => row.numberOfAbsentSemesters,
helpText:
'The number of semesters the student has been absent (both statutory and non-statutory absences) during their study right',
},
{
key: 'semesterEnrollmentsForExcel',
title: 'Enrollment status',
displayColumn: false,
children: semestersToInclude.map(semester => ({
key: `${semester}`,
title: getTextIn(allSemestersMap[`${semester}`]?.name),
displayColumn: false,
getRowVal: student => {
if (!student.studyright.semesterEnrollments) {
return 'Not enrolled'
}
const enrollment = student.studyright.semesterEnrollments.find(enrollment => enrollment.semester === semester)
return getEnrollmentTypeTextForExcel(enrollment?.type, enrollment?.statutoryAbsence)
},
})),
},
{
key: 'thesisCompleted',
title: 'Thesis\ncompleted',
getRowVal: row => (row.thesisInfo ? 'Thesis completed' : 'Thesis not completed'),
getRowContent: row => (row.thesisInfo ? <Icon color="green" name="check" /> : null),
getRowExportVal: row => (row.thesisInfo ? '1' : null),
cellProps: row =>
row.thesisInfo
? {
style: { textAlign: 'center' },
title: [
`Attainment date: ${reformatDate(row.thesisInfo.attainmentDate, ISO_DATE_FORMAT)}`,
`Course code: ${row.thesisInfo.courseCode}`,
`Grade: ${row.thesisInfo.grade}`,
].join('\n'),
}
: {},
helpText:
'The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark.',
},
{
key: 'latestAttainmentDates',
title: 'Latest attainment date',
children: [
{
key: 'latestAttainmentHops',
title: 'HOPS',
getRowVal: row => reformatDate(row.attainmentDates.latestHops, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student last completed a course in their primary study plan',
},
{
key: 'latestAttainmentTotal',
title: 'Total',
getRowVal: row => reformatDate(row.attainmentDates.latestTotal, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student last completed any course at the university',
},
],
},
{
key: 'earliestAttainmentDates',
title: 'Earliest\nattainment date',
children: [
{
key: 'earliestAttainmentHops',
title: 'HOPS',
getRowVal: row => reformatDate(row.attainmentDates.earliestHops, ISO_DATE_FORMAT),
filterType: 'date',
helpText: 'The date when the student first completed a course in their primary study plan',
},
],
},
]
}
export const CloseToGraduation = () => {
useTitle('Students close to graduation')
const { data: students = {}, isError, isLoading } = useGetStudentsCloseToGraduationQuery()
const { data: faculties = [] } = useGetFacultiesQuery()
const { data: semesterData = [] } = useGetSemestersQuery()
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
const { getTextIn } = useLanguage()
const { visible: namesVisible } = useStudentNameVisibility()
const [chosenFaculties, setChosenFaculties] = useState([])
const [chosenProgrammes, setChosenProgrammes] = useState([])
const [activeTabIndex, setActiveTabIndex] = useState(0)
const [rowCount, setRowCount] = useState(0)
const [semesterEnrollmentsVisible, setSemesterEnrollmentsVisible] = useState(false)
const allSemesters = Object.entries(semesterData?.semesters || {}).map(item => item[1])
const allSemestersMap = allSemesters.reduce((obj, cur, index) => {
obj[index + 1] = cur
return obj
}, {})
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
getTextIn,
allSemesters,
allSemestersMap,
filteredStudents: students,
year: `${new Date().getFullYear() - Math.floor(NUMBER_OF_DISPLAYED_SEMESTERS / 2)}`,
})
const { bachelor = [], masterAndLicentiate = [] } = students
const bachelorStudentsAreDisplayed = activeTabIndex === 0
const displayedStudents = bachelorStudentsAreDisplayed ? bachelor : masterAndLicentiate
const handleDisplayedDataChange = students => setRowCount(students.length)
const filteredStudents = displayedStudents.filter(s => {
if (chosenProgrammes.length > 0) return chosenProgrammes.includes(s.programme.code)
return chosenFaculties.length === 0 || chosenFaculties.includes(s.programme.code.slice(1, 4))
})
const studyTrackVisible =
chosenProgrammes.length === 1 && filteredStudents.some(student => student.programme.studyTrack != null)
const columns = getColumns(
getTextIn,
namesVisible,
semesterEnrollmentsVisible,
studyTrackVisible,
allSemestersMap,
bachelorStudentsAreDisplayed,
{
getSemesterEnrollmentsContent,
getSemesterEnrollmentsVal,
}
)
const onTabChange = (_event, { activeIndex }) => {
setActiveTabIndex(activeIndex)
setChosenFaculties([])
setChosenProgrammes([])
}
const renderContent = () => {
if (isError) {
return (
<Message content="Please try reloading the page." header="There was an error" icon="ban" negative size="big" />
)
}
if (isLoading) return <Loader active inline="centered" />
const facultyOptions = faculties.map(faculty => ({
key: faculty.code,
text: getTextIn(faculty.name),
value: faculty.code,
description: faculty.code,
}))
const programmeOptions = studyProgrammes
.filter(
programme =>
!programme.value.includes('+') && programme.value.startsWith(bachelorStudentsAreDisplayed ? 'KH' : 'MH')
)
.filter(programme => (chosenFaculties.length > 0 ? chosenFaculties.includes(programme.value.slice(1, 4)) : true))
.sort(createLocaleComparator('text'))
return (
<Tab.Pane>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<InfoBox content={closeToGraduationToolTips} />
<Form style={{ width: '100%', marginBottom: '1em' }}>
<Form.Field>
<label>Faculties</label>
<Dropdown
multiple
onChange={(_, { value }) => {
setChosenFaculties([...value])
setChosenProgrammes(chosenProgrammes.filter(p => value.includes(p.slice(1, 4))))
}}
options={facultyOptions}
placeholder="Choose faculties"
search
selection
value={chosenFaculties}
/>
</Form.Field>
<Form.Field>
<label>Degree programmes</label>
<Dropdown
multiple
onChange={(_, { value }) => setChosenProgrammes([...value])}
options={programmeOptions}
placeholder="Choose degree programmes"
search
selection
value={chosenProgrammes}
/>
</Form.Field>
</Form>
<Message info style={{ width: '100%' }}>
{`Last updated: ${reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)}`}
</Message>
<PaginatedSortableTable
columns={columns}
data={filteredStudents}
defaultSort={['programme', 'asc']}
featureName="students_close_to_graduation"
handleDisplayedDataChange={handleDisplayedDataChange}
rowCount={rowCount}
rowsPerPage={200}
title={`Students close to graduation (${rowCount} out of ${displayedStudents.length} students selected)`}
/>
</div>
</Tab.Pane>
)
}
const panes = [
{
menuItem: "Bachelor's programmes",
render: renderContent,
},
{
menuItem: "Master's and licentiate's programmes",
render: renderContent,
},
]
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Divider horizontal style={{ margin: '3em' }}>
Students close to graduation
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '2em',
padding: '2em',
gap: '2em',
}}
>
<StudentNameVisibilityToggle style={{ marginTop: '0', marginBottom: '0' }} />
<Radio
checked={semesterEnrollmentsVisible}
label="Show semester enrollments"
onChange={() => setSemesterEnrollmentsVisible(!semesterEnrollmentsVisible)}
toggle
/>
</div>
<Tab activeIndex={activeTabIndex} onTabChange={onTabChange} panes={panes} style={{ maxWidth: '80%' }} />
</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'
@@ -1,233 +0,0 @@
import qs from 'query-string'
import { useEffect, useState } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Button, Form, Loader, Modal, TextArea } from 'semantic-ui-react'
import { SearchHistory } from '@/components/SearchHistory'
import {
useCreateCourseListMutation,
useDeleteCourseListMutation,
useGetSavedCourseListsQuery,
useUpdateCourseListMutation,
} from '@/redux/completedCoursesSearch'
export const CompletedCoursesSearch = ({ setValues }) => {
const location = useLocation()
const history = useHistory()
const [modal, setModal] = useState(false)
const [courseInput, setCourseInput] = useState('')
const [studentInput, setStudentInput] = useState('')
const [name, setName] = useState('')
const courseLists = useGetSavedCourseListsQuery()
const [searchList, setSearches] = useState(null)
const [selectedSearchId, setSelectedSearchId] = useState('')
const isFetchingOrLoading = courseLists.isLoading || courseLists.isFetching
const isError = courseLists.isError || (courseLists.isSuccess && !courseLists.data)
const [updateCourseList, { isLoading: updateIsLoading, data: updatedData }] = useUpdateCourseListMutation()
const [createCourseList, { isLoading: createIsLoading, data: createdData }] = useCreateCourseListMutation()
const [deleteCourseList, { isLoading: deleteIsLoading, data: deletedData }] = useDeleteCourseListMutation()
const parseQueryFromUrl = () => {
const query = qs.parse(location.search)
if (!Array.isArray(query.courseList)) query.courseList = [query.courseList]
if (!Array.isArray(query.studentList)) query.studentList = [query.studentList]
return query
}
useEffect(() => {
if (courseLists.isSuccess && courseLists.data) {
setSearches(courseLists.data)
}
}, [courseLists])
useEffect(() => {
if (updatedData) {
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
setSearches(updatedSearches)
}
}, [updatedData])
useEffect(() => {
if (createdData && !createdData.error) {
const newList = searchList.concat(createdData)
setSearches(newList)
}
}, [createdData])
useEffect(() => {
if (deletedData) {
const filteredSearches = searchList.filter(s => s.id !== deletedData)
setSearches(filteredSearches)
}
}, [deletedData])
useEffect(() => {
if (location.search) {
const query = parseQueryFromUrl()
setValues(query)
}
}, [location.search])
const clearForm = () => {
setCourseInput('')
setStudentInput('')
setSelectedSearchId('')
}
const pushQueryToUrl = query => {
const searchString = qs.stringify(query)
history.push({ search: searchString })
}
const handleClose = () => {
setModal(false)
clearForm()
}
const onDelete = () => {
deleteCourseList({ id: selectedSearchId })
clearForm()
}
const onSave = () => {
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
if (selectedSearchId !== '') {
updateCourseList({ id: selectedSearchId, courseList })
} else {
createCourseList({ courseList, name })
}
}
const onSelectSearch = selectedId => {
if (!selectedId) {
clearForm()
return
}
const { id: selectedSearchId } = selectedId
const selectedSearch = searchList?.find(search => search.id === selectedSearchId)
if (selectedSearch) {
setCourseInput(selectedSearch.courseList.join(', '))
setName(selectedSearch.name)
setSelectedSearchId(selectedSearch.id)
}
}
const onClicker = event => {
event.preventDefault()
const courseList = courseInput
.split(/[\s,]+/)
.map(code => code.trim())
.filter(code => code !== '')
const studentList = studentInput
.split(/[;\s,]+/)
.map(code => code.trim())
.filter(s => s !== '')
.map(s => (s.length === 8 ? `0${s}` : s))
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
return
}
setValues({
studentList,
courseList,
})
pushQueryToUrl({ courseList, studentList })
handleClose()
}
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
return (
<Modal
onClose={handleClose}
open={modal}
size="small"
trigger={
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
Search completed courses of students
</Button>
}
>
<Modal.Content>
<Form>
<h2>Search completed courses of students</h2>
<Form.Field>
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
<TextArea
data-cy="student-no-input"
onChange={event => setStudentInput(event.target.value)}
placeholder="012345678, 12345678"
value={studentInput}
/>
</Form.Field>
<Form.Field>
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
<TextArea
data-cy="course-list-input"
onChange={event => setCourseInput(event.target.value)}
placeholder="TKT12345, PSYK-123"
value={courseInput}
/>
</Form.Field>
<Form.Field>
<Form.Field data-cy="search-name">
<em> Insert name for this course list if you wish to save it </em>
<Form.Input
disabled={selectedSearchId !== ''}
onChange={event => setName(event.target.value)}
placeholder="name"
value={name}
/>
</Form.Field>
</Form.Field>
<SearchHistory
handleSearch={onSelectSearch}
header="Saved courselists"
items={
searchList
? searchList.map(list => ({
...list,
text: list.name,
timestamp: new Date(list.updatedAt),
params: { id: list.id },
}))
: []
}
updateItem={() => null}
/>
<Modal.Actions>
<Button
content="Save"
data-cy="save-courselist"
disabled={!name || updateIsLoading || createIsLoading}
floated="left"
icon="save"
loading={updateIsLoading || createIsLoading}
onClick={onSave}
/>
<Button
content="Delete"
disabled={!selectedSearchId || deleteIsLoading}
floated="left"
icon="trash"
negative
onClick={onDelete}
/>
<Button onClick={handleClose}>Cancel</Button>
<Button data-cy="completed-courses-search-button" onClick={event => onClicker(event)} positive>
Search
</Button>
</Modal.Actions>
</Form>
</Modal.Content>
</Modal>
)
}

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