Compare commits

...
881 Commits
Author SHA1 Message Date
Santeri Rajamäki a5af4ffcae [CourseStats] Partially rewrite controlflow logic 2025-12-17 17:06:19 +02:00
Santeri Rajamäki cab573eebb [Backend] Fix invalid import
It seems that types are never actually resolved
2025-12-17 15:40:56 +02:00
Santeri Rajamäki 5d3c5c99c5 [CourseStats] Simplify alternatives type 2025-12-17 15:27:24 +02:00
Santeri Rajamäki 577de97741 [Filters][Date] Change filter title 2025-12-16 10:21:01 +02:00
Santeri Rajamäki 60d95e45d2 [Filters][Date] Change course credits to course participation 2025-12-16 10:08:19 +02:00
Santeri Rajamäki 34ae6f279b [DegreeProgramme] Fix visibility chip for module courses 2025-12-12 17:08:04 +02:00
joonas-a 1c91554a3b [Frontend] Theme refresh
Increased contrast and consistency

Also moved unihow export to ooditable toolbar in custom population
2025-12-12 16:54:18 +02:00
Santeri Rajamäki e53c0c2de4 [CourseStats] Move Redux hook to useState 2025-12-12 14:25:41 +02:00
Santeri Rajamäki 25deee6016 [CourseStats] Use full data in summary tab 2025-12-12 14:02:15 +02:00
Santeri Rajamäki f8cc05afda [Frontend][Highchart] Remove unused (due to data drawn) start date from CreditAccumulation 2025-12-12 14:02:15 +02:00
joonas-a 9b46c9f43f [Courses of population] Use new number selector
Fixes debounce etc
2025-12-12 13:38:47 +02:00
Santeri Rajamäki 654a2a6604 [E2E][CourseStats] Use correct column for grade matching 2025-12-12 10:00:27 +02:00
Santeri Rajamäki 27a9ac8225 [Frontend] Add retry on error card to Population statistics 2025-12-12 09:57:54 +02:00
Santeri Rajamäki b8d93af641 [Filters] Reword date filter mutate function 2025-12-12 09:23:48 +02:00
joonas-a 33ee9d142d [Course pop] New col to general tab: associated programme
Same programme that is used in computing the programme distribution tab
2025-12-11 18:07:14 +02:00
joonas-a 25ece19e36 [Course pop] lang dist: rephrase error text 2025-12-11 18:07:14 +02:00
Santeri Rajamäki 39ac7a2665 [Frontend] Introduce mutation as a standalone method in filters 2025-12-11 16:20:35 +02:00
joonas-a fb35bc0c2b [Course search] Show no results if no results 2025-12-11 15:32:35 +02:00
joonas-a ce08567bb4 [Course search] make multisearch notice fit in with rest of the app 2025-12-11 11:52:23 +02:00
joonas-a 89f08dabc5 [Course search] remove arbitrary limit in multisearch
limit of 99999 was nothing but clutter
2025-12-11 11:31:51 +02:00
Santeri Rajamäki d6f414dc71 Change comment to override GH issue 2025-12-10 21:46:02 +02:00
Santeri Rajamäki d9a8a87c2f [Frontend] Make Class statistics Students ellipsed spans respect set maxWidth 2025-12-10 20:06:46 +02:00
Santeri Rajamäki 2bab5c6ef0 [DegreeCourses] Use course.code instead of random id 2025-12-10 19:52:31 +02:00
Santeri Rajamäki 8cf9eae693 [Frontend] Make Class statistics Students study track respect set maxWidth 2025-12-10 19:39:59 +02:00
Santeri Rajamäki 535cd08593 [Frontend] Force courses of class percentages toFixed(2) 2025-12-10 19:32:57 +02:00
Santeri Rajamäki f3fd84598a [Frontend] Fix course filter toggle texts 2025-12-10 19:30:14 +02:00
Santeri Rajamäki ceb813636a [Backend] Simplify getCoursesByNameAndOrCode function 2025-12-10 19:13:26 +02:00
joonas-a c51f90ab1a Hotfix LSP powered file path 2025-12-10 19:12:58 +02:00
Santeri Rajamäki 12a0a38ce0 Hotfix 2025-12-10 19:05:01 +02:00
Santeri Rajamäki ef25eeae40 [E2E] Course statistics search form should use main course code for selection 2025-12-10 19:05:01 +02:00
joonas-a 70b10480a4 [Course search] fix substitutions fetched by not-main-course sometimes 2025-12-10 19:03:02 +02:00
joonas-a 11bfd0b8c5 [Course search] Add switch to include special courses
e.g. courses created from credit transfer
2025-12-10 18:25:14 +02:00
Santeri Rajamäki a45ec5537e [Backend] Fix population array to set conversion 2025-12-10 18:03:00 +02:00
Santeri Rajamäki 165fff0163 [Backend] Rework population obfuscation function 2025-12-10 18:02:59 +02:00
Santeri Rajamäki a6df2e8d50 [Filters] Retype course filter cards 2025-12-10 18:02:59 +02:00
Santeri Rajamäki bfeb3ef9b1 [Frontend] Extend FilteredCourse type 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 4fdbba9fe1 [Filters] Remove course filter card population check 2025-12-10 18:02:59 +02:00
Santeri Rajamäki fe52b06696 [Backend] Remove obfuscation from course population data 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 54e60279f7 [Shared] Remove unused module courseData 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 2ef35ced6b [Meta] Rework class population data 2025-12-10 18:02:59 +02:00
Santeri Rajamäki 386290288f [Backend] Remove obfuscation from class population query 2025-12-10 18:02:59 +02:00
joonas-a b1d33dc64c [Course search] Fix sort order 2025-12-10 17:38:38 +02:00
joonas-a 7d4c8c99bf [Course search] Dont show special courses by default
Courses e.g. from result of hyväksilukeminen with random a-z - 0-9
codes, and other courses with no "main_course_code" in db
2025-12-10 17:00:36 +02:00
joonas-a ed7e03169a [Course stats] add/correct typing 2025-12-05 15:18:37 +02:00
joonas-a eaa9a17a49 [Lint] silence warning in case component still needed 2025-12-05 13:55:43 +02:00
joonas-a 6c463b6b3b [Cleanup] Delete unused tooltips 2025-12-05 13:41:36 +02:00
joonas-a 79c04ba2a6 [Course stats] style + tsx credit gains 2025-12-05 13:41:04 +02:00
joonas-a b0c4d8bb8c [Frontend] Add dummy facultyCode to NO_PROGRAMME 2025-12-05 13:40:18 +02:00
joonas-a cf0796bb88 [Course stats] Finish courses of population styling/debounce 2025-12-04 20:24:25 +02:00
joonas-a 9a23799972 [Frontend] finish StudentAmountLimiter component 2025-12-04 20:15:18 +02:00
joonas-a 010829b96c [Frontend] New component for numerical TextField
Has internal state and works with debounce
2025-12-04 20:05:15 +02:00
Santeri Rajamäki b921bc0891 [Populations] Add secondary email to other filterview student > general tab excels 2025-12-02 10:14:01 +02:00
Joonas AhovalliandGitHub aa6ce4ab28 Remove SortableTable documentation link
Removed link to SortableTable documentation from README.
2025-12-02 09:55:35 +02:00
joonas-a b5b211e750 [Frontend] Use same old/new programme filtering logic everywhere 2025-12-01 20:18:45 +02:00
joonas-a c114121a7a [General tab] filter out inactive study rights from primary programme column
This is to match the behaviour of previously changed programme
distribution panel
2025-12-01 20:00:31 +02:00
joonas-a 31eb9b2101 [Backend] Make student obfuscation fn return dummy studyplans array 2025-12-01 17:15:16 +02:00
Santeri Rajamäki 55c1a5f93b [Backend] Rename isOpenUniCourseCode to getOpenUniCourseCode to reflect what the function is actually used for 2025-12-01 17:12:06 +02:00
Santeri Rajamäki 2311a9c027 [E2E][SGG] Replace mirrored state with same the label text 2025-12-01 16:56:01 +02:00
Santeri Rajamäki 62b7864ffe [E2E][SGG] Fix contains dated field 2025-12-01 16:45:36 +02:00
Santeri Rajamäki f1000a8f25 [E2E] Readd nav-bar cypress tag to ToolBar 2025-12-01 16:41:58 +02:00
Santeri Rajamäki 4e566827f7 [Students] Fix backend crash when return value is null instead of valid value 2025-12-01 15:36:32 +02:00
Santeri Rajamäki 56f5f730c4 [CustomPopulation] Add secondary email to custom population excel 2025-12-01 15:29:20 +02:00
Santeri Rajamäki 556999614c [PopulationStatistics] Add secondary email column index 2025-12-01 15:29:20 +02:00
joonas-a 1c4e3721c6 [Custom population] Update distribution infobox accordingly 2025-11-28 16:36:51 +02:00
joonas-a 7032902c75 [Lint] Alphabetical ordering of props (???) 2025-11-28 16:21:08 +02:00
joonas-a e3928ebd5f [Programme distribution] remake component in custom/course population views 2025-11-28 16:15:14 +02:00
joonas-a 917931378a [Meta] Delete unused css file 2025-11-26 13:19:24 +02:00
joonas-a ff93d54470 [Course pop] Add missing margin 2025-11-25 17:47:41 +02:00
Santeri Rajamäki 402ee672a1 [StatisticsOf] Readd studyright_id to enrollments 2025-11-25 17:46:22 +02:00
joonas-a d5fc584d93 [Frontend] New component StyledCell to use with StyledTable 2025-11-25 16:46:31 +02:00
joonas-a 11a390128f [Course stats] Revisit language distribution 2025-11-25 16:08:19 +02:00
joonas-a f80ef6fb40 [Course stats] Reword tooltip 2025-11-25 16:08:19 +02:00
joonas-a 78e31f0a3e [Course stats] Revisit grade distribution 2025-11-25 16:08:19 +02:00
Santeri Rajamäki 651b3b5c0d [Filters] Clarify studyright status filtering 2025-11-25 16:07:24 +02:00
joonas-a 66e666c99a [Courses of class] UI tweaks 2025-11-25 15:29:37 +02:00
Santeri Rajamäki 464cdff246 [META] Fix lint errors 2025-11-25 15:26:43 +02:00
Santeri Rajamäki 58c35f21b0 [Students] Fix falsy query skip logic 2025-11-25 15:26:43 +02:00
Santeri Rajamäki ae1c7765a1 [StatisticOf] Fix mock date 2025-11-25 15:26:43 +02:00
joonas-a 59e2258da3 [SGG] Make credit cutoff button a switch 2025-11-25 14:34:06 +02:00
joonas-a 5f8dc44958 [Frontend] Remake toggleWithTooltip component 2025-11-25 14:25:12 +02:00
joonas-a c8b4586ea1 [Class stats] Credit accumulation panel betterments 2025-11-25 13:49:21 +02:00
joonas-a b04859639f [Panelview] Make more consistent 2025-11-25 13:25:26 +02:00
joonas-a 8fba294dd8 [Progress tab] Add student name toggle 2025-11-21 18:41:05 +02:00
joonas-a 714f3bc953 [Tags tab] center spinner + add name toggle 2025-11-21 18:39:31 +02:00
joonas-a 7247049564 [Class stats] Move table actions to OT toolbar 2025-11-21 18:21:15 +02:00
Santeri Rajamäki ff6646ebb4 [Frontend] Remove more wrapping divs 2025-11-21 17:03:10 +02:00
joonas-a d77c735030 [Ooditable] Integrate all excel buttons 2025-11-21 16:24:24 +02:00
joonas-a aaefbfa9fe [Language center view] revisit styles 2025-11-21 16:24:24 +02:00
joonas-a 7ec487412f [Ooditable] Integrate excel export 2025-11-21 16:24:24 +02:00
joonas-a f7c84ca06f [Ooditable] Delete unused css 2025-11-21 16:24:24 +02:00
joonas-a bc46a18151 [Ooditable] Add missing docstring 2025-11-21 16:24:24 +02:00
Santeri Rajamäki fcd8eb9284 [Frontend] Use CSS variables in themes 2025-11-21 16:19:40 +02:00
Santeri Rajamäki d0c52f301f [Frontend] Convert Container components to PageLayout and fix erroneous trees 2025-11-21 16:18:45 +02:00
Santeri Rajamäki a606c21cd3 [Frontend] Separate Layout component from App state 2025-11-21 16:10:50 +02:00
Santeri Rajamäki dc496618f3 [Backend] Update util types 2025-11-21 14:48:02 +02:00
Santeri Rajamäki 099e60b617 [Backend] Update student routing types 2025-11-21 14:37:49 +02:00
Santeri Rajamäki c39fdcd77a [Backend] Update course routing types 2025-11-21 14:36:50 +02:00
Santeri Rajamäki 907e24e26e [Shared] Add uniq util function 2025-11-21 13:38:43 +02:00
Santeri Rajamäki 664d8fb83a [Footer] Make DOM shallower and adapt design to NavBar 2025-11-21 11:01:38 +02:00
Santeri Rajamäki 3618d318eb [NavBar] Make DOM shallower 2025-11-21 10:38:52 +02:00
joonas-a cc09af092f [Backend] Remove benchmarking console.logs 2025-11-20 14:47:28 +02:00
Santeri Rajamäki 48a77d2b12 [META] Run install:local 2025-11-18 13:30:21 +02:00
Santeri Rajamäki 99a598e773 [META] Update node requirement to version 24 and update package-locks 2025-11-18 13:27:31 +02:00
Santeri Rajamäki c9455d2fde [Frontend] Make population course amount limiter consistent with debounced state 2025-11-18 13:04:09 +02:00
Santeri Rajamäki a343b2460a [Frontend] Fix :first-child rendering error 2025-11-18 12:37:45 +02:00
Santeri Rajamäki 7ca612364b [Backend] Remove unused type import
Validator types are used with sequelize-typescript, but they are imported at least by sequelize.
No explicit import is needed.
2025-11-18 12:17:24 +02:00
Santeri Rajamäki 6bed41dd9e [Frontend] Convert population roots to typescript 2025-11-18 12:06:27 +02:00
joonas-a 392d5ea3b0 [Frontpage] Remove mention of service break 2025-11-18 09:04:43 +02:00
Santeri Rajamäki 537ab19fb6 [CoursePopulation] Use PageTitle title correctly 2025-11-14 15:56:40 +02:00
Santeri Rajamäki 74b566f574 [Backend] Rework population shared maps 2025-11-14 15:31:30 +02:00
Santeri Rajamäki 509fdeadfa [Backend] Rewrite parts of uncached user login 2025-11-14 14:57:57 +02:00
Santeri Rajamäki 1f13389954 [Frontend] Remove unused SegmentContainer 2025-11-13 17:50:00 +02:00
Santeri Rajamäki e699139f4b [Frontend] Adapt FilterViews to use PageTitle and PageLayout 2025-11-13 17:22:50 +02:00
Santeri Rajamäki 9f9786a3bb [E2E] Fix Course of Class expansion tests 2025-11-13 14:55:31 +02:00
Santeri Rajamäki 7c79d23a47 [Frontend] Replace currently invalid font-weights 2025-11-13 14:36:23 +02:00
Santeri Rajamäki 85eb3c9634 [Frontend] Add Roboto 500 to the project
Font-weight 500 is used by MUI for weight "Medium"
2025-11-13 14:30:20 +02:00
Santeri Rajamäki 25f7f46a36 [Frontend] Replace default Roboto import with the correct weighted ones 2025-11-13 14:10:11 +02:00
Santeri Rajamäki 569e411f7c [DOCS] Remove references to Semantic UI 2025-11-13 04:21:33 +02:00
Santeri Rajamäki d7f8024d27 [Frontend] Add explicit Roboto font import
The font was loaded from google servers before.
2025-11-13 04:18:54 +02:00
Santeri Rajamäki ca429a7b68 [Frontend] Remove "New user interface" notification 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 01bc1f11f4 [Frontend] Move FacultyProgress and FacultyProgressTable components from components/material to components/Faculties 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 50aad14a39 [Frontend] Move ProgrammeDropdown component from components/material to components/CourseStatistics 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 76624da6c6 [Frontend] Merge HelpInfoCard component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f7eba6b2d7 [Frontend] Move TableHeaderWithTooltip component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 6aa7762acb [Frontend] Merge CheckIconWithTitle component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 1f61ab7b81 [Frontend] Move DataTable component from components/material to components/StudyProgramme 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5681ef9f97 [Frontend] Move CurriculumPicker component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 7e903456ae [Frontend] Move MRTExcelExport component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ece089b5b6 [Frontend] Move FilterOldProgrammesToggle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 177b2c39a5 [Frontend] Merge RightsNotification component from components/material into the only parent 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f399041163 [Frontend] Move StudentNameVisibilityToggle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 7f238c5ccb [Frontend] Move StudentInfoItem component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ce41a9a3e3 [Frontend] Move MedianTimeBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki a117ce0fd4 [Frontend] Move ProgressBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki fe5418575e [Frontend] Move StackedBarChart component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 08adaed6d7 [Frontend] Move LineGraph component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki fc8d59af72 [Frontend] Move BreakdownBarChart component from components/material to components/GraduationTimes
BreakdownBarChart is moved and renamed as BreakdownBarChartV2 as the file contains intent to merge both BreakdownBarChart files.
2025-11-13 04:18:54 +02:00
Santeri Rajamäki 9e7c84d053 [Frontend] Move TotalsDisclaimer component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 3e69cfae20 [Frontend] Move MockButton component from components/material to components/Users 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 69b9301c17 [Frontend] Move PageTitle component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 4cae8eaac2 [Frontend] Move ProgressBar component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 6ea3f9a922 [Frontend] Move UpdateButton component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ea7d4b1554 [Frontend] Move SearchHistory component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 426658f8b0 [Frontend] Move ExpandableRow component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 72a6778ae5 [Frontend] Move RoleChip component from components/material to components/Users 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 8c2a2d113e [Frontend] Move StyledAccordion component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 27dbf0b575 [Frontend] Move StyledTable component from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 2ff9ae7843 [Frontend] Move loading components from components/material to components/Loading 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ee8a7b016a [Frontend] Move infobox component from components/material to components/InfoBox 2025-11-13 04:18:54 +02:00
Santeri Rajamäki c7831b7f66 [Frontend] Move navigation components from components/material to components/NavigationBar 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5895021a48 [Frontend] Move graduation time components from components/material to components/GraduationTimes 2025-11-13 04:18:54 +02:00
Santeri Rajamäki ced54e7a9b [Frontend] Move section components from components/material to components/Section 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 25cd18e025 [Frontend] Move footer components from components/material to components/Footer 2025-11-13 04:18:54 +02:00
Santeri Rajamäki c08b2cf37c [Frontend] Move status notification components from components/material to components/StatusNotifications 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 2da42ed93e [Frontend] Move error components from components/material to components/ 2025-11-13 04:18:54 +02:00
Santeri Rajamäki f2e7893c60 [Frontend] Move toggle components from components/material to components/common/toggle 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 5b348c37db [Frontend] Move link components from components/material to components/common 2025-11-13 04:18:54 +02:00
Santeri Rajamäki bb6ca028c7 [Frontend] Remove class items from custom.css 2025-11-13 04:18:54 +02:00
Santeri Rajamäki 340d46c739 [Frontend] Remove Semantic UI 2025-11-13 04:18:53 +02:00
Santeri Rajamäki 52f77935fc [Frontend] Remove Semantic UI icon from Highcharts charts 2025-11-13 04:17:50 +02:00
Santeri Rajamäki d35cbc1085 [Frontend] Remove SortableTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 6f17bb002a [CoursesOfPopulation] Context cleanup 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 692e704108 [Frontend] Make filter toggle button small 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 31c45581e0 [Teachers] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki cb3ed86231 [OpenUni] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki de85a38abe [ProgressTab] Convert SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki e28a1e5e6f [CoursesOfClass] Style "when passed" OodiTable export row 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 66bd4be4ed [CoursesOfClass] Convert "when passed" SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 37fbb5cbb3 [CoursesOfClass] Remove on-demand rendering from tabs 2025-11-13 04:17:50 +02:00
Santeri Rajamäki c31fac4419 [CoursesOfClass] Convert pass/fail SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 7760f60ff5 [CoursesOfClass] Convert grades SortableTable to OodiTable 2025-11-13 04:17:50 +02:00
Santeri Rajamäki 7b65539873 [Frontend] Remove unnecessary imported package
Scheduler is a react internal package, never used in the project.
2025-11-12 23:23:31 +02:00
joonas-a fe4bda7ed8 [Backend] Add temp logging to statisticsOf 2025-11-11 18:12:12 +02:00
joonas-a 6ccaec1643 [Backend] Add missing function 2025-11-11 18:03:32 +02:00
joonas-a df01c3b693 [Backend] make class stat req mankeling faster 2025-11-11 17:37:23 +02:00
joonas-a b7235ff4b4 HOTFIX: capital O in OODIkone 2025-11-10 18:10:30 +02:00
joonas-a 84d8a6bfec [Frontpage] Add announcement on service break 2025-11-10 18:10:29 +02:00
joonas-a 24ccb9de58 [E2E / lang center] Fix tests + remove useless test 2025-11-10 17:50:44 +02:00
joonas-a faac3e968e [E2E] Add missing cy selectors 2025-11-10 17:42:22 +02:00
Santeri Rajamäki 3407af8215 [ColorizedCourseTable] Add semester length check to "By semesters" table column memoization 2025-11-10 17:24:50 +02:00
Santeri Rajamäki 94d289194a [ColorizedCourseTable] Move total column in "By semesters" table 2025-11-10 17:17:24 +02:00
Santeri Rajamäki 39c21b261a [ColorizedCourseTable] Fix column pinning in "By faculties" table 2025-11-10 17:01:09 +02:00
Santeri Rajamäki 74ae6f3d46 [ColorizedCourseTable] Move total column in "By faculties" table 2025-11-10 17:00:32 +02:00
Santeri Rajamäki 3f9ce90834 [ColorizedCourseTable] Add totla sorting to "By faculties" table 2025-11-10 16:57:46 +02:00
Santeri Rajamäki b235fa7883 [SortableTable] Remove unused features 2025-11-10 15:40:46 +02:00
joonas-a e2070248be [E2E / completed courses] flakiness reduce 🪄 2025-11-10 15:28:44 +02:00
Santeri Rajamäki 97068be78e [ColorizedCourseTable] Convert "By faculty" table to OodiTable 2025-11-10 15:26:56 +02:00
joonas-a 53dd55f874 [E2E / teachers] Fix tests 2025-11-10 15:13:04 +02:00
joonas-a 3c99a30a8c [E2E / open uni] skip all tests 2025-11-10 15:05:39 +02:00
joonas-a 5fd94fd0d0 [E2E / SGG] Fix tests 2025-11-10 15:02:04 +02:00
joonas-a c36ce33f7f [E2E / class stats] fix tests 2025-11-10 14:55:06 +02:00
joonas-a ff6bcd6658 [E2E / custom population] Fix tests 2025-11-10 14:00:30 +02:00
joonas-a 53e75b6164 [E2E filters] fix tests 2025-11-10 12:43:01 +02:00
joonas-a a65c4d448b [Custom population] facelift + refactor + TS + MUI 2025-11-10 12:18:11 +02:00
joonas-a f9a4d85ce2 [Frontend] Type betterments to class stats views*
*with a twist
2025-11-10 12:18:11 +02:00
Santeri Rajamäki c610a53168 [ColorizedCourseTable] Convert "By semester" table to OodiTable 2025-11-07 13:37:44 +02:00
Santeri Rajamäki ad40e02442 [OodiTable] Add theming for zebra stripes 2025-11-07 13:26:01 +02:00
Santeri Rajamäki 6b6e070deb [ColorizedCourseTable] Update getColor to use simpler logic 2025-11-07 13:13:03 +02:00
Santeri Rajamäki 253f31d7d0 [OodiTable] Add Zebra stripe feature 2025-11-07 13:06:11 +02:00
joonas-a 551775bcec [Course population] Fix broken layout 2025-11-04 15:49:54 +02:00
joonas-a 70e8d6f033 [Class stats] style unification vol 1 2025-11-04 15:44:44 +02:00
Santeri Rajamäki ea505b5d84 [Frontend] Hide ColorizedCoursesTable tabs when only one option is available 2025-11-03 12:17:35 +02:00
Santeri Rajamäki 344a3c00ef [SortableTable] Remove actions entry from README 2025-11-03 12:09:32 +02:00
Santeri Rajamäki b29984f589 [SortableTable] Remove unnecessary features 2025-11-03 12:08:19 +02:00
Santeri Rajamäki 0ad6d6f9a2 [Frontend] Fix validation to spec and move util to shared 2025-11-03 11:47:21 +02:00
Santeri Rajamäki 6bf1d9a590 [Frontend] Remove unused util 2025-11-03 11:36:17 +02:00
Santeri Rajamäki 430fc97917 [Teachers] Convert Sortabletable to MUI table 2025-11-03 11:35:17 +02:00
joonas-a 7ba2d3489e [StyledMessage] Correct prop naming 2025-10-31 17:55:47 +02:00
joonas-a 1e68c959b9 [SGG] Fix redundant loading spinner logic 2025-10-31 17:54:02 +02:00
joonas-a 55b900d1f6 [SGG overview] Migrate to MUI+TS 2025-10-31 17:52:46 +02:00
joonas-a 1ff637ab9d [Types] fix GroupsWithTags type 2025-10-31 13:53:04 +02:00
joonas-a cff045fd3b [SGG] Fix content incorrectly centered in panels 2025-10-30 18:11:43 +02:00
joonas-a 7fce9fd5be [SGG] Delete unused css import 2025-10-30 17:58:09 +02:00
joonas-a a2569d1525 [SGG] Delete unused css file 2025-10-30 17:54:08 +02:00
joonas-a 70e220ba58 [SGG] Delete unused file 2025-10-30 17:52:21 +02:00
joonas-a 9482f58930 [SGG] Move all utils to same file (utils) 2025-10-30 17:51:37 +02:00
joonas-a fbc92b6949 [SGG] Split large file 2025-10-30 17:16:35 +02:00
joonas-a 2b452429c9 [SGG] Refactoring 2025-10-30 17:09:43 +02:00
joonas-a 2eb542a61a [Lint] Fix errors 2025-10-29 11:08:43 +02:00
joonas-a 10ba9153ed [Frontpage] Remove alert on updating data tables 2025-10-28 17:48:53 +02:00
Santeri Rajamäki 4eb3946d4f [CoursePopulation] Remove extra collapsing attribute from grade dist. 2025-10-25 14:01:11 +03:00
santeri0200andGitHub 691233a91e Merge pull request #4905 from UniversityOfHelsinkiCS/renovate/major-github-artifact-actions
Update GitHub Artifact Actions (major)
- Adds support for Node 24
2025-10-25 13:56:15 +03:00
renovate[bot]andGitHub cda44b321c Update GitHub Artifact Actions 2025-10-25 00:34:16 +00:00
joonas-a bdbed16047 [Programme overview/courses] Make pagination choices meaningful 2025-10-24 18:13:41 +03:00
joonas-a ec6dd9d672 [SGG] minimal tsx conversion for DX 2025-10-24 17:43:14 +03:00
joonas-a 95ff21a09b [Types] Add re-export for filterview course type 2025-10-24 17:43:14 +03:00
joonas-a a464d8cd49 [SGG] Index cleanup 2025-10-24 17:43:14 +03:00
joonas-a d97e1757b1 [Credit stats] Remove sgg special treatment 2025-10-24 17:43:14 +03:00
joonas-a e91239c4e4 [AgeStats] Add missing typing 2025-10-24 17:43:14 +03:00
Santeri Rajamäki b0d358db78 [CustomPopulation] Upgrade custom population search to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 22643fa590 [CoursePopulation] Upgraded population courses wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 9c5665c216 [OpenUni] Upgrade custom open uni population result wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 5549202299 [OpenUni] Upgrade custom open uni population wrapper to MUI 2025-10-24 17:02:42 +03:00
Santeri Rajamäki 31004bed07 [Frontend] Upgrade credit accumulation graph wrapper to MUI 2025-10-24 17:02:41 +03:00
Santeri Rajamäki 5cdfea1bc2 [CoursePopulation] Upgrade credit gain table and tabs to MUI 2025-10-24 17:02:41 +03:00
joonas-a cbb17ef00f [Frontend] Add general purpose styled message component 2025-10-24 16:45:56 +03:00
joonas-a a75b3b1d5a [SGG/types] correctly make some fields optional 2025-10-24 16:31:38 +03:00
joonas-a b34f42efd9 [Routes] Type custom population/sgg rtk endpoint 2025-10-24 16:26:15 +03:00
joonas-a 34e2293bc8 [SGG] jsx->tsx preparation 2025-10-24 15:23:48 +03:00
Santeri Rajamäki 71ae263e40 [Frontend] Remove react-datetime 2025-10-24 15:20:45 +03:00
Santeri Rajamäki b87b129766 [OpenUni] Upgrade custom open uni search to MUI 2025-10-24 15:20:45 +03:00
Santeri Rajamäki 76c707c322 [Frontend] Convert DateRangeSelector to use Stack 2025-10-24 15:20:45 +03:00
joonas-a 1d0d9f91bf [Frontend] Move full screen loading spinner to reusable component 2025-10-24 15:01:32 +03:00
Santeri Rajamäki da22043197 [PopulationStudents] Upgrade check student list admin component to MUI 2025-10-24 14:35:15 +03:00
joonas-a db567235a9 [Modules tab] consistent row naming 2025-10-24 14:00:31 +03:00
Santeri Rajamäki 08d42c8efd [Frontend] Upgraded tag population component to MUI 2025-10-24 13:16:08 +03:00
Santeri Rajamäki 53003b2c93 [Frontend] Remove unused useTabChangeAnalytics hook 2025-10-24 12:05:04 +03:00
Santeri Rajamäki 9049160b02 [SGG] Upgrade population students tabs to MUI 2025-10-24 12:04:14 +03:00
Santeri Rajamäki b40ba0846a [SGG] Upgrade SGG overview to MUI 2025-10-24 11:51:22 +03:00
Santeri Rajamäki cae1fe1550 [Frontend] Enable enrollment date selector to work in other views 2025-10-24 11:43:04 +03:00
Santeri Rajamäki 180bea21c1 [Frontend] Upgrade ColorizedCoursesTable selector components to MUI 2025-10-24 09:21:00 +03:00
Santeri Rajamäki f861d55aab [Frontend] Upgrade ColorizedCoursesTable component to MUI 2025-10-24 09:02:55 +03:00
Santeri Rajamäki 9f689d5ec7 [SGG] Upgrade single SGG view to MUI 2025-10-24 08:53:51 +03:00
Santeri Rajamäki 43d67713a6 [CustomPopulation] Upgrade Unihow data export button to MUI 2025-10-23 23:46:38 +03:00
Santeri Rajamäki ff15862aaf [Frontend] Remove unused Semantic UI segment from population courses container 2025-10-23 22:36:28 +03:00
Santeri Rajamäki 2d00381b0d [CustomPopulation] Replace search history component with the MUI version 2025-10-23 22:31:43 +03:00
Santeri Rajamäki 9186ca5b40 [SGG] Remove loading attribute wrapping paper 2025-10-23 22:21:35 +03:00
Santeri Rajamäki 7b43b7bb06 [CustomPopulation] Upgrade index to MUI 2025-10-23 22:15:17 +03:00
Santeri Rajamäki 533b6b17bd [Frontend] Upgrade rights notification to MUI 2025-10-23 16:53:36 +03:00
Santeri Rajamäki 1ffa846243 [SGG] Upgrade population course selector to MUI 2025-10-23 16:47:39 +03:00
Santeri Rajamäki 9f7f037e90 [SGG] Upgrade common to MUI 2025-10-23 16:26:13 +03:00
Santeri Rajamäki 795bae945d [SGG] Upgrade index to Typescript 2025-10-23 16:20:03 +03:00
Santeri Rajamäki c2b0dc46ca [SGG] Upgrade index to MUI 2025-10-23 16:18:52 +03:00
renovate[bot]andGitHub f22fe44f5a Update dependency react-markdown to v10 2025-10-23 12:41:04 +00:00
santeri0200andGitHub 2139286a49 Merge pull request #4901 from UniversityOfHelsinkiCS/renovate/actions-setup-node-6.x
Update actions/setup-node action to v6
2025-10-23 15:36:25 +03:00
Santeri Rajamäki 4e1ad9441a [META] Upgrade vite to 7.1.12
Closes #4903
2025-10-23 15:35:13 +03:00
Santeri Rajamäki 3a021e07a7 [E2E][Teachers] Get the row, not the index of MAT12004 2025-10-23 14:59:52 +03:00
Santeri Rajamäki 8b14c21f3c [Frontend] Add curriculum guard to population details 2025-10-23 14:04:34 +03:00
Santeri Rajamäki 6bbd53ade9 [E2E][Teachers] Update tests to work with MUI components 2025-10-23 13:55:23 +03:00
Santeri Rajamäki e4e20c8e3c [OodiTable] Ignore unused export on feature interface 2025-10-23 13:13:02 +03:00
Santeri Rajamäki 3337192b8a [CoursePopulation] Upgrade course grade distribution table to MUI 2025-10-23 13:10:17 +03:00
Santeri Rajamäki 61ee4475e7 [Frontend] Add loading section wrapper 2025-10-23 12:57:34 +03:00
Santeri Rajamäki 43c3dd2998 [Teachers] Upgraded teacher index to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki de5e23fdc6 [Teachers] Upgraded teacher statistics view to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 355b034a4a [Teachers] Upgraded teacher statistics table to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 2321ab7242 [Teachers] Upgraded teacher details to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki ecc610bf6d [Teachers] Upgraded teacher search tab to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki bef5e39d22 [Teachers] Upgraded teacher leaderboard form to Typescript 2025-10-23 12:54:33 +03:00
Santeri Rajamäki b7d3fbab09 [Teachers] Upgraded teacher leaderboard to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki c0d7538cab [Teachers] Upgrade other wrappers' info boxes to MUI 2025-10-23 12:54:33 +03:00
Santeri Rajamäki 7858845b60 [Teachers] Upgrade leaderboard wrapper to MUI 2025-10-23 12:54:33 +03:00
joonas-a 4c87250206 [Ooditable] use same copy button with notification in all tables 2025-10-21 17:34:59 +03:00
joonas-a 8c52ff707a [General tab] Use better notification when copying student numbers 2025-10-21 17:11:23 +03:00
joonas-a 92d9040260 [General tab] Fix few column alignments 2025-10-21 16:53:47 +03:00
joonas-a 874e75670e [Stylelint] Modify css selector regex 2025-10-21 16:30:50 +03:00
joonas-a 9f60d026ad [Ooditable] Dom optimizations and cleanup 2025-10-21 16:29:54 +03:00
Santeri Rajamäki 0bb2da9137 [Frontend] Fix filter toggle forwardRef error and commit to icon design 2025-10-21 12:33:36 +03:00
Santeri Rajamäki 0977eefbd3 [Frontend] Convert filter toggle to use common filter toggle icon 2025-10-20 22:24:05 +03:00
Santeri Rajamäki 99d2e803b6 [Frontend] Upgrade common filter toggle icon to MUI 2025-10-20 22:23:16 +03:00
Santeri Rajamäki 5a4998ce84 [PopulationStatistics] Upgrade course filter toggle to MUI 2025-10-20 22:22:07 +03:00
Santeri Rajamäki 316c17745d [CustomPopulation] Changed language of search result table to typescript 2025-10-20 21:49:44 +03:00
Santeri Rajamäki bfad75d03e [CustomPopulation] Upgraded search result table to MUI 2025-10-20 21:48:29 +03:00
Santeri Rajamäki c802e3827f [PopulationStatistics] Upgraded Courses of Class to use MUI tabs 2025-10-20 21:26:12 +03:00
Santeri Rajamäki 78f1b2a3e4 [CoursePopulation] Upgrade Language distribution table to MUI 2025-10-20 20:50:52 +03:00
Santeri Rajamäki 6b7b9da7c8 [PopulationStatistics][Tags] Rework Semantic UI Dropdown to MUI 2025-10-20 17:10:37 +03:00
Santeri Rajamäki dfadf975d0 [DegreeProgramme] Make study track selection lazy to remove warnings 2025-10-20 14:04:05 +03:00
Santeri Rajamäki 3809d0e284 [DegreeProgramme] Reorder items to create more dependable render order 2025-10-20 13:59:36 +03:00
Santeri Rajamäki b2338981a9 [Frontend] Remove SisuLinkItem 2025-10-20 13:58:20 +03:00
Santeri Rajamäki be56f8b388 [Frontend] Remove unused modules 2025-10-20 13:26:32 +03:00
Santeri Rajamäki bf4c8fa2fb [Frontend] Apply ForwardRef to Link component
Use https://mui.com/material-ui/integrations/routing/ as guidance.
2025-10-20 13:18:56 +03:00
Santeri Rajamäki d9b8bad314 [PopulationStatistics] Convert SortableTable button to MUI 2025-10-20 12:18:01 +03:00
Santeri Rajamäki 2149cad79c [Teachers] Upgrade frontpage tab feature to MUI 2025-10-20 12:18:00 +03:00
joonas-a 718393cb25 [Material UI] Courses tab 2025-10-16 14:48:59 +03:00
joonas-a bb56f3e801 [Ooditable] make export able to parse objects 2025-10-15 13:32:43 +03:00
joonas-a 08a3f50a5a [Ooditable] streamline exports accessorkey handling 2025-10-15 12:59:09 +03:00
joonas-a e04315e72b [Ooditable] consistent coloring from theme 2025-10-15 12:29:05 +03:00
joonas-a 56a8463871 [Ooditable] better copy button 2025-10-15 12:22:18 +03:00
joonas-a 3895ca5143 [Ooditable] Hide horizontal overflow in export view 2025-10-15 12:17:02 +03:00
Santeri Rajamäki dbb89ad2d8 [E2E][PopulationStatistics] Update searched icon 2025-10-14 16:52:35 +03:00
Santeri Rajamäki 422f6464fc [E2E][LanguageCenter] Remove "hasLanded" inline helper 2025-10-14 16:47:55 +03:00
Santeri Rajamäki 84cd9956ff [CustomPopulation] Use existing MUI percentage bar 2025-10-14 16:41:12 +03:00
Santeri Rajamäki 3473d8e546 [SGG] Center SGG list link icons 2025-10-14 16:33:33 +03:00
Santeri Rajamäki d5e44d4df4 [Frontend] Abstract react-router Link behind wrapper component 2025-10-14 16:26:17 +03:00
Santeri Rajamäki 843f9d9716 [Frontend] Add Link wrapper for MUI/react-router implementation 2025-10-14 16:26:17 +03:00
joonas-a 521ec7e302 [Class stats] Center no students found info box 2025-10-14 16:03:43 +03:00
Santeri Rajamäki 7217f9666d [LanguageCenterView] Convert info message to common MUI info button 2025-10-14 15:20:26 +03:00
Santeri Rajamäki a40cd6a38c [CoursePopulation] Upgrade population course tab feature to MUI 2025-10-14 14:51:04 +03:00
Santeri Rajamäki 8258fa3cf8 [PopulationStatistics] Remove CoursesTab container to Semantic UI contents 2025-10-14 14:18:54 +03:00
Santeri Rajamäki 65b0c83778 [PopulationStatistics] Upgraded progress tab skeleton to MUI 2025-10-14 14:04:22 +03:00
Santeri Rajamäki 7eb5511a77 [PopulationStatistics] Upgraded progress tab links to MUI version 2025-10-14 13:53:36 +03:00
Santeri Rajamäki 378df9cfcd [Frontend] Upgraded SISU link component to MUI 2025-10-14 13:36:36 +03:00
Santeri Rajamäki 0ba959b99e [Frontend] Upgrade dimmer to MUI 2025-10-14 13:16:17 +03:00
Santeri Rajamäki 609fd2e3bc [Frontend] Update StyledTable styling 2025-10-14 12:43:27 +03:00
Santeri Rajamäki 7a43f3f522 [PopulationStatistics] Upgraded student tags table to MUI 2025-10-14 12:41:09 +03:00
renovate[bot]andGitHub 61959c25b9 Update actions/setup-node action to v6 2025-10-14 04:59:32 +00:00
Santeri Rajamäki 5146ff9c48 [Charts] Upgrade highcharts tooltips to MUI
This does not include icons as MUI for some reason does not want to behave with react element and string parsing.
2025-10-13 19:19:07 +03:00
Santeri Rajamäki 481acc3f27 [Frontend] Upgrade progress bar to MUI 2025-10-13 17:56:26 +03:00
Santeri Rajamäki df7761e6e7 [Frontend] Upgrade infobox to MUI 2025-10-13 17:11:40 +03:00
Santeri Rajamäki 7c2c6478b5 [PopulationStatistics] Upgraded student progress icons to MUI 2025-10-13 16:00:37 +03:00
Santeri Rajamäki 284a24c937 [PopulationStatistics] Upgraded student courses icons to MUI 2025-10-13 15:20:15 +03:00
Santeri Rajamäki eedfe5a971 [PopulationStatistics] Upgrade courses of class course management modal to MUI 2025-10-13 15:06:39 +03:00
Santeri Rajamäki e958ba7a5e [PopulationStatistics] Upgrade courses of class course management buttons to MUI 2025-10-13 15:06:33 +03:00
Santeri Rajamäki f5ce505a34 [StudyProgramme] Make CI happy again 2025-10-13 13:13:30 +03:00
Santeri Rajamäki d596db6f15 [StudyProgramme] Reorder hooks for more reliable execute order 2025-10-10 17:47:22 +03:00
Santeri Rajamäki a8da08bd82 [StudyProgramme] Reformat programmeId split 2025-10-10 17:43:27 +03:00
Santeri Rajamäki f4cf3f2c71 [StudyProgramme] Replace excess cypress tag with key 2025-10-10 17:30:47 +03:00
Santeri Rajamäki d2190579fe [SGG] Credit statistics clean up left from query rework 2025-10-10 17:18:59 +03:00
Santeri Rajamäki 3805ed1ffe [Redux] Replace actionHistory reducer with actionHistory middleware 2025-10-10 16:33:12 +03:00
Santeri Rajamäki 14d24dd320 [Redux] skip focus/unfocus state changes in action history item 2025-10-10 15:01:27 +03:00
santeri0200andGitHub da54f92261 Merge pull request #4898 from UniversityOfHelsinkiCS/OOD-97-hide-graduation-title-in-fd
[Programmes] Hide graduation title when not default service provider
2025-10-09 13:52:33 +03:00
Matti Räsänen 248627d809 Moved service provider check to top 2025-10-07 12:51:18 +03:00
Matti Räsänen bc177a124c [Programmes] Hide graduation title when not default service provider 2025-10-07 11:45:16 +03:00
Santeri Rajamäki b5592f4a6c Resolve #4896 2025-10-03 15:36:07 +03:00
Santeri Rajamäki 31c2ecfbd5 [SENTRY] Update sentry config in production.yaml 2025-10-03 15:00:33 +03:00
Santeri Rajamäki 57dca3f6c4 [Frontend][API] Extract base headers to be one time computed value 2025-10-03 14:42:12 +03:00
Santeri Rajamäki 501604ee7e [REDUX] Convert handleRequest callApi to then..catch instead of blocking await after action chain completes 2025-10-03 14:36:55 +03:00
Santeri Rajamäki 257d2c313c Stop stalling semestercodes from crashing frontend
Instead if semestercode request fails crash deliberately to show user something actually went wrong.
2025-10-03 13:01:29 +03:00
Santeri Rajamäki 3f51d6c073 Fixed typo in SGG prettifyTagName 2025-10-01 17:03:53 +03:00
Santeri Rajamäki fe5269f0be Completed courses of students now respects user specified course ordering 2025-10-01 16:22:01 +03:00
joonas-a 1b8cc1c69f [Frontend] Add types a bit 2025-09-30 16:27:41 +03:00
joonas-a fca3c11191 [Backend] assert type where otherwise lost during mankelointi 2025-09-30 16:05:05 +03:00
joonas-a b2f0976e14 [Frontend/FilterView] Type filteredCourses 2025-09-30 15:51:00 +03:00
Santeri Rajamäki 07b8b813ce Fix SGG obfuscated formatter now shows "degree programme" instead of "study programme" 2025-09-30 12:33:18 +03:00
Santeri Rajamäki 0c45f7f480 Completed courses of students now respects course ordering 2025-09-30 12:21:24 +03:00
Santeri Rajamäki 29112f11e4 [META] Replace "study programme" with "degree programme"
Closes #4751
2025-09-30 11:59:10 +03:00
Santeri Rajamäki 817eff0490 [Filters] enrollmentStatus now filters out cancelled studyrights 2025-09-30 11:40:01 +03:00
joonas-a f8c665ca5e [Date filter] fix needing to click twice 2025-09-26 14:45:37 +03:00
joonas-a 5abac85e81 [Filters] Make setting filter programmatically open the relevant card 2025-09-26 14:21:32 +03:00
joonas-a cf15dfab72 [Updater] Use most recent iteration of each study track, instead of the oldest 2025-09-25 17:27:53 +03:00
joonas-a 9ca60cacc5 [Class statistics] Add button to copy all student numbers to clipboard 2025-09-25 14:16:47 +03:00
joonas-a 8747161f0c [Filters/student number] Enable user input and allow pasting many at once 2025-09-24 15:36:51 +03:00
joonas-a 03c13cedd2 [Filters] Finishing touches fix padding etc 2025-09-23 17:48:53 +03:00
joonas-a 086d1f89f4 [E2E] Fix course filter tests 2025-09-23 17:23:49 +03:00
joonas-a 3335891b5a [Cypress] Add command to select from FilterSearchableSelect (mui autocomplete) 2025-09-23 17:17:44 +03:00
joonas-a 1b36ac9dfe [Filters] Show substituted courses in course filter cards 2025-09-23 16:23:37 +03:00
joonas-a 60d08ed9eb [Filters] Make course filter searchable 2025-09-23 15:21:30 +03:00
joonas-a dc51480e27 [Eslint] fix error 2025-09-19 13:22:56 +03:00
joonas-a aa30506df2 [CI] Remove invalid container declaration 2025-09-19 13:16:39 +03:00
joonas-a c929cd2325 [Meta] delete node-actionlint as abandoned and outdated 2025-09-19 12:01:11 +03:00
joonas-a 0fef278dd8 [CI] retry fix e2e 2025-09-19 11:59:45 +03:00
joonas-a 1e9cab4b46 [CI] require tests for staging and fix reusable workflow call 2025-09-19 11:42:28 +03:00
joonas-a 2beea33a4c [CI/e2e] Try to up node version 2025-09-19 11:32:54 +03:00
joonas-a 6d0acb4091 [Chore] Silence eslint for types with potential use cases 2025-09-18 15:51:34 +03:00
joonas-a 6d8d41dbe1 [Chore] Cleanup 2025-09-18 15:40:01 +03:00
joonas-a e3f6c4a244 [Backend] explicitly declare model fields to prevent shadowing 2025-09-18 15:31:18 +03:00
joonas-a 818b9e4e1c [Infra] Revert RHEL 10 -> 9 for compatibility 2025-09-18 11:20:42 +03:00
joonas-a b8bbe7634e [CI] Deploy to staging with failing tests 2025-09-18 10:48:17 +03:00
joonas-a 6e8e63b38d [Fix] Add safeguard for missing user 2025-09-17 17:28:54 +03:00
joonas-aandSanteri Rajamäki 04deef0c2b [Course filter] Show course code in addition to its name in card 2025-09-16 15:36:02 +03:00
joonas-aandSanteri Rajamäki a8e75754cd [Class statistics] Re-introduce summary rows in modules tab 2025-09-16 15:36:02 +03:00
joonas-aandSanteri Rajamäki c350adac64 [OodiTable] Implement aggregation/summary rows 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 59b79d26d2 [META] Update gelf-pro override on sis-updater-scheduler 2025-09-16 15:36:02 +03:00
Santeri Rajamäki cd8f5b11b1 [META] Run npm update on sis-updater-scheduler 2025-09-16 15:36:02 +03:00
Santeri Rajamäki b8e110e39a [META] Run npm update on sis-updater-worker 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 95b60ed378 [META] Run npm update on backend 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 9dac180b95 [META] Update typescript to version 5.9.2 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 908985410b [META] Update unfuck-spa-shibboleth-session to version 5.0.0 2025-09-16 15:34:33 +03:00
Santeri Rajamäki 9f686fafd5 [META] Run npm update on frontend 2025-09-12 11:20:50 +03:00
Santeri Rajamäki 59963e4961 [META] Update xlsx blob to version 0.20.3 2025-09-12 11:12:39 +03:00
Santeri Rajamäki 1f1dc624e4 [META] Update unfuck-spa-shibboleth-session to version 4.1.1 2025-09-12 11:08:21 +03:00
Santeri Rajamäki 3e374f29d0 [META] Run npm update on root package 2025-09-12 10:29:58 +03:00
santeri0200andGitHub 9c060874eb Merge pull request #4884 from UniversityOfHelsinkiCS/renovate/npm-vite-vulnerability
Update dependency vite to v7.0.7 [SECURITY]
2025-09-12 10:23:26 +03:00
renovate[bot]andGitHub 3a04397e5d Update dependency vite to v7.0.7 [SECURITY] 2025-09-09 21:34:40 +00:00
Santeri Rajamäki 6ae174a1c1 [Filters] Add substitution information to course filter 2025-09-04 16:03:52 +03:00
santeri0200andGitHub 738ff03d2c Merge pull request #4877 from UniversityOfHelsinkiCS/renovate/actions-setup-node-5.x
Update actions/setup-node action to v5
2025-09-04 11:14:31 +03:00
Santeri Rajamäki b11d45da60 [Filters] Reshaped date range selector 2025-09-04 11:13:38 +03:00
renovate[bot]andGitHub e2bc22fdab Update actions/setup-node action to v5 2025-09-04 05:24:55 +00:00
Santeri Rajamäki 1433e949bc [Filters] Animate collapse margin transition 2025-09-03 13:36:04 +03:00
Santeri Rajamäki 7040d78de8 [Filters] Outline filter contents 2025-09-03 13:31:17 +03:00
Santeri Rajamäki 79ff2887fb Upgrade Redhat UBI9 to UBI10 2025-09-02 11:35:40 +03:00
Santeri Rajamäki eac2789951 Added engine versions 2025-09-02 11:35:12 +03:00
santeri0200andGitHub 9688747773 Merge pull request #4876 from UniversityOfHelsinkiCS/OOD-94-redis-port
[FD configuration] Added REDIS_PORT to updater and scheduler in fd docker compose file
2025-09-02 11:34:18 +03:00
Santeri Rajamäki 27616be349 [Frontend] Convert sisu url parsing to a hook 2025-09-02 11:33:44 +03:00
santeri0200andGitHub d330b84d1c Merge pull request #4875 from UniversityOfHelsinkiCS/OOD-90-config-json
[FRONTEND] OOD-90: fetching frontend config from json
2025-09-02 11:33:03 +03:00
Matti Räsänen 9275058e31 [FD configuration] Added REDIS_PORT to updater and scheduler in fd docker compose file 2025-09-02 07:24:46 +03:00
santeri0200andGitHub de8a090898 Merge pull request #4874 from UniversityOfHelsinkiCS/OOD-92
[FRONTEND] Hide show population button in course stats page
2025-09-01 17:25:08 +03:00
Jan Rundt ae393fa857 OOD-90: fetching frontend config from json 2025-09-01 15:32:41 +03:00
Matti Räsänen e46faa0904 OOD-92: Added service provider check to SHOW POPULATION button 2025-09-01 15:04:20 +03:00
Santeri Rajamäki ffa0120bd7 [E2E] Credit statistics logic has always been faulty 2025-09-01 14:49:08 +03:00
Santeri Rajamäki ed8f282cf5 [Frontend] Add alert element to the frontpage 2025-09-01 13:49:12 +03:00
Santeri Rajamäki f0e9a66a7e [Backend] Decouple tags and credits from statisticsOf mankeli 2025-09-01 13:47:04 +03:00
Santeri Rajamäki 0e8c3696eb [GeneralTab] return null if TVEX or transferSource is undefined 2025-09-01 13:40:14 +03:00
Santeri Rajamäki db986ee0fb Extract student option name from the single attribute object 2025-09-01 13:37:15 +03:00
Santeri Rajamäki a35f9cc6d3 [ProgressTab] Move criteria fetching to progress tab 2025-09-01 10:57:33 +03:00
Santeri Rajamäki d60232b954 [Setup] Reword config missing warning message 2025-09-01 10:57:01 +03:00
Santeri Rajamäki 5ef73f0094 Upgade cypress to version 15
Closes #4870
2025-09-01 10:29:19 +03:00
Santeri Rajamäki 433570f124 [ProgressTab] Readd ablity to read progress data based on programme start year 2025-08-21 19:03:55 +03:00
Santeri Rajamäki 7bf8c54efd [GeneralTab] Fix credits since programme start 2025-08-21 17:02:28 +03:00
Santeri Rajamäki 81da962991 [Ooditable] Enable usage of maxSize attribute in columns 2025-08-21 15:44:51 +03:00
Santeri Rajamäki d3d2c2aaf2 Rework StudentInfoItem 2025-08-21 15:21:38 +03:00
Santeri Rajamäki 8fc2019358 [Ooditable] Simplify cell function 2025-08-21 15:08:15 +03:00
Santeri Rajamäki 7f253257b1 [Filters] Use defined key for graduatedFromProgramme default value 2025-08-21 14:48:27 +03:00
Santeri Rajamäki 43c866fa74 [Filters] Tags and study track use the same alert notification when empty 2025-08-21 14:44:31 +03:00
Santeri Rajamäki a5c842222f [Filters] Add default values to options list for rest of the FilterRadio users 2025-08-21 14:41:28 +03:00
Santeri Rajamäki 2e948c802c [Filters] Add possibility of controlled value to FilterRadio
Fix Transferred to programme filter state
2025-08-21 14:34:48 +03:00
Santeri Rajamäki da64f63d48 [Filters] Add disabled tooltip to FilterSelect 2025-08-21 13:20:19 +03:00
Santeri Rajamäki 800012ef05 [GeneralTab] Add columns to excel columns 2025-08-21 12:47:43 +03:00
Santeri Rajamäki b29d478c86 [Ooditable] Use header names instead of id to match columns 2025-08-20 13:39:26 +03:00
tfhuhtal d4506fe70f fix: add jami to db list 2025-08-19 17:39:22 +03:00
tfhuhtal 80159e46f0 feat: use selection only when choosing one dump to reset 2025-08-19 13:39:20 +03:00
tfhuhtal 3a834eb5f4 feat: update setup scripts to use s3 2025-08-19 11:59:24 +03:00
Santeri Rajamäki e56e553f25 Fix study track overflow 2025-08-18 18:13:30 +03:00
Santeri Rajamäki d6e32d2b19 [Filters] programmes move component definitions out of the filter card 2025-08-18 18:06:25 +03:00
Santeri Rajamäki 1413745a3a Fix react render warnings from filter components 2025-08-18 17:53:58 +03:00
Santeri Rajamäki e174dc5403 [E2E][Filters] Fix studyTrack dropdown type 2025-08-18 17:44:40 +03:00
Santeri Rajamäki 45cd8d82d9 Refactor dayjs imports to abide by x-import-modules rules 2025-08-18 17:30:06 +03:00
Santeri Rajamäki a10e3dbaf8 [Filters] Add multiple attribute to studyTracks filter
Suppress MUI warning about invalid empty strings when only empty string is available...
2025-08-18 17:15:53 +03:00
Santeri Rajamäki afa652db30 Upgrade stylelint-config-standard to version 39
Closes #4849
2025-08-18 16:35:12 +03:00
Santeri Rajamäki a1165c76dc Upgrade vite plugin-react-swc to version 4
Closes #4865
2025-08-18 16:32:52 +03:00
Santeri Rajamäki affea21c5a Upgrade download-artifact to action version 5
Closes #4853
2025-08-18 16:30:13 +03:00
santeri0200andGitHub 3e68dcd494 Merge pull request #4864 from UniversityOfHelsinkiCS/renovate/actions-checkout-5.x
Update actions/checkout action to v5
2025-08-18 16:18:44 +03:00
Santeri Rajamäki eb6837a079 [ModuleTab] Mankel some export data 2025-08-18 16:16:58 +03:00
renovate[bot]andGitHub a29e01b6d9 Update actions/checkout action to v5 2025-08-18 13:01:07 +00:00
Santeri Rajamäki 6916824d26 Upgrade sentry to version 10 2025-08-18 16:00:01 +03:00
Santeri Rajamäki 21127a7d6c [ModuleTab] Add value type (again) 2025-08-18 15:49:28 +03:00
Santeri Rajamäki af9f7c06e6 Fix lint 2025-08-18 15:47:56 +03:00
Santeri Rajamäki f882a22263 [ModuleTab] Replace MRT with Ooditable 2025-08-18 15:43:35 +03:00
Santeri Rajamäki 89ae035cf3 [Ooditable] Add VerticalHeader feature 2025-08-18 15:43:35 +03:00
joonas-a 1767790adb [Filters] Revisit Admission type 2025-08-18 14:34:23 +03:00
joonas-a a46ff9b570 [Population search form] reverse order of years in picker 2025-08-18 12:41:37 +03:00
joonas-a 25b6de49c0 Delete dead code 2025-08-15 18:37:54 +03:00
joonas-a c6903ebe25 [Faculties][By starting year] Fix excel export not connecting data with correct year 2025-08-15 18:23:37 +03:00
joonas-a 2d2c25b763 [Backend][Faculties] minor readability improvement 2025-08-15 18:21:50 +03:00
Santeri Rajamäki d232312365 [Frontend] Unify GeneralTab 2025-08-15 12:50:27 +03:00
joonas-a f26f114b1d [Faculties] typing 2025-08-15 12:43:55 +03:00
Santeri Rajamäki 7f8a1bdb0d [Populations] Replace default expanded in accordions with expanded 2025-08-15 10:27:45 +03:00
Santeri Rajamäki 8d0dcc5e1a [Populations] Add default expanded to accordions with content 2025-08-14 13:32:34 +03:00
joonas-a c7173b34ec [Credit stats] Bring types to students 2025-08-14 12:11:07 +03:00
Santeri Rajamäki 361af09dc7 [SGG] Let group tags be undefined in formatting 2025-08-14 11:59:45 +03:00
Santeri Rajamäki f6db93ee67 [Frontend] Course population link QoL 2025-08-14 11:23:17 +03:00
Santeri Rajamäki dee45df9b1 [Backend] Custom population students may not have the correct studyright
This feature wasn't tested widely enough. The studyright *should* always be correct for class populations.
This does not gurantee that the correct studyright is found for anyone in the custom populations.
2025-08-13 16:51:47 +03:00
joonas-a 9d53e3343e [Lint] fix 2025-08-13 16:41:31 +03:00
joonas-a 86083ecc0b [Student statistics] Update tooltip 2025-08-13 16:33:29 +03:00
joonas-a 693de4a505 [Student statistics] Add absent to status col 2025-08-13 16:29:16 +03:00
Santeri Rajamäki 8ad4be048d [Ooditable] Make pagination sizes consistent 2025-08-13 16:16:06 +03:00
Santeri Rajamäki 293c104f49 [RTK] Explicitly use react versions of RTK hooks 2025-08-13 15:34:12 +03:00
joonas-a 6e6fa1bad0 [Student statistics] Type tags table 2025-08-13 14:05:59 +03:00
joonas-a 59772a0dad [Student statistics] Type rtkapi and the view 2025-08-13 13:52:32 +03:00
joonas-a 15a64c8727 [General tab] Fix studyright status col showing inactive in place of active 2025-08-12 17:10:36 +03:00
Santeri Rajamäki fc6bd6f866 [CourseStatistics] Remove courseStatisticsUtils in favour of util 2025-08-12 17:07:54 +03:00
Santeri Rajamäki 11566d95c3 [E2E][CourseStatistics] Let users search as much as they want 2025-08-12 16:51:31 +03:00
Santeri Rajamäki f155979cb8 [Frontend] Move CourseStatistics utility functions to ./util 2025-08-12 16:42:11 +03:00
joonas-a 667a403c35 [Studyguidancegroups] Typing and make semester enrollments start at given year if exists 2025-08-12 16:27:49 +03:00
Santeri Rajamäki 34aaba9444 [LINT] Remove unused imports 2025-08-12 16:17:17 +03:00
Santeri Rajamäki 38c84a0d6c [CourseStatistics] Diffuse selectors 2025-08-12 16:04:36 +03:00
Santeri Rajamäki 130da21892 [Backend] Improve population statistics by course max year calculations 2025-08-12 16:04:36 +03:00
joonas-a c160aa85ad [Backend/redux] add type to studyguidancegroups 2025-08-12 15:47:03 +03:00
joonas-a 8247c55545 Update comment 2025-08-12 14:56:12 +03:00
joonas-a 3c6cb9aaac [Semester enrollments fns] Fix for class stats + close to grad 2025-08-12 14:47:39 +03:00
joonas-a 313a050688 [General tab] Revisited column order 2025-08-12 12:06:45 +03:00
joonas-a d12de74d1b [General tab] simplified credits since column text 2025-08-12 11:56:25 +03:00
joonas-a 1d10107e0d [Study programmes] Fix search only working on programme code 2025-08-12 11:51:06 +03:00
joonas-a da5cee8e66 [Population search form] default to latest year instead of 2017 2025-08-12 10:55:47 +03:00
joonas-a d0ff477d13 [General tab] Increase pagination row counts 2025-08-12 10:49:39 +03:00
Santeri Rajamäki 5da6949a8a [E2E][Filters] Skip describe that is flaky 2025-08-12 09:25:32 +03:00
Santeri Rajamäki 025f0bc954 [E2E][Filters] Skip CI failiing test 2025-08-12 09:18:19 +03:00
joonas-a 8208f1ade5 Lint fix 2025-08-12 09:17:26 +03:00
joonas-a 648f11ea88 [Population search] make 2017 lower limit for year selector 2025-08-11 14:59:02 +03:00
joonas-a f1435aac7f [General tab] Fix custom pop & show absent 2025-08-11 12:07:23 +03:00
joonas-a f356d5cb7d [General tab] Show absent (sgg) 2025-08-08 14:50:04 +03:00
joonas-a 3bb2c8e88c [General tab] Show absent in status col (class statistics) 2025-08-08 14:40:53 +03:00
Joonas AhovalliandGitHub e38b0bb0ae Merge pull request #4851 from UniversityOfHelsinkiCS/eslint-improvement
Attempt to gain more value from linter
2025-08-07 16:26:16 +03:00
joonas-a 77a84f479c Revert type:module on main package json 2025-08-07 15:54:45 +03:00
joonas-a 9b55cf5455 [Cypress] config fix 2025-08-07 15:29:02 +03:00
joonas-a 47dc180145 [Frontend] Fix tsc errors 2025-08-07 15:17:08 +03:00
joonas-a f0e161359e [Eslint] cypress lint 2025-08-07 15:06:45 +03:00
joonas-a f4bf29c717 [Eslint] fixes and frontend lint 2025-08-07 15:05:22 +03:00
joonas-a 50a21143e8 [Eslint] backend 2025-08-07 15:05:22 +03:00
joonas-a d208012f07 [Eslint] run --fix + fix shared 2025-08-07 15:05:22 +03:00
joonas-a 40c3cf7ca1 [tsconfig] revert composite+references for eslint 2025-08-07 15:05:22 +03:00
joonas-a b432e1b359 [Eslint] better config 2025-08-07 15:05:22 +03:00
joonas-a 7762b81cb8 [tsconfigs] try one main config with references 2025-08-07 15:05:22 +03:00
joonas-a 55c2d94b3f [Meta] use type:module in main package.json 2025-08-07 15:05:22 +03:00
joonas-a b7d3d6580d [Eslint] cleanup services/** 2025-08-07 15:05:22 +03:00
joonas-a f9525d7757 [Eslint/frontend] silence no-unused-modules in js/jsx 2025-08-07 15:05:22 +03:00
joonas-a bf28e8e013 [Vite] include conf file in tsconfig 2025-08-07 15:05:22 +03:00
joonas-a 5bd0d291a6 [Eslint] Replace eslint-plugin-import 2025-08-07 15:05:22 +03:00
joonas-a 499b459807 [Eslint] Rework configuration 2025-08-07 15:05:22 +03:00
Santeri Rajamäki 811641e0c3 [E2E] Redefine init visit 2025-08-05 14:36:56 +03:00
Santeri Rajamäki add0074205 [E2E] Move getEmptyYears to the only file using the function 2025-08-05 14:29:39 +03:00
Santeri Rajamäki 586c297ada [E2E] Mark some flaky tests 2025-08-05 14:12:10 +03:00
Santeri Rajamäki 99984f5aa0 [E2E] Fix uncaught error in range filter checking function 2025-08-05 13:38:07 +03:00
Santeri Rajamäki 262c5b253c [E2E] Get headers once instead on each request after visit 2025-08-05 13:35:06 +03:00
Santeri Rajamäki 329899b089 [Backend] Added more details to unauthorized request errors 2025-08-05 13:03:56 +03:00
Santeri Rajamäki c9774a80d4 [E2E][Filters] Convert range selector function to Cypress command 2025-08-05 12:54:23 +03:00
Santeri Rajamäki 40e675b147 [E2E] Apply init on all urls and method within localhost 2025-08-05 12:46:17 +03:00
Santeri Rajamäki 4c73a1dbed [E2E][Filters] Remove unused functions 2025-08-05 12:09:01 +03:00
Santeri Rajamäki 21d6187c38 [E2E] Temp remove wait from initURL 2025-08-04 19:40:41 +03:00
Santeri Rajamäki 8c18bbc942 [Frontend] Make a Backdrop material component 2025-08-04 19:34:30 +03:00
Santeri Rajamäki c02fe5af2a [Filters] Add label tag to FilterSelect InputLabel 2025-08-04 19:28:57 +03:00
Santeri Rajamäki 32b0c8a3db [E2E][Filters] Add multiselect switch to FilterSelect select function 2025-08-04 19:26:56 +03:00
Santeri Rajamäki ca4bb11d12 [E2E][Filters] Skip incorrectly working "not included" check 2025-08-04 19:25:52 +03:00
Santeri Rajamäki fed71d4435 [E2E][Filters] Use Mocked date correctly 2025-08-04 19:25:15 +03:00
Santeri Rajamäki d23a0e67a4 [E2E] Replace the only instance of non-initiated .init param 2025-08-04 14:49:58 +03:00
Santeri Rajamäki 6d37b116cf [E2E] Force init to act on all localhost urls 2025-08-04 14:43:46 +03:00
Santeri Rajamäki 774b883513 [E2E][Filters] Use setSystemTime instead of setting clock on init 2025-08-03 03:38:35 +03:00
Santeri Rajamäki 18aa9ded2d [E2E][Uni] Disabled failing unexplained test with fixed data 2025-08-03 03:24:54 +03:00
Santeri Rajamäki f423b48a7b [E2E] Force wait init page load before continuing tests 2025-08-03 03:10:28 +03:00
Santeri Rajamäki 97a0725740 [Students] Fix graph absence dates 2025-08-01 18:41:57 +03:00
Santeri Rajamäki 9789fe79e4 [Frontend] Fix CreditAccumulationGraphHighCharts button size issue 2025-08-01 18:22:53 +03:00
Santeri Rajamäki 3d6cce2593 [E2E][SGG] Skip table checks 2025-08-01 18:22:15 +03:00
Santeri Rajamäki 4b9e1850b1 [E2E][Population statistics] Fix Graduation check 2025-08-01 18:21:29 +03:00
Santeri Rajamäki 9ad7452334 [Students] Fix graph start date 2025-08-01 18:20:46 +03:00
Santeri Rajamäki 2c9bf88270 [E2E][Filters] Rebuild the whole suite and fix minor bugs in filter cards 2025-08-01 17:33:26 +03:00
Santeri Rajamäki bc36f0da91 [GeneralTab] Fix case where there is no primaryProgramme 2025-07-31 22:14:04 +03:00
Santeri Rajamäki a3187dcc53 [Frontend] Use standard timeout for all useDebounce calls 2025-07-31 22:08:15 +03:00
Santeri Rajamäki 757d2ad730 [Frontend] Tightened filter logic flow 2025-07-31 22:06:50 +03:00
Santeri Rajamäki 1c16621124 [Frontend] Separate auth loading from application state 2025-07-31 20:26:35 +03:00
Santeri Rajamäki 45cbcd58ad [Fronend] Remove RTKApi DevTools in non-development environments 2025-07-31 18:20:36 +03:00
Santeri Rajamäki 92f394b230 [Ooditable] Remove explicit any from ColumnDef 2025-07-31 15:14:39 +03:00
Santeri Rajamäki ac71464055 [Ooditable] Remove debug options from base config 2025-07-31 14:53:58 +03:00
Santeri Rajamäki 94af09d0d4 [E2E][SGG] Replace CS matching with inlined contains 2025-07-31 14:50:22 +03:00
joonas-a 99684923dc [Meta] bump dependencies 2025-07-31 11:40:18 +03:00
joonas-a ab63820736 [Updater] Fix cjs/esm compatibility on migrations 2025-07-30 15:55:14 +03:00
santeri0200andGitHub 7cd61602df Merge pull request #4848 from UniversityOfHelsinkiCS/sortabletablekiller
Replace MRT in GeneralTab
2025-07-29 19:44:39 +03:00
Santeri Rajamäki dacce9764e Fix more eslint errors 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 1c6176830c Run eslint --fix 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 23a600477f [Ooditable] extract columnDefinitions hook from useMemo per eslint/react rules 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 0127d41381 Move baseColumns to columnDefinitions 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 4a4a6efb0f Remove unused GeneralTab files 2025-07-29 19:35:14 +03:00
Santeri Rajamäki efc773141b Fix lint error in semestersPresent 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 89aa2b6931 [Ooditable] Replace MRT as GeneralTab implementation 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 7f01fb688b [Ooditable] Returned ability to associate credits since 2025-07-29 19:35:14 +03:00
Santeri Rajamäki ccca69ce82 [Ooditable] Let state fall trough to make React happy with its' hooks 2025-07-29 19:35:14 +03:00
Santeri Rajamäki 10f09577a0 Fix eslint unresolvable errors 2025-07-29 19:35:14 +03:00
Santeri Rajamäki bf38fe33ab Change degreeProgrammeType to the real implementation 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 427d2cdd4d Run eslint --fix again 2025-07-29 19:35:13 +03:00
Santeri Rajamäki a61876657f Fix eslint errors 2025-07-29 19:35:13 +03:00
Santeri Rajamäki f74f319c89 Rename format function per react/eslint rules 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 6ebbf32e1f Run prettier on frontend 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 395eab9b58 [Ooditable] Add excel export 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d99ff8ee50 [Ooditable] Cleanup 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 94042a7ea6 [Ooditable] Add support for Studyguidancegroup populations 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d9c869e13f [Ooditable] Rework CustomPopulation formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 8a718554ea [Ooditable] Rework CoursePopulation formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki c8036892c6 [Ooditable] Reorganize PopulationDetails columns and formatting fields 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d664a19c10 [Ooditable] Rework PopulationDetails formatting 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 28e2316f04 [Ooditable] Add support for Custom population 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 0e48735748 [Ooditable] Add support for Course populations 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 67787daccf [Ooditable] Remove unnecessary conditional statement 2025-07-29 19:35:13 +03:00
Santeri Rajamäki d7829a0cfc [Ooditable] Fixup combinedProgrammes column visibility 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 2424b115f5 [Ooditable] Minor tweaks to DOM tree and variable names 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 3ebe68998b [Ooditable] Pass columns and formatting to PopulationStudents 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 6947d1b858 [GeneralTab] Redo column definitions 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 629cec7560 [GeneralTab] Replace formatting file 2025-07-29 19:35:13 +03:00
Santeri Rajamäki 094f625eb5 [MRT] Make formatting better 2025-07-29 19:35:12 +03:00
Santeri Rajamäki b7720f620c [Ooditable] Rename baseColumns to a use hook 2025-07-29 19:35:12 +03:00
Santeri Rajamäki 9e5acd3145 [OodiTable] Modularize 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 704265963c [OodiTable] Add page selector to bottom 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki b4bfcd1292 [OodiTable] Sticky headers, viewport based height and scrolling 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 302f0b3b33 [OodiTable] sticky studentnumber column 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 2ba108a34e OT: style sorting arrows 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 86d656dc4c OT: sorting 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 2e8c9c2a7e OT: two-story columns 2025-07-29 19:35:12 +03:00
joonas-aandSanteri Rajamäki 730ac94c20 [OodiTable] Progress 2025-07-29 19:35:11 +03:00
joonas-aandSanteri Rajamäki 44584d1f7d [OodiTable] Skeleton 2025-07-29 19:35:11 +03:00
joonas-aandSanteri Rajamäki d93b708029 Initial commit 2025-07-29 19:35:11 +03:00
Santeri Rajamäki dcbc7a916e [Backend] Rewrite enrollment unwrapper 2025-07-29 19:33:45 +03:00
Santeri Rajamäki a7e3e45690 [Shared] Clarify that objects typed FormattedStudent may be obfuscated 2025-07-29 19:07:46 +03:00
Santeri Rajamäki 2404d9a42c [Backend] Readd student enrollments 2025-07-29 19:05:31 +03:00
Santeri Rajamäki 340912b5a1 [E2E] Fix eslint errors 2025-07-29 15:31:28 +03:00
joonas-a 7aaf584eaa [Cypress] Fix population statistics 2025-07-29 12:20:10 +03:00
joonas-a 57de5435bd [Cypress] Fix population statistics credit stats tests 2025-07-29 12:02:49 +03:00
joonas-a 1804bccefb [Cypress] Fix wrong statistics in course stats test 2025-07-29 10:44:57 +03:00
joonas-a 6461aaff4e [Frontend] Acually fix highest grade of course computation for ht/tt 2025-07-29 10:42:08 +03:00
joonas-a b32a0fb3fe [Frontend] Fix highest grade of course computation 2025-07-28 12:11:37 +03:00
joonas-a ad0ccbc98b [Lint] Fix enum comparison in general tab 2025-07-28 10:23:30 +03:00
joonas-a a1f3cb8fa8 [Frontend] Convert common.js to typescript 2025-07-25 17:20:37 +03:00
joonas-a b074b90553 [Filters] Rewrite gradefilter precompute 2025-07-25 14:42:00 +03:00
Santeri Rajamäki ce7a34e1d7 [Student statistics] Acually render enrollments correctly 2025-07-24 17:00:52 +03:00
joonas-a 314dcc7215 [Frontend] import images from assets instead of common 2025-07-24 15:53:00 +03:00
joonas-a 7c9c5508d0 [Credit statistics] Material UI distribution development tab 2025-07-24 15:30:25 +03:00
Santeri Rajamäki d6b289eef2 [Lint] Fix lint errors 2025-07-24 15:29:00 +03:00
Santeri Rajamäki eaaa55bba6 [Filters] Touch up filter types 2025-07-24 15:04:23 +03:00
Santeri Rajamäki 60fb3a8ae0 [Population students] Fix mappings 2025-07-24 14:23:51 +03:00
Santeri Rajamäki 208bd3511d [Backend] Acually type the formatted student data
This commit reworks the formatStatisticsForApi into near transportable form and adds types to the return value.
2025-07-23 20:04:22 +03:00
Santeri Rajamäki 3a9a5a69e4 [Backend] Add route types to routes/users 2025-07-23 19:31:02 +03:00
Santeri Rajamäki 3b8aa5bf56 [Frontend] Remove amount attribute from FilterSelect options 2025-07-23 14:21:55 +03:00
joonas-a 4e373d4ef2 [Credit statistics] Material UI statistics tab 2025-07-23 12:41:32 +03:00
joonas-a 219dc7ce47 [Credit statistics] Material UI tab component 2025-07-22 17:04:57 +03:00
joonas-a 9bc087818a [Credit statistics] convert filter toggle to tsx 2025-07-22 16:38:39 +03:00
joonas-a ad1998bcd0 [Age distribution] Use mui typography for consistency 2025-07-22 16:24:36 +03:00
joonas-a fb118094a9 [Percentage bar] Increase height 2025-07-22 16:17:04 +03:00
joonas-a dc9a7b3920 [Credits gained] add styling 2025-07-22 16:15:00 +03:00
joonas-a 5b5ec30458 [Credit statistics] Material UI credits gained table 2025-07-22 15:52:19 +03:00
joonas-a a7d30769b8 [Credit statistics] Material UI credits gained table container 2025-07-22 12:46:23 +03:00
joonas-a 442f2685e5 [Utils] make formatDate accept dayjs objects 2025-07-22 11:41:00 +03:00
joonas-a ae097d04b6 [Credit statistics] remove helper function for not being helpful 2025-07-22 11:01:54 +03:00
joonas-a e934157821 [Credit statistics] Material UI credits gained tab container 2025-07-22 10:51:15 +03:00
joonas-a db6cb155a0 [Credit statistics] Material UI main view 2025-07-21 15:48:31 +03:00
joonas-a 8c6ea09791 [Infobox] make it accept the sx prop 2025-07-21 15:26:26 +03:00
joonas-a 34ee583425 [Courses of class] Rename component import 2025-07-21 12:56:40 +03:00
joonas-a 592ea364f4 [Courses of class] Material UI selector 2025-07-21 12:53:47 +03:00
Santeri Rajamäki 2212af8d7e [Frontend] Convert transferredToProgramme filter to predefined MUI components 2025-07-18 20:51:02 +03:00
Santeri Rajamäki 27bba9f135 [Frontend] Convert tags filter to predefined MUI components 2025-07-18 20:46:37 +03:00
Santeri Rajamäki 42112bb36e [Frontend] Convert studyRightType filter to predefined MUI components 2025-07-18 20:23:38 +03:00
Santeri Rajamäki b1aed16f99 [Frontend] Convert studyRightStatus filter to predefined MUI components 2025-07-18 17:32:18 +03:00
joonas-a 238e6ea9ce [Population details] Material UI AgeStats 2025-07-18 17:16:51 +03:00
joonas-a 0682d90dce [Population statistics] small groundwork and renaming 2025-07-18 17:12:13 +03:00
Santeri Rajamäki bc26f1cf7b [Frontend] Convert studentNumber filter to predefined MUI components 2025-07-18 17:04:52 +03:00
Santeri Rajamäki 5b2c3d7fff [Frontend] Semesters present betterments 2025-07-18 16:38:45 +03:00
Santeri Rajamäki ffd7da54cc [Frontend] Convert programmes filter to predefined MUI components 2025-07-18 13:53:17 +03:00
Santeri Rajamäki 3b6b387c0d [Frontend] Extend FilterSelect with InputItem and MenuItem render options 2025-07-18 13:51:20 +03:00
Santeri Rajamäki a68d1f13c1 [Frontend] Convert hops filter to predefined MUI components 2025-07-18 11:12:56 +03:00
Santeri Rajamäki dbf838f5a5 [Frontend] Added FilterSwitch to common filter components 2025-07-18 11:12:56 +03:00
Santeri Rajamäki c37c9d0b99 [Frontend] Rename FilterRadioProp type 2025-07-18 11:12:56 +03:00
Santeri Rajamäki 24e478e7b4 [Frontend] Add default value checking to FilterRadio 2025-07-18 11:12:56 +03:00
joonas-a 2481713a0a [Class statistics] unmount loading spinner when finished loading 2025-07-18 11:09:18 +03:00
joonas-a 843a61d74a Remove unneeded code 2025-07-18 11:01:10 +03:00
joonas-a 783c46b62e [Population search] finish refactor 2025-07-18 10:59:02 +03:00
joonas-a ec55cc101f [Population search form] split studytrackselector 2025-07-17 17:26:57 +03:00
joonas-a 9698329ae2 [Population search form] split degreeprogrammeselector 2025-07-17 17:14:10 +03:00
joonas-a 191b8b4c3a [Population search form] split into parts 2025-07-17 16:01:19 +03:00
joonas-a b0d439c1be [Population search history] Use new query 2025-07-17 15:38:14 +03:00
Santeri Rajamäki 0f21a53381 [Frontend] Converted enrollmentStatus filter to use predefined MUI components 2025-07-16 21:05:07 +03:00
Santeri Rajamäki b2ff61f85b [Frontend] Remove nullish coalescing from FilterSelect value 2025-07-16 20:48:08 +03:00
Santeri Rajamäki ac7e3f2423 [Frontend] Converted course filter to use predefined MUI components 2025-07-16 20:33:15 +03:00
Santeri Rajamäki 46f316c82f [Frontend] Extended FilterSelect with disable option 2025-07-16 20:32:15 +03:00
Santeri Rajamäki 926a0d67d1 [Frontend] Converted graduatedFromProgramme to use predefined components 2025-07-16 19:46:25 +03:00
Santeri Rajamäki 4e319e801b [Frontend] Added FilterRadio to common filter components 2025-07-16 19:23:14 +03:00
Santeri Rajamäki 3182fa89d4 [Frontend] FilterSelect remove undefined from supported value type 2025-07-16 18:06:05 +03:00
Santeri Rajamäki 012686c49b [Frontend] Extend FilterSelect with multiple option 2025-07-16 18:02:11 +03:00
Santeri Rajamäki 9d5a927d86 [Frontend] Convert studyTrack filter to use predefined MUI components 2025-07-16 17:33:15 +03:00
Santeri Rajamäki 05f6a12871 [Frontend] Convert startYearAtUni filter to use predefined MUI components 2025-07-16 17:26:09 +03:00
Santeri Rajamäki 1ed659f93c [Frontend] Convert gender filter to use predefined components 2025-07-16 17:22:06 +03:00
Santeri Rajamäki 215002a86c Enable other valid value types in FilterSelect 2025-07-16 17:08:13 +03:00
Santeri Rajamäki dea0d29053 [Frontend] Convert curriculumPeriod filter t ouse predefined components 2025-07-15 16:52:26 +03:00
Santeri Rajamäki daac389968 [Frontend] Convert citizenship filter to use predefined components 2025-07-15 16:47:22 +03:00
Santeri Rajamäki 3e0ca1827a [Frontend] Migrate graduatedFromProgramme filter to MUI 2025-07-15 16:35:40 +03:00
Santeri Rajamäki fe2f73bd5c [Frontend] Migrate grade filter to MUI 2025-07-15 16:11:41 +03:00
Santeri Rajamäki d27a931c4e [Frontend] Migrate gender filter to MUI 2025-07-15 15:21:24 +03:00
Santeri Rajamäki 7f02d194d4 [Frontend] Migrate enrollmentStatus filter to MUI 2025-07-15 15:06:01 +03:00
Santeri Rajamäki 72e7519cb7 [Frontend] Migrate curriculumPeriod filter to MUI 2025-07-15 14:34:37 +03:00
Santeri Rajamäki a39b80946c [Frontend] Migrate citizenship filter to MUI 2025-07-15 14:26:57 +03:00
Santeri Rajamäki 9b7b7c7e2d [Frontend] Migrate FilterToggle to MUI 2025-07-15 13:32:41 +03:00
santeri0200andGitHub 73e69b5f57 Merge pull request #4834 from UniversityOfHelsinkiCS/filtering
Improve filtering
2025-07-14 20:13:26 +03:00
Santeri Rajamäki 6a4a7b539b [Frontend] Make filter actions less packed 2025-07-14 19:57:18 +03:00
Santeri Rajamäki a2dab9dbbe [Frontend] Use actions as intended 2025-07-14 19:43:46 +03:00
Santeri Rajamäki b06931b403 Reintroduce reset action to filters 2025-07-14 18:53:44 +03:00
Santeri Rajamäki 902af27091 Fix uncaught lint error 2025-07-14 18:46:26 +03:00
Santeri Rajamäki 6d1d8e90c1 [Frontend] Immediately execute filters before passing them to FilterView 2025-07-14 18:42:48 +03:00
Santeri Rajamäki 003ff2665e [Frontend] Rework FilterCard rendering logic 2025-07-14 18:42:07 +03:00
Santeri Rajamäki 56b87ba3a1 [Frontend] Cleanup view filter options, context and precompute values 2025-07-14 17:54:20 +03:00
Santeri Rajamäki ea94c510b0 [Frontend] Retype selectViewFilters and reorder FilterView filter counting 2025-07-14 17:21:55 +03:00
Santeri Rajamäki 97b82c71bc [Frontend] Filters now reset to default state (not initial) and setOptions options is now native 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 37710702a3 [Frontend] Separate filter selector and action types from factory scope 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 06dff4c3bc [Frontend] Remove unnecessary priority attribute from filters 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 47d8fef876 [Frontend] Add titles to all filters 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 267b79324c [Frontend] Do the todo in HOPS filter 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 88fc069110 [Frontend] Clarify filter contest 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 3ea74621db [Frontend] Reword FilterFactory 2025-07-14 16:21:53 +03:00
Santeri Rajamäki e88c544bb4 [Frontend] Clarify filter store 2025-07-14 16:21:53 +03:00
Santeri Rajamäki 14d63f7009 [Frontend] Clarify filter selector wrapper type 2025-07-14 16:21:53 +03:00
Santeri Rajamäkiandmatiassinisalo 613aa14fd4 Make life easier
Co-authored-by: matiassinisalo <matias.aleksanteri@hotmail.fi>
2025-07-14 15:14:36 +03:00
Santeri Rajamäki d35e4309be Fix admission type test in filters suite 2025-07-11 17:21:29 +03:00
joonas-a 89e261c5f2 [Filters] mui select admission type 2025-07-11 16:36:55 +03:00
Santeri Rajamäki df8a257030 Rework columnHelpers createMap 2025-07-11 11:52:33 +03:00
joonas-a db391fb15d [Cypress] Filters 2025-07-10 16:59:11 +03:00
santeri0200andGitHub 7f96288849 Added QLTY badge for the project 2025-07-10 16:38:00 +03:00
joonas-a c64ba8ce29 [Filters] Material UI filter range v2 2025-07-10 16:31:49 +03:00
joonas-a 1a1d55bf93 [Filters] Material UI age filter 2025-07-10 15:29:43 +03:00
joonas-a 44b47331bb [Navbar] Fix hitboxes on tab items with dropdown 2025-07-10 13:40:12 +03:00
joonas-a bcaa0f498f [Class statistics] add loading spinner 2025-07-10 13:13:40 +03:00
joonas-a 59827d440a [Population details] cleanup 2025-07-10 12:13:27 +03:00
joonas-a 7f9b472e30 [Sortable Table] Remove crypto random uuid 2025-07-10 12:06:04 +03:00
joonas-a 95141e652e [Cypress] population statistics 2025-07-10 12:04:59 +03:00
joonas-a cf2a7e56e0 [Cypress] maybe fix study programme overview 2025-07-10 11:23:36 +03:00
joonas-a e50a6d5577 [Filters] Minor cleanup 2025-07-10 10:26:41 +03:00
joonas-a d1c41972f7 [Population search] styling 2025-07-09 16:33:12 +03:00
joonas-a cebee9d8cf [CI] fix cy 2025-07-09 16:15:30 +03:00
joonas-a 3f7b62015e [Population details] Material UI 2025-07-09 14:51:06 +03:00
Santeri Rajamäki e4e5a9d075 Remove analytistics tools from repo
qltysh allows us to use a _web editor_ for the config
2025-07-08 17:53:06 +03:00
joonas-a 435209b742 [Filters] Fix admissiontypefilter react issue 2025-07-08 17:42:00 +03:00
Santeri Rajamäki 866c5a64e4 Added qlty config 2025-07-08 17:36:15 +03:00
joonas-a 5f20d7a344 Remove unused code 2025-07-08 17:10:41 +03:00
joonas-a 6dcfadb68e [Material UI] FilterTray & FilterCard 2025-07-08 17:06:50 +03:00
Santeri Rajamäki 192171f270 [Frontend] Admission type for all 2025-07-08 17:00:15 +03:00
Santeri Rajamäki 0538329d60 Fix 2025-07-08 16:44:23 +03:00
Santeri Rajamäki 5b458933d2 [Frontend] Rework PopulationStudents 2025-07-08 16:35:08 +03:00
Santeri Rajamäki fdbed2f1cf Fk MUI part 2 2025-07-08 14:34:20 +03:00
Santeri Rajamäki cc73d5983e Hotfix population urls 2025-07-08 14:19:39 +03:00
Santeri Rajamäki e69e8dbe02 Update hardcoded populations query urls 2025-07-08 14:13:16 +03:00
Santeri Rajamäki ab460b0e9f Fix allSemesters indexing 2025-07-08 14:09:28 +03:00
Santeri Rajamäki b1d8e852df Update student hardcoded populations redirect 2025-07-08 13:16:04 +03:00
Santeri Rajamäki d5dfed6942 Hotfix 2025-07-08 13:08:52 +03:00
Santeri Rajamäki 5d6c0500a3 Add docker env defaults to overwrite Cypress misbehaviour 2025-07-08 13:02:20 +03:00
Santeri Rajamäki 2b58b9ba91 Fix date picker tests 2025-07-08 12:33:47 +03:00
Santeri Rajamäki 9bb72b2f0e Fix semestersPresent currentSemester is null issue 2025-07-08 11:58:26 +03:00
Santeri Rajamäki ad73cd7d0f [Shared] Fix omitKeys 2025-07-08 11:25:28 +03:00
Santeri Rajamäki 83551edacf Fix class size text capitalization 2025-07-08 10:20:07 +03:00
Santeri Rajamäki a0ee324fe7 [Frontend] Remove unused common file 2025-07-08 10:08:02 +03:00
Santeri Rajamäkiandtfhuhtal fb221edd87 Fix Cypress service provider error
Co-authored-by: tfhuhtal <tuomas.huhtala@icloud.com>
2025-07-08 09:55:45 +03:00
joonas-a 64c61d1be2 [Population statistics] convert info card to MUI 2025-07-07 17:43:31 +03:00
joonas-a 216083a0d4 Fix rebase conflict with tags 2025-07-07 16:41:57 +03:00
joonas-a a04789094e [Population details] Use new query 2025-07-07 16:08:21 +03:00
joonas-a 13db9cdb6d [Tags] Add safeguard for undefined 2025-07-07 16:08:21 +03:00
joonas-a 3b78e7ade3 [Population Link] Rework URLs 2025-07-07 16:08:21 +03:00
joonas-a 7cbc1dfb26 [Courses of class] use new query 2025-07-07 16:08:21 +03:00
joonas-a ab37b967ad [Class statistics] Start url streamlining 2025-07-07 16:08:01 +03:00
Santeri Rajamäki 5b15a2516f Make course statistics test less flaky 2025-07-07 06:26:44 +03:00
Santeri Rajamäki 56e6e88a9e [Frontend] Fix navbar key error 2025-07-07 02:20:43 +03:00
Santeri Rajamäki 3ed5aed710 [Frontend] Acually use Unarray in frontend 2025-07-07 02:12:41 +03:00
Santeri Rajamäki a0294b7750 [Shared] Fix lint error with isolatedModules 2025-07-07 01:51:29 +03:00
Santeri Rajamäki 9539d6b97b [Backend] Add route types to route/populations
Move around types to shared/
2025-07-07 01:31:15 +03:00
Santeri Rajamäki 578fef70a8 [Backend] Add route types to routes/customPopulationSearch 2025-07-07 00:40:57 +03:00
Santeri Rajamäki 31fac70a31 [Backend] Add route types to routes/curriculumPeriod 2025-07-07 00:24:32 +03:00
Santeri Rajamäki 9e631d119f [Backend] Add route types to routes/courses 2025-07-03 17:57:23 +03:00
Santeri Rajamäki ef0d95f83f [Backend] Add route types to routes/closeTograduation 2025-07-03 17:56:51 +03:00
Santeri Rajamäki e2774bfdc4 [Backend] Add route types to routes/login 2025-07-03 17:47:39 +03:00
Santeri Rajamäki dc65bb4571 [Backend] Root clean up 2025-07-03 16:59:18 +03:00
Santeri Rajamäki 48cb58deb7 Fix uncaught lint error 2025-07-03 16:27:54 +03:00
Santeri Rajamäki c65e4dadad [Frontend] Root clean up 2025-07-03 16:23:15 +03:00
Santeri Rajamäki 8388914406 [Frontend] Navbar improvments 2025-07-03 12:34:31 +03:00
Santeri Rajamäki 3e87dd0c79 [Frontend] Convert student view "Map" arrays to Maps 2025-07-03 11:29:44 +03:00
Santeri Rajamäki 81dda57671 [Frontend] Make FilterTray look nicer 2025-07-03 11:17:00 +03:00
Santeri Rajamäki 7670562b90 [Frontend] Upgrade DateSelector 2025-07-03 11:15:43 +03:00
Santeri Rajamäki df632591bb [Frontend] Rework TagsTab data fetching 2025-07-02 16:28:36 +03:00
Santeri Rajamäki ece01ad9d2 [Frontend] Rework semestersPresent 2025-07-02 15:31:30 +03:00
Santeri Rajamäki be1be25d00 [Frontend] Convert semestersPresent to use CSS based SVGs and MUI components 2025-07-02 13:04:02 +03:00
Santeri Rajamäki 4c553124bf [Frontend] Update Vite to version 7 2025-07-01 17:16:32 +03:00
Santeri Rajamäki a0e26d361f Fk MUI 2025-07-01 17:03:40 +03:00
Santeri Rajamäki 4bf5982062 Fix population search boxes in tests 2025-07-01 16:15:41 +03:00
Santeri Rajamäki d06d7b120b Fix open uni search tests 2025-07-01 15:48:01 +03:00
Santeri Rajamäki ad3ebe0464 Fix study guidance groups test 2025-07-01 15:09:12 +03:00
Santeri Rajamäki 76a7d40784 Fix already fixed tests 2025-07-01 14:54:38 +03:00
joonas-a ba4808aaf7 [Population search] tweaks & infoboxes 2025-07-01 14:27:21 +03:00
Santeri Rajamäki fdc97a4537 Fix population statistics tests 2025-07-01 12:57:36 +03:00
Santeri Rajamäki 73c3882751 Fix course statistics test 2025-07-01 12:57:36 +03:00
joonas-a d04c77e7d2 [Population search form] use MUI-based search history 2025-07-01 12:53:56 +03:00
Santeri Rajamäki 07fb9b7905 [Frontend] Convert searchHistory to use localStorage hook 2025-06-30 20:12:12 +03:00
Santeri Rajamäki 002d91613a Fix CI lint errors 2025-06-30 20:10:55 +03:00
Santeri Rajamäki 0a5332664b [Frontend] Convert PanelView to MUI 2025-06-30 19:41:33 +03:00
Santeri Rajamäki d72f1a8478 [Frontend] Students tab betterments 2025-06-30 19:40:25 +03:00
joonas-a 246ad05031 [Population search form] Material UI 2025-06-30 18:09:45 +03:00
Santeri Rajamäki 83b79fc9ea Fix semesters present not working 2025-06-30 16:50:49 +03:00
Santeri Rajamäki e8df57f895 Fix duration change to reflect fractional years in honours calculation 2025-06-30 12:04:48 +03:00
Santeri Rajamäki 040f8cad8f Remove dayjs duration and use diff as intended 2025-06-30 11:13:08 +03:00
Santeri Rajamäki 3cde980950 No moment to waste
Use dayjs instead of moment. We might want to explore some other alternatives in the future.
This commit closes #4800, #4811 and #4812

PopulationSearchForm is still waiting for @joonas-a
2025-06-27 14:58:20 +03:00
Santeri Rajamäki a2c665a501 Get all needed semester names at once
This closes #4781
2025-06-25 22:36:23 +03:00
Santeri Rajamäki ee99dcb449 [Frontend] Create a curriculum hook
Now setCurriculum isn't gatekept by curriculumPicker closing #4809
2025-06-25 21:44:26 +03:00
Santeri Rajamäki b4509f79cd Reduced curriculum response size 2025-06-25 19:38:24 +03:00
Santeri Rajamäki c7121795c3 [Frontend] Rework PopulationStatistics render chain 2025-06-25 16:51:28 +03:00
joonas-a 21e4e44713 [Population search] Rid momentjs 2025-06-25 16:25:37 +03:00
santeri0200andGitHub 37f93b328a Merge pull request #4833 from UniversityOfHelsinkiCS/populus
[Frontend]  Populationes optimizatae
- Reduce the amount of API calls
- Unify the calling convention to population APIs
- Make population API calls faster
2025-06-24 19:45:23 +03:00
Santeri Rajamäki ce0b42f88b Update lint action and updater workflow node versions to 22 2025-06-24 19:40:01 +03:00
Santeri Rajamäki dcd8ba77b3 Remove failing waits from tests 2025-06-24 19:30:03 +03:00
Santeri Rajamäki 6dc3bdb72a Make FilterCard more usable 2025-06-24 19:11:12 +03:00
Santeri Rajamäki ef80ee436d Cleanup FilterView 2025-06-24 19:11:12 +03:00
Santeri Rajamäki 6ad8981547 Refix credit accumulation 2025-06-24 19:11:12 +03:00
Santeri Rajamäki 3169c34864 Use correct API version for cypress route 2025-06-24 19:11:12 +03:00
santeri0200andGitHub 1d769c4fa2 Merge branch 'master' into populus 2025-06-24 17:38:14 +03:00
Santeri Rajamäki 11c6ab2db4 Rename FilterView course filter file 2025-06-24 17:36:39 +03:00
joonas-a b48ca23e9d Fix e2e 2025-06-24 17:24:02 +03:00
joonas-a 14da33f852 Fix lint errors 2025-06-24 17:03:14 +03:00
joonas-a 5e8beae9ed [Population search] MUI: 4 out of many 2025-06-24 16:49:10 +03:00
joonas-a 9676371823 [Filters] Fix Hops credit filter 2025-06-24 16:49:10 +03:00
joonas-a 5267cff110 [Filters] Add utils to transferred to programme filter 2025-06-24 16:49:10 +03:00
Santeri Rajamäki 647b2197fe Refix courses tests with enrollment calculations 2025-06-24 16:45:53 +03:00
Santeri Rajamäki 9f8048c0fb Fix semester loading and use strings in range selector tests 2025-06-24 16:29:28 +03:00
Santeri Rajamäki 3f9a245d16 Fix courses tests 2025-06-24 15:37:18 +03:00
Santeri Rajamäki ebfc16a07e Patch age filter behavior when reseting 2025-06-24 13:13:20 +03:00
Santeri Rajamäki 56ffaa6dd8 Fix population statistics waiting for courseData 2025-06-24 13:02:09 +03:00
Santeri Rajamäki e142fbb449 Fix population statistics incorrect total credits test 2025-06-24 13:01:12 +03:00
Santeri Rajamäki 219608293c Fix teacher statistics test 2025-06-24 12:16:47 +03:00
santeri0200andGitHub 8faa244c56 Merge branch 'master' into populus 2025-06-24 12:01:22 +03:00
Santeri Rajamäki dea230ecec Try resolve merge conflict
Rename PopulationQueryCard/index from jsx to tsx extension
2025-06-24 11:58:38 +03:00
Santeri Rajamäki 196bea2a58 [Backend] Make population fetching faster 2025-06-24 11:54:28 +03:00
Santeri Rajamäki 23dceab14c [Frontend] Fix tag filter 2025-06-24 10:54:21 +03:00
Santeri Rajamäki 7af0f28c14 Give population coursestats a type
Import the type to frontend
2025-06-23 20:50:41 +03:00
Santeri Rajamäki 9433aef65a [Backend] Convert student studyright lookup to a map 2025-06-23 20:33:30 +03:00
Santeri Rajamäki ad1455b03e Fix courses filter 2025-06-23 18:11:10 +03:00
Santeri Rajamäki 4f698b542e [Frontend] Consolidate semestry checking
This reduces unneccessary calls to API by one
2025-06-23 17:24:28 +03:00
joonas-a 4b8b042c2f [Population query card] Transform to use MUI 2025-06-23 14:55:26 +03:00
joonas-a f82ddf660c [Population search] MUI: 3 out of many 2025-06-23 14:14:59 +03:00
Santeri Rajamäki 54fec454f0 [Backend] Replace bottlenecksOf and courseStatsCounter
Fix some visible bugs with the new code in frontend
2025-06-23 13:32:01 +03:00
Santeri Rajamäki 610fab1c21 [Frontend] Move course filtering to FilterView 2025-06-20 18:22:32 +03:00
Santeri Rajamäki 2bbc041f22 Replace PopulationCourses request entirely
Remove courses-of-population request and bottlenecksOf, use optimizedStatisticsOf call to get the same data.
2025-06-19 17:39:24 +03:00
joonas-a 4efde9a997 Fix e2e 2025-06-19 16:48:29 +03:00
joonas-a be309972be Fix lint 2025-06-19 15:19:01 +03:00
Santeri Rajamäki 2603ef507a [Backend] Rework optimizedStatisticsOf and related functions 2025-06-19 15:11:39 +03:00
joonas-a 6334076698 [Population search] MUI: 2 out of many 2025-06-19 14:20:12 +03:00
joonas-a 3579580029 [Population search] MUI: 1 out of many 2025-06-19 13:07:49 +03:00
Santeri Rajamäki bede2502f5 [Backend] Remove unused course stats from OptimizedStatisticsOf 2025-06-19 10:04:10 +03:00
joonas-a 142f5c444f [Course statistics]: Fix missing programme from Study programmes col 2025-06-18 15:07:42 +03:00
joonas-a 43d11e615b [General tab] Status col: include info if cancelled studyright 2025-06-18 13:04:19 +03:00
joonas-a 2dcdae9796 [General tab] Add status column 2025-06-18 12:49:14 +03:00
joonas-a c0308f43fa [General tab] prepare for new column 2025-06-17 17:30:39 +03:00
Santeri Rajamäki 9f6606daa2 [Backend] Remove unused parameter from bottlenecksOf and PopulationCourses query 2025-06-17 14:00:48 +03:00
Santeri Rajamäki 5b7dda18ca [Frontend] FilterCourses is now a util function 2025-06-17 13:42:24 +03:00
Santeri Rajamäki 47685ac459 Remove unnecessary field from bottlenecksOf and PopulationCourses qurey 2025-06-17 13:25:26 +03:00
Santeri Rajamäki 54467fcdcf [Frontend] StudyguidanceGroups use CustomPopulation route to fetch population data 2025-06-17 13:07:04 +03:00
Santeri Rajamäki 83673a77f4 [Backend] Remove redundant field from bottlenecksOf call 2025-06-17 12:43:34 +03:00
Santeri Rajamäki 7531a9def4 [Frontend] Fix curriculumPeriod filter 2025-06-17 10:16:47 +03:00
Santeri Rajamäki ce244e1cb2 Move PopulationCourses types to shared and pass them to frontend
Additionally convert bottlenecksOf object to Map and reduce parsetime by ~17%
2025-06-16 19:50:53 +03:00
Santeri Rajamäki 8c20865e7a [Frontend] Unify courses fetching for all FilterView views 2025-06-16 16:18:30 +03:00
Santeri Rajamäki a0fdc68117 [Frontend] Force unique id on query result card
Using `.card` as a query us flaky...
2025-06-16 12:19:53 +03:00
Santeri Rajamäki 380c2f8154 Acually fix Population_statistics.js 2025-06-16 12:07:57 +03:00
Santeri Rajamäki 8922d1065f Move intercepts to be before visit 2025-06-13 23:38:10 +03:00
Santeri Rajamäki 4c7b52cfcb Wait for courses request before testing courses tab 2025-06-13 23:16:19 +03:00
Santeri Rajamäki 07bea28642 Touch up tests 2025-06-13 21:13:06 +03:00
Santeri Rajamäki e13a4da9ea Update PopulationStatistics tests to the new reality 2025-06-13 20:54:00 +03:00
Santeri Rajamäki 3f58a321a5 [Frontend] Use courses passed from parents in CoursesTab 2025-06-13 19:05:52 +03:00
Santeri Rajamäki f5b81d73ac [Frontend] Fix CourseStatistics resetState on empty URL params 2025-06-13 17:08:07 +03:00
Santeri Rajamäki b5cfc051b7 [Frontend] Consolidate PopulationCourse queries to one endpoint
This is long overdue.
2025-06-13 16:07:08 +03:00
Santeri Rajamäki eba3a77e2e [Frontend] Use the only query present at CoursesTab/index.jsx
This is a patch, since the query was present even though never used.
The patch should be replaced as mentioned in #4833
2025-06-13 15:42:38 +03:00
Santeri Rajamäki a20bdb5723 [Frontend] Move courses fetching to PopulationStatistics root
- Rework `redux/populationSelectedStudentCourses` to use RTKQuery
- Move primitive implementation of the query from `PopulationDetails/PopulationCourses/` to `PopulationStatistics/index.tsx`
2025-06-13 14:24:44 +03:00
Santeri Rajamäki 4544d1cdec [Frontend] Small optimizations to course filter 2025-06-12 22:23:02 +03:00
Santeri Rajamäki 072360763f Update cypress a bit more 2025-06-12 19:44:10 +03:00
Santeri Rajamäki 639858f874 Update cypress to the new reality 2025-06-12 19:27:11 +03:00
Santeri Rajamäki 92f3e67293 [Frontend] Make filters render faster
Huge performance gains!?
2025-06-12 18:26:39 +03:00
Santeri Rajamäki 983390040e [Frontend] Massively improve render times for some filter components
This tweak improves the render time of FilterTray by 5 fold.
2025-06-12 17:05:40 +03:00
Santeri Rajamäki 277b97327e Update CI serve version to 14.x
This hasn't been updated and was hardcoded to 12.0.0.
The is no comment on why it *shouldn't* be updated.
2025-06-12 15:16:47 +03:00
Santeri Rajamäki 3dab357396 Fix skill issue 2025-06-12 15:15:25 +03:00
Santeri Rajamäki a39648f380 [Frontend] Offload cheap calculatoin to child components
This avoids some prop drilling and has positive performance impact at least in development.
2025-06-12 15:03:16 +03:00
Santeri Rajamäki ccb1865853 Update sentry to version 9.28
Closes #4830
2025-06-11 14:00:01 +03:00
Santeri Rajamäki 81da089798 Update scheduler to version 0.26
Closes #4725
2025-06-11 13:56:41 +03:00
Santeri Rajamäki 785b7d5d74 Update minor version of dependencies
Closes #4762
Closes #4783
2025-06-10 17:51:07 +03:00
Santeri Rajamäki aca70d5442 Revert optimistic eslint fixes 2025-06-10 15:29:47 +03:00
Santeri Rajamäki 41d93c9b43 Fix lint errors triggered by linter updates 2025-06-10 14:33:03 +03:00
Santeri Rajamäki 835c9e580b Update lint tools 2025-06-09 16:38:39 +03:00
Santeri Rajamäki 9c9f317ecb Update eslint-import-resolver-typescript to version 4.4
Cloess #4769
2025-06-09 16:19:08 +03:00
Santeri Rajamäki 3ee6758c72 Update eslint-plugin-cypress to version 5.x
Closes #4826
2025-06-09 16:16:49 +03:00
Santeri Rajamäki 88764f83de Update react-router to version 7.5
Closes #4794
2025-06-09 14:54:57 +03:00
Santeri Rajamäki e8908d28fc Update axios to version 1.9
Closes #4791
2025-06-09 14:54:02 +03:00
Santeri Rajamäki a6d7da315f Update sentry monorepo to versoin 9.x
Closes #4736
2025-06-09 14:45:04 +03:00
santeri0200andGitHub e72e0bb5ad Merge pull request #4821 from UniversityOfHelsinkiCS/filtering
[Frontend] Filtering cleanup
2025-06-09 13:43:22 +03:00
Santeri Rajamäki 15b0286eda [Frontend] Force failure-state instead of crash with faulty courses 2025-06-09 13:32:55 +03:00
Santeri Rajamäki 1104483cac [Frontend] Simplified filter action and selector structure 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 604bcf4998 [Frontend] Remove actions and selectors from filters
Actions and selectors are still attached to the filter factories
2025-06-09 13:32:42 +03:00
Santeri Rajamäki cc839a9e14 [Frontend] Removed extra parameter from filter calls 2025-06-09 13:32:42 +03:00
Santeri Rajamäki 2a076ef6e8 [Frontend] Täsäfy rest of the FilterView 2025-06-09 13:32:42 +03:00
Santeri Rajamäki b05e1c22e5 [Frontend] Minor improvments to filters and filter context 2025-06-09 13:32:26 +03:00
Santeri Rajamäki b9800c857f [Frontend] Converted all filter components to render functions
This should be non-breaking as all the components were functions anyways
2025-06-09 13:31:33 +03:00
Santeri Rajamäki 6d535bf4bb [Frontend] Täsäfy filters
Added FilterTrayProps to FilterTray.tsx
Fixed Filter context typings
Reworked createFilter types
Also reword createFilter to work a little better
2025-06-09 13:30:55 +03:00
joonas-a 714f85d5c0 [Updater-worker] fix winston-sentry 2025-06-07 01:38:04 +03:00
joonas-a 01af4cfd2c [Updater-worker] ES6 2025-06-07 01:02:11 +03:00
Santeri Rajamäki fe55b770e9 [Updater] Update umzug to v3
Closes #4610
2025-06-06 15:25:17 +03:00
Santeri Rajamäki 43e4e605d6 [Cypress] Make OpenUni test use pathname and params instead of hardcoded path 2025-06-06 15:09:52 +03:00
Santeri Rajamäki 15a54f1e26 [Frontend] Replace query-string dependency with two typed utility functions 2025-06-06 14:35:00 +03:00
Santeri Rajamäki 631b5be00a Update shared model definition for SISStudyRight model 2025-06-06 12:01:53 +03:00
joonas-a b8d9648b8c [Student statistics] Infer studyright state from validity period if applicable 2025-06-06 01:22:30 +03:00
joonas-a d52562330b [Backend] Add studyright expiration urns to db model 2025-06-06 00:18:51 +03:00
joonas-a f4c9137a05 [Updater] Add study right expiration rule urns 2025-06-05 23:48:46 +03:00
santeri0200andGitHub 9a1802b6b4 [Frontend] Populus optimus (#4824)
* [Frontend] Täsäfy PopulationStatistics

* [Frontend] Reorganized PopulationStatistics

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

* Convert to RTKQuery for populationStatistics

* Removed useSelector for state.population

* Reset PopulationsStatistics params based on the new query param formatting

* Format semesters to array instead of spreading strings

* Force array formating with single key values

* Stop lying to the users
Progressbar is no more
2025-06-05 16:04:40 +03:00
Santeri Rajamäki fd2d95a832 [Frontend] Format RTKApi query params in Express friendly manner 2025-06-04 19:27:10 +03:00
Joonas Ahovalli dfb557d4e1 hotfix: undefined studyright.... 2025-05-30 16:37:11 +03:00
Joonas Ahovalli 22e2ccf562 [MRT] Adjust default col width + minor changes
Have admissiontype be either value or null, no boolean
Hide email and phone from table, show only in excel
2025-05-30 15:52:57 +03:00
Joonas Ahovalli 68480a7211 [General tab] Add new column for TVEX bitti 2025-05-30 15:25:13 +03:00
Joonas Ahovalli bb4edef070 [Backend] Fetch tvex info along with studyrights 2025-05-30 15:20:04 +03:00
Joonas Ahovalli 6877716468 [Updater] Add tvex info to sis_study_rights 2025-05-30 13:18:47 +03:00
Santeri Rajamäki a21e91fbaa [Updater] added loki 2025-05-30 12:03:36 +03:00
836 changed files with 31399 additions and 29152 deletions
-82
View File
@@ -1,82 +0,0 @@
version: '2'
checks:
argument-count:
enabled: true
config:
threshold: 4
complex-logic:
enabled: true
config:
threshold: 4
file-lines:
enabled: true
config:
threshold: 250
method-complexity:
enabled: true
config:
threshold: 10
method-count:
enabled: true
config:
threshold: 20
method-lines:
enabled: true
config:
threshold: 25
nested-control-flow:
enabled: true
config:
threshold: 4
return-statements:
enabled: false
similar-code:
enabled: true
config:
threshold: # Language-specific defaults. Overrides affect all languages.
identical-code:
enabled: true
config:
threshold: # Language-specific defaults. Overrides affect all languages.
exclude_patterns:
- '.github/'
- '.husky/'
- '.vscode/'
- 'adminer-plugins/'
- 'anonyymioodi/'
- 'cypress/'
- 'documentation/'
- 'scripts/'
- 'services/backend/src/database/migrations_kone/'
- 'services/backend/src/database/migrations_user/'
- 'services/backend/src/models/'
- 'updater/sis-updater-worker/src/db/migrations/'
- 'updater/sis-updater-worker/src/db/models/'
- '.codeclimate.yml'
- '.env.template'
- '.lintstagedrc.js'
- '.nycrc'
- '.prettierrc'
- '.stylelintrc'
- 'cypress.config.js'
- 'LICENSE'
- 'nginx.conf'
- 'nyc-config.js'
- '**/.dockerignore'
- '**/.eslintrc.json'
- '**/.gitignore'
- '**/dev.Dockerfile'
- '**/Dockerfile'
- '**/*.css'
- '**/*.csv'
- '**/*.html'
- '**/*.json'
- '**/*.md'
- '**/*.png'
- '**/*.sh'
- '**/*.sql'
- '**/*.svg'
- '**/*.txt'
- '**/*.yml'
+11 -4
View File
@@ -1,6 +1,13 @@
// ! If we don't have this file, running ESLint will fail because of the import/no-unused-modules rule
// ! See: https://github.com/import-js/eslint-plugin-import/issues/3079
// ! When this issue is resolved, this file can be removed
{
"ignorePatterns": ["**/node_modules/", "**/dist/", "**/build/"]
"ignorePatterns": [
"eslint.config.mjs",
"**/build/**",
"**/dist/**",
"**/node_modules/**",
"**/instrumented/**",
"**/coverage/**",
"nyc-config.cjs",
".lintstagedrc.mjs",
"cypress.config.js"
]
}
+2 -2
View File
@@ -4,9 +4,9 @@ description: 'Runs ESLint and Stylelint for frontend and backend'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
- name: 'Install dependencies'
shell: bash
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
+1 -1
View File
@@ -38,7 +38,7 @@ runs:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
+10 -10
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Get current time'
id: get_current_time
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -85,7 +85,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -100,9 +100,9 @@ jobs:
- name: Create Sentry release
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_URL: https://toska.it.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
SENTRY_ORG: toska
SENTRY_ORG: sentry
SENTRY_PROJECT: oodikone-frontend
with:
environment: production
@@ -119,7 +119,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -134,9 +134,9 @@ jobs:
- name: Create Sentry release
uses: getsentry/action-release@v3
env:
SENTRY_URL: https://toska.cs.helsinki.fi/
SENTRY_URL: https://toska.it.helsinki.fi/
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
SENTRY_ORG: toska
SENTRY_ORG: sentry
SENTRY_PROJECT: oodikone-backend
with:
environment: production
@@ -157,7 +157,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -178,7 +178,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -41,8 +41,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test degree programme overview'
spec: cypress/e2e/Degree_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
@@ -59,7 +59,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+5 -5
View File
@@ -18,7 +18,7 @@ on:
jobs:
test:
uses: ./.github/workflows/test.yaml
uses: UniversityOfHelsinkiCS/oodikone/.github/workflows/test.yaml@master
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
@@ -29,7 +29,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
@@ -64,7 +64,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build backend image
id: build-image
@@ -99,7 +99,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -118,7 +118,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+18 -15
View File
@@ -16,7 +16,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -25,7 +25,7 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build image and export it'
uses: docker/build-push-action@v6
@@ -41,7 +41,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: backend-image
path: /tmp/backend.tar
@@ -50,7 +50,7 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
@@ -76,7 +76,7 @@ jobs:
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -120,9 +120,9 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: backend-image
- name: 'Load built backend image'
@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: check_migrations
with:
@@ -157,7 +157,7 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: backend-image
- name: 'Load built backend image'
@@ -176,7 +176,6 @@ jobs:
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
@@ -199,8 +198,8 @@ jobs:
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test study programme overview'
spec: cypress/e2e/Study_programme_overview.js
- name: 'Test degree programme overview'
spec: cypress/e2e/Degree_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
@@ -215,9 +214,13 @@ jobs:
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
@@ -236,7 +239,7 @@ jobs:
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
+11 -11
View File
@@ -20,10 +20,10 @@ jobs:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 18
node-version: 24
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
@@ -33,7 +33,7 @@ jobs:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -45,7 +45,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: updater-worker-image
path: /tmp/worker.tar
@@ -54,7 +54,7 @@ jobs:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -66,7 +66,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
@@ -79,7 +79,7 @@ jobs:
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
@@ -124,7 +124,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build worker image
id: build-image
@@ -154,7 +154,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build scheduler image
id: build-image
+4 -4
View File
@@ -1,9 +1,10 @@
const path = require('path')
import { relative } from 'path'
const cwd = process.cwd()
const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodikone`
const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file))].join(' ')
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
module.exports = {
export default {
'{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',
@@ -13,7 +14,6 @@ module.exports = {
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
'docker-compose*': files => {
const composeFiles = file => {
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
+2 -1
View File
@@ -6,5 +6,6 @@
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"trailingComma": "es5"
"trailingComma": "es5",
"endOfLine": "lf"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": ["stylelint-config-standard"],
"rules": {
"selector-class-pattern": "^[a-z][a-zA-Z-]*$"
"selector-class-pattern": "^(?:[a-z][a-zA-Z-]*|Mui[A-Za-z-]+)$"
}
}
+1 -2
View File
@@ -1,4 +1,5 @@
# Oodikone
[![Maintainability](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone/maintainability.svg)](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
@@ -111,8 +112,6 @@ The `documentation` directory contains some notes on the sis-db schema and is me
For internal meeting notes and sensitive information, see Toska GitLab.
[SortableTable documentation](./services/frontend/src/components/SortableTable/README.md)
### Basics
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for Oodikone's two main components: **oodikone** and **updater**.
+1 -1
View File
@@ -60,7 +60,7 @@ set_custom_select_prompt() {
# Run scripts and set the prompt
show_welcome
init_dirs
get_username
get_s3_config
set_custom_select_prompt
while true; do
+3 -4
View File
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-commonjs */
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable import/no-unused-modules */
/* eslint-disable import-x/no-extraneous-dependencies */
/* eslint-disable import-x/no-default-export */
const { defineConfig } = require('cypress')
module.exports = defineConfig({
experimentalStudio: process.env.NODE_ENV === 'development',
projectId: 'c3jsph',
defaultCommandTimeout: 30000,
requestTimeout: 30000,
+30 -30
View File
@@ -17,32 +17,32 @@ const openSearch = () => {
}
const selectSavedCourselist = name => {
cy.get('[data-cy=history-search]').click()
cy.get('[data-cy=history-search]').type(name)
cy.get('[data-cy=history-search]').type('{downarrow}')
cy.get('[data-cy=history-search]').type('{enter}')
cy.cs('history-search').click()
cy.cs('history-search').type(name)
cy.cs('history-search').type('{downarrow}')
cy.cs('history-search').type('{enter}')
}
const createCourseList = (courseCodes, courseListName) => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
cy.get('[data-cy=search-name]').type(courseListName)
cy.get('[data-cy=save-courselist]').click()
cy.cs('course-list-input').type(courseCodes.join('\n'))
cy.cs('search-name').type(courseListName)
cy.cs('save-courselist').click()
}
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
const deleteAllPreviousSearches = () => {
cy.get('[data-cy=history-search]').click()
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper li').then($options => {
if ($options.length > 0) {
cy.wrap($options).each(($option, index, $list) => {
cy.wrap($option).click()
cy.get('[data-cy=delete-courselist]').click()
cy.cs('delete-courselist').click()
if (index < $list.length - 1) {
cy.get('[data-cy=history-search]').click()
cy.cs('history-search').click()
}
})
} else {
@@ -57,7 +57,7 @@ describe('When search modal is opened', () => {
hasLanded()
cy.url().should('include', '/completedcoursessearch')
cy.contains('Search completed courses of students')
cy.get('[data-cy=open-completed-courses-modal-button]').click()
cy.cs('open-completed-courses-modal-button').click()
})
it('Modal opens correctly', () => {
@@ -74,8 +74,8 @@ describe('When search modal is opened', () => {
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
hasLanded()
openCompletedCoursesModal()
cy.contains('[data-cy=student-no-input]', '433237, 457144')
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
cy.cs('student-no-input').contains('433237, 457144')
cy.cs('course-list-input').contains('TKT10001, TKT10002')
})
describe('When a search is executed with invalid input', () => {
@@ -83,16 +83,16 @@ describe('When search modal is opened', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.get('[data-cy=student-no-input]').type('1')
cy.get('[data-cy=course-list-input]').type('1')
cy.get('[data-cy=completed-courses-search-button]').click()
cy.get('[data-cy=rights-notification]').should(
cy.cs('student-no-input').type('1')
cy.cs('course-list-input').type('1')
cy.cs('completed-courses-search-button').click()
cy.cs('rights-notification').should(
'contain.text',
'The information for the following students could not be displayed'
)
cy.contains('[data-cy=rights-notification]', '1')
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
cy.cs('rights-notification').contains('1')
cy.cs('completed-courses-table-div').contains('Student number')
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
})
})
@@ -102,9 +102,9 @@ describe('When search modal is opened', () => {
hasLanded()
openCompletedCoursesModal()
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
cy.get('[data-cy=completed-courses-search-button]').click()
cy.cs('student-no-input').type(studentSet1.join('\n'))
cy.cs('course-list-input').type(coursesSet1.join('\n'))
cy.cs('completed-courses-search-button').click()
})
})
@@ -120,10 +120,10 @@ describe('When search modal is opened', () => {
it('Finds correct students and courses', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
for (const studentNumber of studentSet1) {
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
cy.cs('completed-courses-table-div').contains(studentNumber)
}
for (const courseCode of coursesSet1) {
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
cy.cs('completed-courses-table-div').contains(courseCode)
}
})
})
@@ -150,7 +150,7 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', courses.join(', '))
cy.cs('course-list-input').contains(courses.join(', '))
})
it('Course list can be deleted', () => {
@@ -159,7 +159,7 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.get('[data-cy=delete-courselist]').click()
cy.cs('delete-courselist').click()
cy.contains('You have no previous searches.')
})
@@ -169,11 +169,11 @@ describe('Courselist saving-related functions work', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.get('[data-cy=course-list-input]').type(',TKT10001')
cy.get('[data-cy=save-courselist]').click()
cy.cs('course-list-input').type(',TKT10001')
cy.cs('save-courselist').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
})
})
File diff suppressed because it is too large Load Diff
+25 -40
View File
@@ -1,13 +1,9 @@
/// <reference types="cypress" />
const openCustomPopupForm = () => {
cy.get('[data-cy="custom-pop-search-button"]').click()
}
const fillName = () => {
const name = `TEST-${new Date().getTime()}`
cy.contains('Insert name for this custom population if you wish to save it')
cy.get('[data-cy="custom-population-name-input"]').type(name)
cy.contains('Insert a name for this custom population if you wish to save it')
cy.cs('custom-population-name-input').type(name)
return name
}
@@ -16,38 +12,33 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${name}{enter}`)
cy.cs('history-search').click().type(`${name}{enter}`)
}
const deleteAllSearches = () => {
cy.get('[data-cy="history-search"]').children().eq(0).click()
cy.contains('Saved populations')
.parent()
.parent()
.parent()
.parent()
.get('.dropdown')
.then(dropdown => {
const searchItems = dropdown.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
cy.get('button').contains('Delete').click()
}
}
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
.find('li[role=option]')
.then($lis => {
const labels = [...$lis].map(li => li.textContent?.trim() || '')
labels.forEach((label, index) => {
if (index !== 0) cy.cs('history-search').click()
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
cy.contains('button', 'Delete').click()
})
})
}
const fillForm = (content, separator) => {
cy.get('[data-cy="student-number-input"]').type(content.join(separator))
cy.cs('student-number-input').type(content.join(separator))
}
const search = () => {
cy.get('[data-cy="search-button"]').click()
cy.cs('search-button').click()
}
const searchFor = (studentnumbers, separator) => {
openCustomPopupForm()
fillForm(studentnumbers, separator)
search()
}
@@ -74,7 +65,7 @@ const containsSpecificStudents = (studentnumbers = []) => {
}
const checkRightsNotification = studentNumbers => {
cy.get('[data-cy="rights-notification"]').within(() => {
cy.cs('rights-notification').within(() => {
cy.contains(
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
)
@@ -96,7 +87,6 @@ describe('Custom population tests', () => {
cy.visit('/custompopulation')
cy.url().should('include', '/custompopulation')
cy.contains('Custom population')
cy.get('button').contains('Custom population').click()
deleteAllSearches()
})
@@ -126,7 +116,7 @@ describe('Custom population tests', () => {
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Custom population')
cy.get('button').contains('Back to search form')
})
it("Doesn't return students user has no right to", () => {
@@ -138,16 +128,15 @@ describe('Custom population tests', () => {
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Custom population')
cy.get('button').contains('Back to search form')
})
})
describe('Custom population search saving', () => {
it('Saves a custom population search', { retries: 2 }, () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
openCustomPopupForm()
cy.contains(
'Insert student numbers you wish to use for population. Separate each number with a comma, semicolon, space, or newline.'
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
)
const name = fillName()
// A comma and a newline on purpose, the page should be able to handle it
@@ -157,17 +146,16 @@ describe('Custom population tests', () => {
// Round 1
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
// Round 2
cy.visit('/custompopulation')
cy.contains('Custom population')
openCustomPopupForm()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
})
@@ -176,28 +164,25 @@ describe('Custom population tests', () => {
it('Updates a custom population search', () => {
cy.fixture('customPopulations').then(
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
openCustomPopupForm()
const name = fillName()
fillForm(students1, ' ')
save()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length)
containsSpecificStudents(students1)
cy.contains('Custom population')
openCustomPopupForm()
cy.contains('Back to search form').click()
selectSavedPopulation(name)
fillForm(['\n', ...students2], ', ')
save()
cy.visit('/custompopulation')
cy.contains('Custom population')
openCustomPopupForm()
selectSavedPopulation(name)
search()
cy.contains(`Custom population "${name}"`)
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length + students2.length)
containsSpecificStudents([...students1, ...students2])
}
@@ -1,7 +1,19 @@
/* eslint-disable cypress/unsafe-to-chain-command */
/// <reference types="cypress" />
const { getEmptyYears } = require('../support/commands')
const getEmptyYears = isAcademicYear => {
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
const tagName = `tag-${new Date().getTime()}`
@@ -18,8 +30,8 @@ const deleteTag = tagName => {
cy.cs('confirm-delete-tag-button').click()
}
describe('Study programme overview', () => {
describe('Study programme selector', () => {
describe('Degree programme overview', () => {
describe('Degree programme selector', () => {
beforeEach(() => {
cy.init('/study-programme', 'admin')
})
@@ -31,7 +43,7 @@ describe('Study programme overview', () => {
cy.contains('Doctoral programmes')
})
it('Study programme search filter', () => {
it('Degree 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')
@@ -272,9 +284,13 @@ describe('Study programme overview', () => {
describe('Study tracks and class statistics tab works for basic user', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('StudyTracksAndClassStatisticsTab').click()
})
describe('Info boxes', () => {
@@ -301,7 +317,7 @@ describe('Study programme overview', () => {
cy.cs('average-graduation-times-section')
})
it('Students of the study programme are shown correctly', () => {
it('Students of the degree programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
@@ -357,13 +373,13 @@ describe('Study programme overview', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
cy.contains('class size 8 students')
cy.contains('Class size 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 227 students')
cy.contains('Class size 227 students')
})
it('Links to class statistics page with study track info included work', () => {
@@ -373,9 +389,9 @@ describe('Study programme overview', () => {
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 26 students')
cy.contains('3 students out of 26 shown')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
@@ -437,8 +453,9 @@ describe('Study programme overview', () => {
describe('Study track can be changed', () => {
beforeEach(() => {
cy.cs('study-track-select').click()
cy.wait('@stQuery')
cy.contains('All students of the programme')
cy.cs('study-track-select').click()
cy.contains('Ekonometria')
cy.contains('Tietojenkäsittelyteoria')
cy.contains('Tilastotiede')
@@ -464,14 +481,14 @@ describe('Study programme overview', () => {
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('studytrack MAT-MAT')
cy.contains('class size 30 students')
cy.contains('10 students out of 30 shown')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class size 30 students')
cy.contains('Showing 10 out of 30 students')
})
it('Info message about missing progress stats is displayed', () => {
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the study programme.')
cy.contains('Progress data is currently only available for all students of the degree programme.')
})
it.skip('Average graduation times are displayed correctly', () => {
@@ -502,7 +519,7 @@ describe('Study programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('programme-courses-tab').click()
cy.cs('ProgrammeCoursesTab').click()
cy.cs('by-credit-type-section')
cy.cs('by-credit-type-tab')
@@ -664,6 +681,7 @@ describe('Study programme overview', () => {
cy.cs('by-semester-tab').click()
})
// FIXME: Flaky
it('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
@@ -675,7 +693,7 @@ describe('Study programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('degree-courses-tab').click()
cy.cs('DegreeCoursesTab').click()
})
it('Curriculum section', () => {
@@ -711,7 +729,7 @@ describe('Study programme overview', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('tags-tab').click()
cy.cs('TagsTab').click()
})
it('info box', () => {
@@ -721,13 +739,13 @@ describe('Study programme overview', () => {
describe('Adding tags to populations and removing them works', () => {
beforeEach(() => {
cy.contains('This study programme does not have any tags yet')
cy.contains('This degree programme does not have any tags yet')
cy.cs('tag-name-text-field').type(tagName)
})
afterEach(() => {
deleteTag(tagName)
cy.contains('This study programme does not have any tags yet')
cy.contains('This degree programme does not have any tags yet')
})
it('can create and delete tags without start year', () => {
@@ -838,7 +856,7 @@ describe('Study programme overview', () => {
})
it('can access basic information', () => {
cy.cs('basic-information-tab').click()
cy.cs('BasicInformationTab').click()
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
@@ -848,7 +866,7 @@ describe('Study programme overview', () => {
})
it('can access study tracks', () => {
cy.cs('study-tracks-and-class-statistics-tab').click()
cy.cs('StudyTracksAndClassStatisticsTab').click()
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
@@ -856,9 +874,9 @@ describe('Study programme overview', () => {
})
it("doesn't see other tabs", () => {
cy.cs('degree-courses-tab').should('not.exist')
cy.cs('tags-tab').should('not.exist')
cy.cs('update-statistics-tab').should('not.exist')
cy.cs('DegreeCoursesTab').should('not.exist')
cy.cs('TagsTab').should('not.exist')
cy.cs('UpdateStatisticsTab').should('not.exist')
})
})
})
+1 -1
View File
@@ -273,7 +273,7 @@ describe('Faculty statistics', () => {
// TODO: Implement
})
it.skip('Study programme toggle', () => {
it.skip('Degree programme toggle', () => {
// TODO: Implement
})
+396 -392
View File
@@ -1,464 +1,468 @@
/// <reference types="cypress" />
const MOCKED_DATE = new Date('2024-08-30').getTime()
const setClockToMockedDate = () => {
const MOCKED_DATE = new Date('2024-08-30')
cy.clock(MOCKED_DATE, ['Date'])
}
const checkFilteringResult = studentCount => {
const checkFilteredStudentCount = studentCount => {
cy.contains(`Students (${studentCount})`)
}
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
// This function tries to click "x" on all selections inside dropdown matching given attribute
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
cy.cs(dataCyAttribute).click()
}
const clearSingleDropdownSelection = dataCyAttribute => {
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
}
const testRangeFilter = (parentEl, min, max, expected) => {
cy.cs(parentEl)
.cs('range-selector-min')
.find('input')
.invoke('val')
.then(initialMin => {
cy.cs(parentEl)
.cs('range-selector-max')
.find('input')
.invoke('val')
.then(initialMax => {
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
cy.cs(parentEl).cs('range-selector-min').find('input').type(min)
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
cy.cs(parentEl).cs('range-selector-max').find('input').type(max)
checkFilteringResult(expected)
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
cy.cs(parentEl).cs('range-selector-min').find('input').type(initialMin)
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
cy.cs(parentEl).cs('range-selector-max').find('input').type(initialMax)
})
})
}
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
return (filterName, testStepFunctionToRun) => {
cy.cs(`${filterName}-filter-card`)
.invoke('attr', 'data-open')
.then(open => {
const getHeader = () => cy.cs(`${filterName}-header`)
if (open === 'false') {
getHeader().click({ force: true })
}
checkFilteringResult(amountWithoutFiltering)
testStepFunctionToRun()
checkFilteringResult(amountWithoutFiltering)
if (open === 'false') {
getHeader().click({ force: true })
}
})
}
}
describe("Population statistics with a master's programme", () => {
const pathToMathMSc2020 =
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"MH50_001"%7D&year=2020'
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 26
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToMathMSc2020)
})
const expectedBaMaStudents = 24
const expectedMasterOnlyStudents = 2
before(() => cy.init(pathToMathMSc2020))
it('Study track filter works', () => {
runTestStepWithPreAndPostParts('StudyTrack', () => {
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
checkFilteringResult(15)
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
})
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 0, true)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
cy.cs('studyTrackFilter-header').click()
})
describe('Study right type filter', () => {
it('is visible', () => {
cy.contains('Study right type')
})
it('Study right type filter works', () => {
cy.cs('studyRightTypeFilter-header').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
it('is set to all by default', () => {
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
})
})
// Bachelor + master
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(expectedBaMaStudents)
// Master only
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(expectedMasterOnlyStudents)
// All
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
it('works', () => {
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
const expectedBaMaStudents = 24
const expectedMasterOnlyStudents = 2
cy.cs('studyRightTypeFilter-filter-card').cs('bama').click()
checkFilteringResult(expectedBaMaStudents)
cy.cs('studyRightTypeFilter-filter-card').cs('master').click()
checkFilteringResult(expectedMasterOnlyStudents)
cy.cs('studyRightTypeFilter-filter-card').cs('all').click()
checkFilteringResult(defaultAmountOfStudents)
})
})
cy.cs('studyRightTypeFilter-header').click()
})
})
describe('Population Statistics', () => {
const pathToMathBSc2020 =
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"KH50_001"%7D&year=2020'
describe.skip('Population Statistics', { testIsolation: false }, () => {
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 27
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
const graduated = 16
const transferred = 3
beforeEach(() => {
cy.init(pathToMathBSc2020)
})
before(() => cy.init(pathToMathBSc2020))
beforeEach(() => setClockToMockedDate())
it("Transfer filter is set to 'not transferred' by default", () => {
cy.contains('By default only students who have not transferred to this study programme are shown.')
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
})
})
it('Graduation filter works', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
const graduated = 16
getCard().cs('option-graduated-true').click()
checkFilteringResult(graduated)
getCard().cs('option-graduated-false').click()
checkFilteringResult(defaultAmountOfStudents - graduated)
getCard().cs('option-all').click()
})
})
it('Transfer filter works', () => {
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
const transferred = 3
cy.cs('TransferredToProgramme-filter-card').cs('option-have').click()
checkFilteringResult(transferred)
cy.cs('TransferredToProgramme-filter-card').cs('option-all').click()
checkFilteringResult(defaultAmountOfStudents + transferred)
cy.cs('TransferredToProgramme-filter-card').cs('option-havenot').click()
})
})
it('Enrollment filter works', () => {
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
checkFilteringResult(25)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
})
it('Credit filter works', { retries: 3 }, () => {
runTestStepWithPreAndPostParts('CreditsEarned', () => {
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
})
})
it('Age filter works', { retries: 3 }, () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 23, 30, 21)
})
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(14)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
checkFilteringResult(21)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
})
it('Admission type filter works', () => {
runTestStepWithPreAndPostParts('AdmissionType', () => {
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(18)
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(6)
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
})
})
it('Courses filter works', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
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()
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()
})
})
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
runTestStepWithPreAndPostParts('Age', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
getCard().cs('option-graduated-true').click()
testRangeFilter('Age-filter-card', 23, 30, 12)
getCard().cs('option-all').click()
})
})
})
it('"Reset All Filters" button works', { retries: 3 }, () => {
cy.cs('Gender-header').click()
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(13)
cy.cs('StartYearAtUni-header').click()
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
checkFilteringResult(8)
cy.cs('reset-all-filters').click()
checkFilteringResult(defaultAmountOfStudents)
})
it('Study right type filter is not visible', () => {
it.skip('Study right type filter is not visible', () => {
cy.contains('Study right type').should('not.exist')
})
})
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)
// TODO: FilterRadio is not working correcly
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToMathBSc2017)
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
checkFilteredStudentCount(transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Graduation filter works', () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
cy.cs('graduatedFromProgrammeFilter-header').click()
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()
})
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
})
it('Study right status filter works', () => {
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
it('Enrollment filter works', () => {
cy.cs('enrollmentStatusFilter-header').click()
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
checkFilteredStudentCount(25)
// 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'
const defaultAmountOfStudents = 48
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
beforeEach(() => {
cy.init(pathToLimits2021)
cy.cs('enrollmentStatusFilter-clear').click()
cy.cs('enrollmentStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Grade filter works', () => {
runTestStepWithPreAndPostParts('Grade', () => {
cy.cs('gradeFilter-5').click()
checkFilteringResult(23)
cy.cs('gradeFilter-3').click()
checkFilteringResult(29)
cy.cs('gradeFilter-3').click()
cy.cs('gradeFilter-5').click()
})
it('Credit filter works', () => {
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
cy.cs('creditsEarnedFilter-clear').click()
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme defaults to "Attainment" mode', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
})
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 21)
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(33)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
})
it('Age filter works', { retries: 3 }, () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 21, 30, 36)
})
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(9)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(39)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(14)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(1)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
cy.cs('startYearAtUni-dropdown').selectFromDropdown(10)
checkFilteringResult(24)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(21)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Admission type filter works', () => {
cy.cs('admissionTypeFilter-header').click()
cy.selectFromDropdown('admissionTypeFilter', 0)
checkFilteredStudentCount(18)
cy.selectFromDropdown('admissionTypeFilter', 1)
checkFilteredStudentCount(6)
cy.cs('admissionTypeFilter-clear').click()
cy.cs('admissionTypeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-header').click()
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(9)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(7)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
checkFilteredStudentCount(4)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(7)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Grade', () => {
runTestStepWithPreAndPostParts('Age', () => {
cy.cs('gradeFilter-5').click()
checkFilteringResult(23)
testRangeFilter('Age-filter-card', 21, 30, 17)
cy.cs('gradeFilter-5').click()
})
})
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 23, 30, 12)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('"Reset All Filters" button works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(8)
cy.cs('reset-all-filters').click()
cy.cs('genderFilter-header').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Custom Population Statistics', () => {
let runTestStepWithPreAndPostParts
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
const pathToMathBSc2017 =
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 47
beforeEach(() => {
before(() => cy.init(pathToMathBSc2017))
it('Graduation filter works', () => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(42)
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(18)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(5)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
checkFilteredStudentCount(29)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Study right status filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('studyRightStatusFilter-header').click()
// Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Non-Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(5)
// Non-Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
checkFilteredStudentCount(24)
cy.cs('studyRightStatusFilter-clear').click()
cy.cs('studyRightStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
// FIXME: Flaky
describe.skip('Course Statistics', { testIsolation: false }, () => {
const pathToLimits2021 =
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
const defaultAmountOfStudents = 48
before(() => cy.init(pathToLimits2021))
beforeEach(() => setClockToMockedDate())
it('Grade filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(23)
cy.cs('gradeFilter-3').click()
checkFilteredStudentCount(29)
cy.cs('gradeFilter-3').click()
cy.cs('gradeFilter-5').click()
cy.cs('gradeFilter-clear').should('not.exist')
cy.cs('gradeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme filter works and defaults to "Attainment"', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(33)
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(9)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(39)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 10)
checkFilteredStudentCount(24)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(17)
cy.cs('gradeFilter-clear').click()
cy.cs('gradeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Custom Population Statistics', { testIsolation: false }, () => {
let defaultAmountOfStudents
before(() => {
cy.init('/custompopulation')
cy.cs('custom-pop-search-button').click()
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
defaultAmountOfStudents = students.length
cy.cs('student-number-input').click()
cy.cs('student-number-input').type(students.join('{enter}'))
cy.cs('search-button').click()
})
})
beforeEach(() => setClockToMockedDate())
it('Age filter works', () => {
cy.clock(MOCKED_DATE, ['Date'])
runTestStepWithPreAndPostParts('Age', () => {
testRangeFilter('Age-filter-card', 24, 28, 5)
})
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 24, 28, 5)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme defaults to "Active Study Right" mode', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
})
})
it('Programme filter works and defaults to "Active Study Right"', () => {
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(3)
it('Programme filter works', () => {
runTestStepWithPreAndPostParts('Programme', () => {
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
})
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
runTestStepWithPreAndPostParts('Gender', () => {
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
checkFilteringResult(5)
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
checkFilteringResult(3)
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
checkFilteringResult(0)
clearSingleDropdownSelection('genderFilter-dropdown')
})
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(5)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(3)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
checkFilteringResult(2)
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
})
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(2)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
runTestStepWithPreAndPostParts('Courses', () => {
const courses = [
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
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()
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()
})
})
const courses = [
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
cy.cs('courseFilter-header').click()
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(7)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(6)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(2)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(6)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
+19 -53
View File
@@ -1,43 +1,23 @@
/// <reference types="cypress" />
const hasLanded = () => {
cy.contains('This view displays amounts')
cy.contains('Show number of')
}
const chooseSemester = (semester, fromOrTo) => {
cy.get(`[data-cy="semester-${fromOrTo}"]`)
.click()
.within(() => {
cy.get('div.visible.menu.transition').within(() => {
cy.contains('div.item', semester).click()
})
cy.get('div.menu.transition').should('not.have.class', 'visible')
})
cy.cs(`semester-${fromOrTo}`).click()
cy.cs(`select-opt-${semester}`).click()
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
}
const checkNumbers = (numbers, numberOfColumns, mode) => {
const offset = mode === 'semesters' ? 2 : 1
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', numberOfColumns)
cy.get('td').eq(0).contains('All courses total')
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + offset)
.contains(number)
})
})
}
const offset = 1 // skip first col with codes / names
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
cy.get('@tbody').find('tr').first().as('totalRow')
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
const checkStyle = (styles, numberOfColumns) => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', numberOfColumns)
styles.forEach((style, index) => {
cy.get('td')
.eq(index + 2)
.should('have.attr', 'style')
.and('include', style)
})
numbers.forEach((number, index) => {
cy.get('@totalRow')
.find('td')
.eq(index + offset)
.should('contain', number)
})
}
@@ -45,8 +25,7 @@ describe('When language center is opened', () => {
describe('as an admin user', () => {
beforeEach(() => {
cy.init('/languagecenterview', 'admin')
cy.get('[data-cy="completions-button"]').click()
hasLanded()
cy.cs('completions-button').click()
})
describe('Faculties tab', () => {
@@ -65,7 +44,7 @@ describe('When language center is opened', () => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', 15)
cy.get('td').eq(0).contains('All courses total')
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + 1)
@@ -75,13 +54,13 @@ 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, 221], 15)
cy.cs('difference-button').click()
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
})
})
@@ -93,20 +72,7 @@ describe('When language center is opened', () => {
})
it('Semester tab shows numbers', () => {
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
})
it('Coloring mode works on semester tab', () => {
cy.contains('Compare to average of course').click()
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
checkStyle(
expectedAlphas.map(alpha => `background-color: rgba(0, 170, 0, ${alpha})`),
17
)
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').eq(16).should('have.attr', 'style').and('not.include', 'background-color')
})
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
})
})
})
+38 -28
View File
@@ -1,24 +1,23 @@
/// <reference types="cypress" />
const deleteAllSearches = () => {
cy.contains('Saved populations')
.parent()
.parent()
.parent()
.parent()
.get('.dropdown')
.then(dropdown => {
const searchItems = dropdown.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
cy.get('button').contains('Delete').click()
}
cy.cs('open-uni-search-button').click()
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
cy.get('@search').then($el => {
const searchItems = $el.find('div[role=option] > span[class=text]')
for (let i = searchItems.length - 1; 0 <= i; i--) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
cy.get('button').contains('Delete').click()
}
})
}
})
}
describe('Open uni population tests', () => {
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
// removed alltogether.
describe.skip('Open uni population tests', () => {
describe('Open uni form can be used', () => {
// When openUniUser is created, change it here
beforeEach(() => {
@@ -26,27 +25,37 @@ describe('Open uni population tests', () => {
})
after(() => {
cy.init('/openunipopulation', 'admin')
cy.url().should('include', '/openunipopulation')
cy.contains('Open uni student population')
cy.get('[data-cy="open-uni-search-button"]').click()
deleteAllSearches()
})
it('Finds a proper population', () => {
// TODO: Rewrite the whole component
it.skip('Finds a proper population', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="begin-of-search"]').clear().type('01.01.2015')
cy.get('[data-cy="end-of-search"]').clear().type('01.01.2020')
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
cy.get('@textarea').type(courseCodesSet1.join('\n'))
cy.cs('begin-of-search').click()
cy.cs('begin-of-search').clear()
cy.cs('begin-of-search').type('01.01.2015')
cy.cs('end-of-search').click()
cy.cs('end-of-search').clear()
cy.cs('end-of-search').type('01.01.2020')
cy.get('button').contains('Search population').click()
cy.contains('Beginning of the search for all fields:')
cy.contains('01.01.2015')
cy.contains('End of the search for enrollments:')
cy.contains('01.01.2020')
cy.url().should(
'include',
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
)
cy.location('pathname').should('eq', '/openunipopulation')
cy.location('search').then(search => {
const params = new URLSearchParams(search)
const courseCodes = params.getAll('courseCode')
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
expect(params.get('startdate')).to.eq('01-01-2015')
expect(params.get('enddate')).to.eq('01-01-2020')
})
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
@@ -73,9 +82,10 @@ describe('Open uni population tests', () => {
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="save-search"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('TEST-Avoimen-populaatio')
cy.get('button').contains('Search population').click()
cy.get('button').contains('Search population').as('btn')
cy.get('@btn').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
@@ -92,7 +102,7 @@ describe('Open uni population tests', () => {
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
cy.get('[data-cy="open-uni-search-button"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
+106 -110
View File
@@ -2,20 +2,24 @@
// Now "Class statistics" in UI
const selectStudyProgramme = programme => {
cy.contains('Select study programme')
cy.get('[data-cy=select-study-programme]').click()
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
}
const selectStudyTrack = studyTrack => {
cy.contains('Select study track')
cy.get('[data-cy=select-study-track]').click()
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
cy.cs('population-studytrack-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
)
cy.cs('population-studytrack-selector-parent').click()
cy.cs('population-studytrack-selector-parent').within(() => cy.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`
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
}
const pathToMathBSc2020 = getPath('KH50_001')
const pathToMathMSc2020 = getPath('MH50_001')
@@ -23,37 +27,40 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
it('Info box works', () => {
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
cy.get('[data-cy="PopulationSearch-open-info"]').click()
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
cy.get('[data-cy="PopulationSearch-info-content"]').should('contain', 'Tässä osiossa voi tarkastella')
cy.get('[data-cy="PopulationSearch-close-info"]').click()
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
cy.cs('PopulationSearch-info-box-content').should('not.exist')
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
cy.cs('PopulationSearch-info-box-content').should('be.visible')
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
cy.cs('PopulationSearch-info-box-content').should('not.exist')
})
it('Form is usable', () => {
// TODO: Delete MUI
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
cy.contains('Search for class')
cy.contains('See class').should('be.disabled')
cy.contains('Class of')
.parent()
.within(() => {
cy.get('.form-control').as('enrollmentSelect')
})
cy.get('@enrollmentSelect')
.its(`${[0]}.value`)
.then(beforeVal => {
cy.get('@enrollmentSelect').click()
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
})
cy.cs('population-year-selector').as('yearSelect')
cy.cs('population-year-decrement').as('yearDecrement')
cy.cs('population-year-increment').as('yearIncrement')
cy.contains('No study tracks available for this programme')
cy.get('@yearSelect').click()
cy.contains('2018 - 2019').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.get('@yearDecrement').click()
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
cy.get('@yearIncrement').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.cs('population-studytrack-selector').within(() => {
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
})
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.contains('See class').should('be.enabled')
selectStudyTrack('Matematiikka ja soveltava matematiikka')
@@ -61,21 +68,25 @@ describe('Population statistics tests', () => {
})
describe('Correct population is shown for programme', () => {
it('without study tracks', () => {
cy.contains('Select study programme')
// TODO: Delete MUI
it.skip('without study tracks', () => {
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('See class').click()
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
cy.contains('class size 47 students')
cy.contains('Class size 47 students')
})
it('with study tracks', () => {
// TODO: Delete MUI
it.skip('with study tracks', () => {
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyTrack('Matematiikka ja soveltava matematiikka')
cy.contains('See class').click()
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
cy.contains('studytrack MAST-MSM')
cy.contains('class size 1 students')
cy.contains('Class size 1 students')
})
})
})
@@ -84,7 +95,7 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
@@ -128,7 +139,6 @@ describe('Population statistics tests', () => {
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()
@@ -143,8 +153,9 @@ describe('Population statistics tests', () => {
}))
}
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.cs('credits-gained-table-All students of the class').within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
cy.get('th').eq(1).contains('(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')
@@ -192,34 +203,34 @@ describe('Population statistics tests', () => {
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
cy.cs('credit-statistics-tab').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'],
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h3', selector)
cy.contains('h5', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
@@ -241,8 +252,8 @@ describe('Population statistics tests', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
cy.get('[data-cy=toggle-group-module-MAT110]').click()
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
cy.cs('toggle-group-module-MAT110').click()
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
@@ -261,47 +272,39 @@ describe('Population statistics tests', () => {
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
it('New fetch of courses data is done when curriculum is changed', () => {
it('Courses data is changed when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v4/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)
})
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('curriculum-picker').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2020–2023').click({ force: true })
cy.contains('Students (27)')
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
cy.contains('DIGI-100').should('exist')
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click({ force: true })
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.contains('DIGI-100').should('not.exist')
})
it('New fetch of courses data is done when filtered students change', () => {
it('Courses data is changed when filtered students change', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
cy.wait('@courseData').then(({ response }) => {
expect(response.body).to.have.property('allStudents')
expect(response.body).to.have.property('coursestatistics')
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)
})
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
})
cy.contains('Students (16)')
})
})
@@ -316,7 +319,7 @@ describe('Population statistics tests', () => {
})
it("'General tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.cs('ooditable-general').within(() => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
@@ -330,14 +333,16 @@ describe('Population statistics tests', () => {
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
})
cy.cs('ooditable-courses').within(() => {
cy.contains('MAT12001')
cy.contains('MAT21001')
})
})
it("'Modules tab' Displays correct modules based on the selected programme", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Modules').click()
cy.cs('student-table-tabs').contains('Modules').click()
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011')
cy.contains('MAT110')
})
@@ -345,18 +350,17 @@ describe('Population statistics tests', () => {
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click()
cy.contains('Courses of class').click()
cy.cs('student-table-tabs').within(() => {
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011').should('not.exist')
cy.contains('MAT110')
})
})
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
// TODO: This fails in the pipeline, but works locally. Investigate.
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Tags').click()
cy.contains('No tags defined. You can define them here.').find('a').click()
})
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create new tag')
@@ -379,17 +383,15 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.get('textarea').type(existing)
cy.get('textarea').type('{enter}')
cy.get('textarea').type(nonExisting)
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.contains('#checkstudentsresults', 'Results').within(() => {
cy.contains('Student numbers in list and in Sisu').click()
cy.contains('#found', existing)
cy.contains('Student numbers in list but not in Sisu').click()
cy.contains('#notfound', nonExisting)
cy.contains('Student numbers in Sisu but not in list').click()
cy.contains('#notsearched', '457144')
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
cy.cs('found-data').contains(existing)
cy.cs('not-found-title').click()
cy.cs('not-found-data').contains(nonExisting)
cy.cs('not-searched-title').click()
cy.cs('not-searched-data').contains('457144')
})
cy.contains('button', 'Close').click()
cy.contains('Student numbers in list and in Sisu').should('not.exist')
@@ -400,29 +402,23 @@ describe('Population statistics tests', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
})
it('Population statistics is visible', () => {
cy.get('.card').within(() => {
cy.cs('PopulationQueryCard').within(() => {
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
})
it('Only correct panes are visible', () => {
const correctPanes = [
'Credit accumulation (for 27 students)',
'Credit statistics',
'Age distribution',
'Courses of class',
]
cy.get('.accordion.ui.fluid.styled').within(() => {
cy.get('.title').should('have.length', correctPanes.length)
correctPanes.forEach((pane, index) => {
cy.get('.title').eq(index).contains(pane)
})
it('Only correct panels are visible', () => {
cy.cs('panelview-parent').within(() => {
cy.cs('Credit accumulation (for 27 students)')
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of class')
})
})
+4 -5
View File
@@ -20,12 +20,13 @@ const typeStudentNumberAndClick = studentNumber => {
describe('Students tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init()
cy.init('')
cy.contains('Students').click()
cy.url().should('include', '/students')
cy.contains('Show student names')
})
// FIXME: Flaky
it('Students search form is usable', () => {
typeToSearch(student.lastName)
cy.contains('Student number')
@@ -159,12 +160,10 @@ describe('Students tests', () => {
cy.cs('study-rights-section').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => {
cy.get('a').click()
})
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
cy.contains('class size 30 students')
cy.contains('Class size 30 students')
})
it('Grade graph works in all three different modes', () => {
+28 -25
View File
@@ -31,7 +31,7 @@ describe('Study guidance group tests', () => {
const studyGuidanceGroupsTableContent = [
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
['Oma ohjausryhmä', 2, 'Add study programme', 'Add year'],
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
]
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
@@ -52,11 +52,11 @@ describe('Study guidance group tests', () => {
it('has a working back button', () => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
cy.get('button.ui.button').contains('Back').click()
cy.get('button').contains('Back to groups').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
})
describe('without associated study programme and year', () => {
describe('without associated degree programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
})
@@ -66,43 +66,43 @@ describe('Study guidance group tests', () => {
cy.cs('Credit accumulation (for 2 students)')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (2)')
cy.contains('Students (2)')
cy.cs('Credit statistics').should('not.exist')
})
it('students table has the correct tabs', () => {
cy.cs('Students (2)').click()
it.skip('students table has the correct tabs', () => {
cy.contains('Students (2)').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'])
})
})
it('general tab of the students table has the correct columns', () => {
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
'Credits since 1.1.1970',
'Start year at uni',
'Study programmes',
'Degree programmes',
'Tags',
]
cy.cs('Students (2)').click()
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
describe('with associated study programme and year', () => {
describe('with associated degree programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
})
it('shows the correct labels', () => {
cy.get('.ui.medium.header').contains('MAT kandit 2020')
cy.get('.ui.blue.tag.label:first').contains('Matemaattisten tieteiden kandiohjelma')
cy.get('.ui.blue.tag.label:last').contains('2020 - 2021')
cy.get('h4').contains('MAT kandit 2020')
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
cy.get('.MuiChip-root:last').contains('2020 - 2021')
})
it('shows the correct panes', () => {
@@ -110,28 +110,31 @@ describe('Study guidance group tests', () => {
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (3)')
cy.contains('Students (3)')
})
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="true"]')
cy.get('.ui.mini.icon.button.credit-date-filter-input').contains('01.08.2020')
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
cy.cs('Credit accumulation (for 3 students)').click()
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
cy.get('.date-picker')
.eq(0)
.within(() => cy.get('input').should('have.value', '01.08.2020'))
cy.contains('Reset All Filters')
cy.get('.ui.primary.button').contains('Show all credits').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
})
it('students table has the correct tabs', () => {
cy.cs('Students (3)').click()
it.skip('students table has the correct tabs', () => {
cy.contains('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', 'Modules', 'Progress'])
})
})
it('general tab of the students table has the correct columns', () => {
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
@@ -151,7 +154,7 @@ describe('Study guidance group tests', () => {
'Latest attainment date',
'Tags',
]
cy.cs('Students (3)').click()
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
+19 -19
View File
@@ -10,17 +10,19 @@ describe('Teachers page tests', () => {
const teacher1 = 'Luokkanen Liisa Viljami'
const teacher2 = 'Perälä Juhani Susanna'
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
it('Check Statistics', () => {
cy.get(':nth-child(1) > .ui > .search').click()
cy.cs('semester-start').click()
cy.contains('Syksy 2020').click()
cy.cs('course-providers').click()
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
cy.cs('course-providers').children('.icon').click()
cy.contains('button', 'Search').click()
cy.get('body').click(0, 0) // Click outside of the select
cy.cs('search-statistics').click()
cy.contains('Teacher')
const headers = ['Name', 'Credits', 'Credits transferred', 'Passed']
cy.get('table thead tr th').should('have.length', 4)
headers.forEach((header, index) => {
statisticsHeaders.forEach((header, index) => {
cy.get('table thead tr th').eq(index).should('contain', header)
})
cy.contains('td', teacher1).siblings().eq(0).contains('235')
@@ -30,15 +32,15 @@ describe('Teachers page tests', () => {
})
it('Teacher search works', () => {
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher1.split(' ')[0])
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher1.split(' ')[0])
cy.get('table tbody tr').should('have.length', 4)
cy.get('table tbody tr').eq(2).contains('td', teacher1)
cy.get('table tbody tr').eq(3).contains('td', teacher1)
})
it('Can check teacher page', () => {
cy.get('.borderless > :nth-child(3)').click()
cy.get('.prompt').type(teacher2)
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher2)
// Prevent opening in new tab
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
@@ -46,19 +48,17 @@ describe('Teachers page tests', () => {
cy.contains(teacher2)
cy.contains('Syksy 2023').click()
cy.contains('Kevät 2019').click()
cy.get('.ok-sortable-table tbody tr')
.eq(0)
.within(() => {
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
rowContent.forEach((content, index) => {
cy.get('td').eq(index).contains(content)
})
cy.contains('tr', 'MAT12004').within(() => {
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
rowContent.forEach((content, index) => {
cy.get('td').eq(index).contains(content)
})
})
})
it('Check leaderboad works', () => {
cy.get('.borderless > :nth-child(2)').click()
cy.get(':nth-child(1) > .ui > .search').click()
cy.cs('Leaderboard').click()
cy.cs('academic-year').click()
cy.contains('2020-2021').click()
})
})
+2 -1
View File
@@ -51,7 +51,8 @@ describe('University view', () => {
checkProgressTables()
})
it('years in the tables can be clicked to show faculty level breakdown', () => {
// Nothing in this test is explained. Non of these values are verified.
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
cy.cs('study-right-toggle').click()
cy.cs('bachelors-faculty-progress-table-show-button3').click()
cy.contains('29.5%').trigger('mouseover', { force: true })
+12 -7
View File
@@ -72,14 +72,19 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.contains('label', 'Study programme')
.siblings()
.within(() => {
cy.get("div[role='option']").should('have.length', 2)
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
})
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('h6', 'Degree programme')
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
cy.cs('population-programme-selector-parent').within(() =>
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
)
})
})
})
-1
View File
@@ -15,7 +15,6 @@
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
+72 -43
View File
@@ -79,16 +79,19 @@ const userHeaders = [
]
/**
Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
*/
Cypress.Commands.add('init', (path = '', userId = 'basic') => {
cy.intercept('', req => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
cy.intercept({ hostname: 'localhost', url: '**', method: '*' }, req => {
req.headers = headersToUse
})
const { baseUrl } = Cypress.config()
cy.visit(baseUrl.concat(path))
// TODO: Add wait for visit url.
// This is block by course population query.
cy.visit({ method: 'GET', url: path, headers: headersToUse })
})
/**
@@ -104,27 +107,72 @@ Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
})
/**
* Select item specified by `index` number from a semantic-ui dropdown with
* `data-cy` attribute value `name`.
* Select item from FilterSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
const subject = cy.wrap(s)
const indexes = Array.isArray(index) ? index : [index]
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
cy.cs(`${label}-selector`).click()
indexes.forEach(i => {
subject.click()
if (typeof i === 'number') {
subject.children('.menu').children().eq(i).click({ force: true })
} else {
subject.children('.menu').containing(i).eq(0).click({ force: true })
}
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
// Close multiple selection so it does not block elements underneath it.
if (Array.isArray(index)) {
cy.wrap(s).children('.icon').click({ force: true })
}
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
})
/**
* Select item from FilterSearchableSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
cy.cs(`${label}-selector`).click()
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
})
/**
* Set range selector values FilterRange.
* If the reset flag is set, the function tries to reset the original values.
*/
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
const minText = min.toString()
const maxText = max.toString()
const expectedText = expected.toString()
const parent = `${filter}-filter-card`
cy.cs(parent)
.cs('FilterRangeStart')
.find('input')
.invoke('val')
.then(initialMin => {
cy.cs(parent)
.cs('FilterRangeEnd')
.find('input')
.invoke('val')
.then(initialMax => {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
// This should be linked to other checking functions.
// Maybe one day.
cy.contains(`Students (${expectedText})`)
if (reset) {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
}
})
})
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
@@ -142,22 +190,3 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
})
})
const getEmptyYears = isAcademicYear => {
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
module.exports = {
getEmptyYears,
}
-2
View File
@@ -13,11 +13,9 @@
// https://on.cypress.io/configuration
// ***********************************************************
// eslint-disable-next-line import/no-extraneous-dependencies
require('@cypress/code-coverage/support')
require('./commands')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Cypress.on('uncaught:exception', error => {
/* returning false here prevents Cypress from failing the test */
return true
+2
View File
@@ -120,6 +120,7 @@ services:
- redis
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
@@ -148,6 +149,7 @@ services:
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
REDIS_PORT: 6379
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
+1 -1
View File
@@ -21,7 +21,7 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
### teachers
- By default, the role only allows seeing the statistics of course providers (study programmes) they have full rights to (see [Programmes](#programmes))
- By default, the role only allows seeing the statistics of course providers (degree programmes) they have full rights to (see [Programmes](#programmes))
- This role always needs to be given and removed manually
- If the user is a member of the `hy-dekaanit` or `hy-varadekaanit-opetus` IAM group (or is an admin), they can see leaderboards, information about individual teachers, and statistics of all course providers. They don't need to be given the `teachers` role manually.
+3 -3
View File
@@ -6,7 +6,7 @@ The made changes do not have any effects on faculty or trends views. **Needs man
Basic rule of thumb is that for the master/licentiate programme I used names `combinedProgramme` and in some rare cases `secondProgramme` as whole or part of the variable names.
## Study programme
## Degree programme
**Needs manual work** in the function `getCombinedProgrammeId` in `services/frontend/src/common/index.js` if new programmes is added. For showing the both programme data correctly, the filtering mechanism is changed to add both programmes and it is not caring for which programme user has acual rights or IAMrights. However, the links for the class stats level are hidden in studytrack tables, if the user has no rights for the programme.
@@ -15,7 +15,7 @@ Basic rule of thumb is that for the master/licentiate programme I used names `co
- Overview
- **Needs manual work**
**Redis nightly study programme update**
**Redis nightly degree programme update**
- Updates automatically combined programme stats. **Needs manual work** here `services/backend/src/services/studyprogrammeHelpers.js` in the constant `combinedStudyprogrammes`. This might needs refactoring if bachelor programme can have many separate master programmes. Then remember to change function `refreshNewOverview` in `services/backend/src/events.js` accordingly to handle lists.
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
## Study guidance groups
- Backend: student data is fetched by bachelor study programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Frontend: things are done in the same way as with class stats.
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
+1 -1
View File
@@ -53,7 +53,7 @@ erDiagram
open_uni_population_searches
progress_criteria
study_guidance_group_tags
study_programme_pins
degree_programme_pins
tag ||--o{ tag_student : "belongs to"
tag
tag_student
-1
View File
@@ -46,7 +46,6 @@ A collection of useful tools, documentation and other resources.
- [Material Icons](https://mui.com/material-ui/material-icons/)
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
- [Material React Table documentation](https://www.material-react-table.com/docs)
- [Semantic UI React documentation](https://react.semantic-ui.com)
<!--
+208 -135
View File
@@ -1,183 +1,256 @@
import pluginJs from '@eslint/js'
import globals from 'globals'
import pluginCypress from 'eslint-plugin-cypress/flat'
import pluginImport from 'eslint-plugin-import'
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import pluginReact from 'eslint-plugin-react'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import tsEslint from 'typescript-eslint'
// @ts-check
/** @type {import('eslint').Linter.Config[]} */
export default [
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'
import eslintPluginCypress from 'eslint-plugin-cypress'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginImportX from 'eslint-plugin-import-x'
import { createNodeResolver } from 'eslint-plugin-import-x'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
export default tseslint.config(
{
files: ['**/*.{js,ts,jsx,tsx}'],
},
{
ignores: ['eslint.config.mjs', '**/build/', '**/dist/', '**/node_modules/'],
// Global ignores
ignores: [
'eslint.config.mjs',
'**/build/**',
'**/dist/**',
'**/node_modules/**',
'**/instrumented/**',
'**/coverage/**',
'nyc-config.cjs',
'.lintstagedrc.mjs',
'cypress.config.js',
]
},
// Global options (excl. e2e)
{
ignores: ['cypress/**/*.js'],
extends: [
eslint.configs.recommended,
eslintPluginImportX.flatConfigs.recommended,
eslintPluginImportX.flatConfigs.typescript,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.{js,cjs}']
}
},
globals: {
...globals.browser,
...globals.node,
},
},
},
{
plugins: {
'import-x': eslintPluginImportX
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json',
],
},
},
},
pluginJs.configs.recommended,
...tsEslint.configs.recommendedTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
pluginCypress.configs.recommended,
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
pluginImport.flatConfigs.recommended,
pluginImport.flatConfigs.typescript,
pluginPrettierRecommended, // This must be the last plugin so it can override other configs
{
plugins: {
'react-hooks': pluginReactHooks,
},
},
{
rules: {
camelcase: 'off', // TODO: Enable this rule (at least for frontend)
'class-methods-use-this': 'error',
'consistent-return': 'error',
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
'import/no-commonjs': 'error',
'import/no-default-export': 'error',
'import/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import/no-unused-modules': ['error', { missingExports: true, unusedExports: true }],
'import/order': [
'error',
{
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
},
],
'no-alert': 'error',
'no-async-promise-executor': 'error',
'no-await-in-loop': 'error',
'no-console': 'error',
'no-implied-eval': 'error',
'no-param-reassign': ['error', { props: false }],
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
'no-param-reassign': 'error',
'no-promise-executor-return': 'error',
'no-return-assign': 'error',
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'react/display-name': 'off', // TODO: Delete this override
'react/function-component-definition': [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
],
'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',
'react/no-this-in-sfc': 'error',
'react/no-unescaped-entities': 'off',
'react/no-unknown-property': 'off', // TODO: Delete this override
'react/prefer-stateless-function': 'error',
'react/prop-types': 'off',
...pluginReactHooks.configs.recommended.rules,
'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',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-misused-promises': 'off', // Most of these errors come from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'import-x/no-commonjs': 'error',
'import-x/no-default-export': 'error',
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import-x/no-unused-modules': ['error',
{
missingExports: true,
unusedExports: true,
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
}],
'import-x/order': ['error', {
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
}],
// TypeScript
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
],
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/class-methods-use-this': 'error',
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/prefer-destructuring': ['error',
{ VariableDeclarator: { object: true } }
],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
'@typescript-eslint/no-implied-eval': 'error',
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
'@typescript-eslint/restrict-template-expressions': 'off',
},
},
{
files: ['**/*.{js,jsx}'],
rules: {
...tsEslint.configs.disableTypeChecked.rules,
'dot-notation': 'error',
settings: {
'import-x/internal-regex': '^@oodikone/shared/',
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json'
],
}),
createNodeResolver({
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
})
],
},
},
{
files: ['services/backend/src/routes/*.{ts,js}'],
rules: {
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/consistent-return': 'off',
}
},
// Disable type-aware linting for javascript files
{
files: ['{updater,services}/**/*.{js,cjs}'],
extends: [tseslint.configs.disableTypeChecked]
},
// Frontend camelCase and react
{
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
'react': eslintPluginReact,
},
extends: [
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
eslintPluginReactHooks.configs['recommended-latest'],
],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
}
}
},
rules: {
'camelcase': 'warn',
'react/prop-types': 'off', // Typescript but worse
'react/no-unescaped-entities': 'off',
'react/function-component-definition': ['error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/no-array-index-key': 'error',
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
'react/no-this-in-sfc': 'error',
'react/prefer-stateless-function': 'error',
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error',
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
],
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
'react/jsx-no-useless-fragment': 'error',
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
'react/jsx-sort-props': ['error', { reservedFirst: false }],
'react/jsx-tag-spacing': 'warn',
},
settings: {
'react': {
'version': 'detect'
}
}
},
// Cypress should only use rules provided by the plugin below
{
files: ['cypress/**/*.js'],
extends: [
eslintPluginCypress.configs.recommended
]
},
// Overrides for CommonJS
{
files: [
'services/backend/**/*.js',
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'updater/sis-updater-scheduler/**/*.js'
],
languageOptions: {
sourceType: 'commonjs',
},
rules: {
'import-x/no-commonjs': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Rapodiff
{
files: ['services/backend/src/rapodiff/**/*'],
rules: {
'no-console': 'off',
},
},
// Migration files
{
files: ['services/**/shared/**/*'],
files: [
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'services/backend/src/database/**/*.js'
],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
languageOptions: {
sourceType: 'commonjs',
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'import/no-commonjs': 'off',
'import/no-unused-modules': 'off',
'import-x/no-unused-modules': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/require-await': 'off',
},
},
// TODO: See if these can be disabled and or fix
{
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
rules: {
// TODO: Most of these overrides should probably be removed
'consistent-return': 'off',
'import/no-default-export': 'off',
'import/no-unused-modules': 'off',
'import-x/no-default-export': 'off',
'import-x/no-unused-modules': 'off',
'no-await-in-loop': 'off',
'no-param-reassign': 'off',
'no-promise-executor-return': 'off',
},
},
{
settings: {
'import/internal-regex': '^@oodikone/shared/',
'import/resolver': {
typescript: {
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json',
],
},
},
react: {
version: '18.3',
},
},
},
]
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
)
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
module.exports = {
+3120 -3303
View File
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -1,6 +1,9 @@
{
"name": "oodikone",
"description": "An application for analyzing university data.",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"cypress": "cypress",
"cypress:open": "cypress open",
@@ -9,7 +12,6 @@
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"stylelint": "stylelint",
"actionlint": "node-actionlint",
"prettier": "prettier",
"cli": "./cli.sh",
"oodikone": "./run.sh oodikone anon up --detach",
@@ -41,30 +43,28 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@cypress/code-coverage": "^3.13.12",
"@eslint/js": "^9.22.0",
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^14.1.0",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-import-resolver-typescript": "^3.8.3",
"eslint-plugin-cypress": "^4.2.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"cypress": "^15.0.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.1",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.0.0",
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"node-actionlint": "^1.2.2",
"lint-staged": "^16.1.0",
"nyc": "^17.1.0",
"prettier": "3.5.3",
"stylelint": "^16.15.0",
"stylelint-config-standard": "^37.0.0",
"typescript-eslint": "^8.26.0"
"stylelint": "^16.20.0",
"stylelint-config-standard": "^39.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
"lodash": "^4.17.21",
"moment": "^2.30.1"
"lodash": "^4.17.21"
}
}
+69 -30
View File
@@ -9,7 +9,8 @@
## Folders
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
USER_DATA_FILE="$DUMP_DIR/hyuserdata"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
@@ -18,14 +19,16 @@ KONE_DB_NAME="kone-db"
SIS_DB_NAME="sis-db"
SIS_IMPORTER_DB_NAME="sis-importer-db"
USER_DB_NAME="user-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME")
JAMI_DB_NAME="jami-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME" "$JAMI_DB_NAME")
## Urls should be in same order as databases as both are iterated through by indexes.
KONE_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-kone-db.sql.gz"
SIS_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-sis-db.sql.gz"
SIS_IMPORTER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/importer.sql.gz"
USER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-user-db.sql.gz"
REAL_DUMP_URLS=("$KONE_DB_REAL_DUMP_URL" "$SIS_DB_REAL_DUMP_URL" "$SIS_IMPORTER_DB_REAL_DUMP_URL" "$USER_DB_REAL_DUMP_URL")
KONE_DB_S3_PATH="oodikone_kone"
SIS_DB_S3_PATH="oodikone_updater"
SIS_IMPORTER_DB_S3_PATH="sis_importer"
USER_DB_S3_PATH="oodikone_user"
JAMI_DB_S3_PATH="jami"
S3_PATHS=("$KONE_DB_S3_PATH" "$SIS_DB_S3_PATH" "$SIS_IMPORTER_DB_S3_PATH" "$USER_DB_S3_PATH" "$JAMI_DB_S3_PATH")
# Source utility functions
source "$PROJECT_ROOT"/scripts/utils.sh
@@ -55,9 +58,46 @@ retry() {
download_real_dump() {
local database=$1
local pannu_url=$2
local dump_destination="$DUMP_DIR/$database.sql.gz"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
local s3_path=$2
local selection_flag=$3
rm -f "$dump_destination"
if [ "$selection_flag" = "true" ]; then
local backup_files
mapfile -t backup_files < <(s3cmd -c "$S3_CONFIG_FILE" ls "$S3_BUCKET/$s3_path/" | sort -rk1,2 | awk '{print $4}')
if [ ${#backup_files[@]} -eq 0 ]; then
die "No backup files found in $S3_BUCKET bucket $s3_path path!"
fi
infomsg "Available backups:"
PS3="Select backup (or 0 to go back): "
select chosen_backup in "${backup_files[@]}"; do
if [ "$REPLY" = "0" ] || [ "$chosen_backup" = "Go back" ]; then
infomsg "Going back to previous menu."
break
elif [ -n "$chosen_backup" ]; then
infomsg "You selected: $chosen_backup"
local FILE_NAME
FILE_NAME=$(basename "$chosen_backup")
infomsg "Fetching the selected dump: $FILE_NAME"
s3cmd -c "$S3_CONFIG_FILE" get "$chosen_backup" "$dump_destination"
if [ ! -f "$dump_destination" ]; then
die "Download failed or file not found: $dump_destination"
fi
echo "$dump_destination"
break
else
warningmsg "Invalid selection. Please select a valid backup number."
fi
done
else
local current_date
current_date=$(date +"%Y%m%d")
s3cmd -c "$S3_CONFIG_FILE" get "s3://psyduck/${s3_path}/${s3_path}_${current_date}.sql.gz" "$dump_destination"
fi
}
check_if_postgres_is_ready() {
@@ -101,11 +141,12 @@ reset_databases() {
reset_jami_data() {
local database="jami-db"
local pannu_url="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/jami.sql.gz"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Downloading Jami data"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
infomsg "Removing database and related volume"
docker volume rm oodikone_jami-data || warningmsg "This is okay, continuing"
@@ -122,13 +163,12 @@ reset_jami_data() {
}
reset_all_real_data() {
infomsg "Downloading real data dumps, asking for pannu password when needed"
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
local url="${REAL_DUMP_URLS[$i]}"
download_real_dump "$database" "$url"
local url="${S3_PATHS[$i]}"
download_real_dump "$database" "$url" false
done
reset_jami_data
reset_databases "${DATABASES[@]}"
}
@@ -152,28 +192,30 @@ reset_single_database() {
local database=""
local url=""
local selection_flag=true
while true; do
select opt in "${options[@]}"; do
case $opt in
"kone-db")
local database=$KONE_DB_NAME
local url=$KONE_DB_REAL_DUMP_URL
local url=$KONE_DB_S3_PATH
break 2;;
"sis-db")
local database=$SIS_DB_NAME
local url=$SIS_DB_REAL_DUMP_URL
local url=$SIS_DB_S3_PATH
break 2;;
"sis-importer-db")
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
local url=$SIS_IMPORTER_DB_S3_PATH
break 2;;
"user-db")
local database=$USER_DB_NAME
local url=$USER_DB_REAL_DUMP_URL
local url=$USER_DB_S3_PATH
break 2;;
"jami-db")
reset_jami_data
local database=$JAMI_DB_NAME
local url=$JAMI_DB_S3_PATH
break 2;;
"Return to main menu.")
break 2;;
@@ -186,7 +228,7 @@ reset_single_database() {
if [ -n "$database" ]; then
infomsg "Downloading $database dump"
download_real_dump $database $url
download_real_dump $database $url $selection_flag
reset_databases $database
fi
}
@@ -242,14 +284,11 @@ init_dirs() {
# If username is not set, get username from data file.
# Ask user to provide username, if username was not found from data file.
get_username() {
if [ ! -f "$USER_DATA_FILE" ]; then
warningmsg "University username is needed to get database dumps from toska servers, please enter it now:"
read -r username
echo "$username" > "$USER_DATA_FILE"
successmsg "Succesfully saved username for later usage."
get_s3_config() {
if [ ! -f "$S3_CONFIG_FILE" ]; then
warningmsg "No username provided, config file is required. The \".s3cfg\" config file can be found on toska/dokumentaatio repo."
return 0
fi
username=$(head -n 1 < "$USER_DATA_FILE")
infomsg "Using your university username - $username - for getting database dumps"
infomsg "Using your config $S3_CONFIG_FILE - for getting database dumps"
}
@@ -1,6 +1,6 @@
require('./src/util/sentry') // Sentry must be the first import
require('./src/app')
const logger = require('./src/util/logger')
import './src/util/sentry' // Sentry must be the first import
import './src/app'
import logger from './src/util/logger'
process.on('unhandledRejection', reason => {
logger.error({ message: 'unhandledRejection: ', meta: reason })
+2415 -1464
View File
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -6,41 +6,41 @@
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^8.55.0",
"@sentry/profiling-node": "^8.55.0",
"axios": "^1.8.1",
"bullmq": "^5.41.8",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
"cron": "^4.1.0",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"moment": "^2.30.1",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.2",
"pg": "^8.13.3",
"redis": "^4.7.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.6",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"vite": "^7.1.12",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.16",
"@types/morgan": "^1.9.9",
"@types/node": "^22.13.10",
"@types/validator": "^13.12.2",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/lodash": "^4.17.17",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
},
"overrides": {
"winston-gelf-transporter": {
@@ -1,16 +1,16 @@
require('express-async-errors')
const express = require('express')
import 'express-async-errors'
import express from 'express'
const { baseUrl, backendPort } = require('./config')
const { initializeDatabaseConnection, dbConnections } = require('./database/connection')
const { startCron } = require('./events')
const routes = require('./routes').default
const logger = require('./util/logger')
require('./worker/worker')
import { baseUrl, backendPort } from './config'
import { initializeDatabaseConnection, dbConnections } from './database/connection'
import { startCron } from './events'
import routes from './routes'
import logger from './util/logger'
import './worker/worker'
initializeDatabaseConnection()
.then(() => {
dbConnections.connect()
void dbConnections.connect()
dbConnections.on('connect', () => {
logger.info('Connected to sis db successfully')
-105
View File
@@ -1,105 +0,0 @@
// Node env to use
const isDev = process.env.NODE_ENV === 'development'
const isStaging = process.env.REACT_APP_STAGING === 'true'
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
const runningInCI = process.env.CI === 'true'
// IAM group
const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
const pateToken = process.env.PATE_API_TOKEN || ''
// Jami
const jamiUrl = process.env.JAMI_URL || ''
// Importer client
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
const importerDbApiUser = process.env.IMPORTER_DB_API_USER || ''
const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD || ''
// Sisu
const sisUrl = process.env.SIS_HOST
const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
// Networking: Urls & ports
const baseUrl = isDev ? '/api' : ''
const frontUrl = process.env.FRONT_URL
const backendPort = 8080
// System run in whose environment
const serviceProvider = process.env.SERVICE_PROVIDER ? process.env.SERVICE_PROVIDER.toLowerCase() : 'toska'
// Optional logout-url configuration
const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
const rootOrgId = process.env.ROOT_ORG_ID || 'hy-university-root-id'
const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
const concurrentWorkers = process.env.CONCURRENT_WORKERS ? parseInt(process.env.CONCURRENT_WORKERS, 10) : 2
let DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS, 10)
if (Number.isNaN(DB_MAX_CONNECTIONS)) {
DB_MAX_CONNECTIONS = 5 // sequelize's default
}
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'
const KONE_PASSWORD = process.env.KONE_PASSWORD || 'postgres'
const USER_PASSWORD = process.env.USER_PASSWORD || 'postgres'
const SIS_PASSWORD = process.env.SIS_PASSWORD || 'postgres'
module.exports = {
DB_URL_USER,
CRYPT_KEY,
DB_URL_KONE,
DB_MAX_CONNECTIONS,
DB_MAX_CRON_CONNECTIONS,
CRON_SCHEDULE,
redis,
SECRET_TOKEN,
DB_SCHEMA_KONE,
KONE_PASSWORD,
USER_PASSWORD,
SIS_PASSWORD,
requiredGroup,
SIS_DB_URL,
SIS_UPDATER_URL,
runningInCI,
isProduction,
pateToken,
jamiUrl,
baseUrl,
frontUrl,
backendPort,
importerUrl,
importerToken,
importerDbApiUser,
importerDbApiPassword,
isDev,
isStaging,
rootOrgId,
serviceProvider,
configLogoutUrl,
sisUrl,
sisGrapqlAppAccount,
sisGrapqlAppKey,
languageCenterViewEnabled,
concurrentWorkers,
}
+60
View File
@@ -0,0 +1,60 @@
// Node env to use
export const isDev = process.env.NODE_ENV === 'development'
export const isStaging = process.env.REACT_APP_STAGING === 'true'
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
// IAM group
export const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
// Jami
export const jamiUrl = process.env.JAMI_URL ?? ''
// Importer client
export const importerUrl = process.env.IMPORTER_DB_API_URL ?? ''
export const importerToken = process.env.IMPORTER_DB_API_TOKEN ?? ''
export const importerDbApiUser = process.env.IMPORTER_DB_API_USER ?? ''
export const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD ?? ''
// Sisu
export const sisUrl = process.env.SIS_HOST
export const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
export const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
// Networking: Urls & ports
export const baseUrl = isDev ? '/api' : ''
export const frontUrl = process.env.FRONT_URL
export const backendPort = 8080
// System run in whose environment
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
// Optional logout-url configuration
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
export const DB_MAX_CRON_CONNECTIONS = Math.max(1, DB_MAX_CONNECTIONS - 5)
export const CRON_SCHEDULE = process.env.CRON_SCHEDULE ?? '0 23 * * *' // Default to 23:00 daily
export const redis = process.env.REDIS
export const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE ?? 'public'
export const KONE_PASSWORD = process.env.KONE_PASSWORD ?? 'postgres'
export const USER_PASSWORD = process.env.USER_PASSWORD ?? 'postgres'
export const SIS_PASSWORD = process.env.SIS_PASSWORD ?? 'postgres'
@@ -1,4 +1,7 @@
// Linter was not having any of this so needed to add resolveJsonModule:true to tsconfig
// export const { facultyCodes, ignoredFacultyCodes, magicFacultyCode } = require('../environment/organizationConfig.json')
export { facultyCodes, ignoredFacultyCodes, magicFacultyCode } from '../environment/organizationConfig.json'
import organizationConfig from '../environment/organizationConfig.json'
export const { facultyCodes } = organizationConfig
export const { ignoredFacultyCodes } = organizationConfig
export const { magicFacultyCode } = organizationConfig
+2
View File
@@ -1,3 +1,5 @@
// This is locked behind admin check.
// We probably want to keep it on the server side and not share it.
export const roles = [
'admin',
'courseStatistics',
+2 -2
View File
@@ -2,7 +2,7 @@ import EventEmitter from 'events'
import { Sequelize } from 'sequelize-typescript'
import { SequelizeStorage, Umzug } from 'umzug'
import conf from '../config'
import * as conf from '../config'
import {
CourseModel,
CourseProviderModel,
@@ -96,7 +96,7 @@ class DbConnection extends EventEmitter {
message: `Sis database connection failed! Attempt ${attempt}/${this.RETRY_ATTEMPTS}`,
meta: error,
})
setTimeout(() => this.connect(attempt + 1), 1000 * attempt)
setTimeout(() => void this.connect(attempt + 1), 1000 * attempt)
}
}
}
+8 -7
View File
@@ -65,13 +65,13 @@ const refreshProgrammesAndFaculties = async () => {
export const refreshProgramme = async (code: string) => {
await updateBasicView(code, '')
await updateStudyTrackView(code, '')
const combinedProgramme = combinedStudyProgrammes[code] || ''
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
await updateBasicView(code, combinedProgramme)
await updateStudyTrackView(code, combinedProgramme)
}
export const refreshProgrammes = async () => {
logger.info('Refreshing study programme and study track overview statistics for all programmes')
logger.info('Refreshing degree programme and study track overview statistics for all programmes')
const facultyCodes = (await getFaculties()).map(faculty => faculty.code)
const programmeCodes: string[] = []
for (const faculty of facultyCodes) {
@@ -106,14 +106,15 @@ const dailyJobs = async () => {
export const startCron = () => {
if (isProduction && !runningInCI) {
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
schedule(CRON_SCHEDULE, async () => {
schedule(CRON_SCHEDULE, () => {
logger.info('Running daily jobs from cron')
await dailyJobs()
void dailyJobs()
})
schedule('0 4 * * 3', async () => {
schedule('0 4 * * 3', () => {
logger.info("Deleting users who haven't logged in for 18 months")
const [, result] = (await deleteOutdatedUsers()) as { rowCount: number }[]
logger.info(`Deleted ${result.rowCount} users`)
const deletedOutdatedUsers = deleteOutdatedUsers() as Promise<{ rowCount: number }[]>
void deletedOutdatedUsers.then(([_, result]) => logger.info(`Deleted ${result.rowCount} users`))
})
schedule('0 19 * * 1', () => {
logger.info('Updating students whose studyplans have not been updated recently')
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/consistent-return */
import { NextFunction, Request, Response } from 'express'
import { Role } from '@oodikone/shared/types'
+21 -38
View File
@@ -4,7 +4,7 @@ import { intersection } from 'lodash'
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
import { FormattedUser, IamAccess } from '../types'
import { FormattedUser } from '../types'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -14,30 +14,6 @@ const hasRequiredIamGroup = (iamGroups: string[], iamRights: string[]) => {
return intersection(iamGroups, requiredGroup).length > 0 || iamRights.length > 0
}
const getUser = async (
showAsUser: string | undefined,
username: string,
name: string,
email: string,
iamGroups: string[],
specialGroup: Record<string, boolean>,
sisId: string,
iamAccess: IamAccess
) => {
if (showAsUser && specialGroup?.superAdmin) {
return await getMockedUser({ userToMock: showAsUser, mockedBy: username })
}
return await getUserToska({
username,
name,
email,
iamGroups,
specialGroup,
sisId,
iamAccess,
})
}
type Headers = {
displayname?: string
hygroupcn?: string
@@ -49,7 +25,7 @@ type Headers = {
'x-show-as-user'?: string
}
const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction) => {
const {
'shib-session-id': sessionId,
'x-show-as-user': showAsUser,
@@ -61,19 +37,15 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
uid: username,
} = req.headers as Headers
const missingHeaders: string[] = []
if (!sessionId) missingHeaders.push('shib-session-id')
if (!username) missingHeaders.push('uid')
if (missingHeaders.length > 0) {
if (!username || !sessionId)
throw new ApplicationError(
`Not enough data in request headers, the following headers were missing: ${missingHeaders.join(', ')}`,
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
403,
{ logoutUrl }
)
}
const iamGroups = parseIamGroups(hygroupcn!)
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId!, iamGroups)
const iamRights = Object.keys(iamAccess)
@@ -82,15 +54,26 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
message: 'User does not have required iam group',
meta: { username, name, email, iamGroups, iamRights },
})
throw new ApplicationError(`User '${username}' does not have required iam group`, 403, { logoutUrl })
}
const user = await getUser(showAsUser, username!, name!, email!, iamGroups, specialGroup, sisId!, iamAccess)
if (!user) {
throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
}
const user =
showAsUser && specialGroup?.superAdmin
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
: await getUserToska({
username,
name: name!,
email: email!,
iamGroups,
specialGroup,
sisId: sisId!,
iamAccess,
})
Sentry.setUser({ username: user.mockedBy ?? username! })
// Set username just-in-case there is error for invalid user
Sentry.setUser({ username: user?.mockedBy ?? username })
if (!user) throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
req.user = user
req.logoutUrl = logoutUrl!
+26 -29
View File
@@ -11,16 +11,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type {
Credit,
Enrollment,
Organization,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
} from '@oodikone/shared/models'
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
import type { SISStudyRight } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
@@ -34,63 +25,69 @@ import { StudyrightExtentModel } from './studyrightExtent'
underscored: true,
tableName: 'sis_study_rights',
})
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
export class SISStudyRightModel extends Model implements SISStudyRight {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: SISStudyRight['id']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
facultyCode!: string
declare facultyCode: SISStudyRight['facultyCode']
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
organization!: Organization
declare organization: SISStudyRight['organization']
@HasMany(() => StudyplanModel)
studyPlans!: Studyplan[]
declare studyPlans: SISStudyRight['studyPlans']
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
studyRightElements!: SISStudyRightElement[]
declare studyRightElements: SISStudyRight['studyRightElements']
@Column(DataType.DATE)
startDate!: Date
declare startDate: SISStudyRight['startDate']
@Column(DataType.DATE)
endDate!: Date
declare endDate: SISStudyRight['endDate']
@Column(DataType.DATE)
studyStartDate!: Date
declare studyStartDate: SISStudyRight['studyStartDate']
@Column(DataType.BOOLEAN)
cancelled!: boolean
declare cancelled: SISStudyRight['cancelled']
@Column(DataType.STRING)
studentNumber!: string
declare studentNumber: SISStudyRight['studentNumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
student!: Student
declare student: SISStudyRight['student']
@ForeignKey(() => StudyrightExtentModel)
@Column(DataType.INTEGER)
extentCode!: ExtentCode
declare extentCode: SISStudyRight['extentCode']
@Column(DataType.STRING)
admissionType!: string
declare admissionType: SISStudyRight['admissionType']
@Column(DataType.JSONB)
semesterEnrollments!: SemesterEnrollment[] | null
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
credits!: Credit[]
declare credits: SISStudyRight['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
enrollments!: Enrollment[]
declare enrollments: SISStudyRight['enrollments']
@Column(DataType.BOOLEAN)
declare tvex: SISStudyRight['tvex']
@Column(DataType.ARRAY(DataType.STRING))
declare expirationRuleUrns: SISStudyRight['expirationRuleUrns']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: SISStudyRight['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: SISStudyRight['updatedAt']
}
@@ -10,57 +10,54 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import type { Name, StudyTrack } from '@oodikone/shared/types'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import type { SISStudyRightElement } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
tableName: 'sis_study_right_elements',
})
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
export class SISStudyRightElementModel extends Model implements SISStudyRightElement {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: SISStudyRightElement['id']
@Column(DataType.DATE)
startDate!: Date
declare startDate: SISStudyRightElement['startDate']
@Column(DataType.DATE)
endDate!: Date
declare endDate: SISStudyRightElement['endDate']
@Column(DataType.BOOLEAN)
graduated!: boolean
declare graduated: SISStudyRightElement['graduated']
@Column(DataType.INTEGER)
phase!: Phase
declare phase: SISStudyRightElement['phase']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyRightId!: string
declare studyRightId: SISStudyRightElement['studyRightId']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
studyRight!: SISStudyRight
declare studyRight: SISStudyRightElement['studyRight']
@Column(DataType.STRING)
code!: string
declare code: SISStudyRightElement['code']
@Column(DataType.JSONB)
name!: Name
declare name: SISStudyRightElement['name']
@Column(DataType.JSONB)
studyTrack!: StudyTrack | null
declare studyTrack: SISStudyRightElement['studyTrack']
@Column(DataType.STRING)
degreeProgrammeType!: DegreeProgrammeType
declare degreeProgrammeType: SISStudyRightElement['degreeProgrammeType']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: SISStudyRightElement['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: SISStudyRightElement['updatedAt']
}
+18 -19
View File
@@ -12,8 +12,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import type { Course } from '@oodikone/shared/models'
import { CourseProviderModel } from './courseProvider'
import { CourseTypeModel } from './courseType'
@@ -26,57 +25,57 @@ import { OrganizationModel } from './organization'
modelName: 'course',
tableName: 'course',
})
export class CourseModel extends Model<Course> implements Course {
export class CourseModel extends Model implements Course {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Course['id']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
code!: string
declare code: Course['code']
@ForeignKey(() => CourseTypeModel)
@Column(DataType.STRING)
coursetypecode!: string
declare coursetypecode: Course['coursetypecode']
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
courseType!: CourseType
declare courseType: Course['courseType']
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
credits!: Credit[]
declare credits: Course['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
enrollments!: Enrollment[]
declare enrollments: Course['enrollments']
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
organizations!: Organization[]
declare organizations: Course['organizations']
@Column(DataType.BOOLEAN)
is_study_module!: boolean
declare is_study_module: Course['is_study_module']
@Column(DataType.JSONB)
name!: Name
declare name: Course['name']
@Column(DataType.DATE)
max_attainment_date!: Date
declare max_attainment_date: Course['max_attainment_date']
@Column(DataType.DATE)
min_attainment_date!: Date
declare min_attainment_date: Course['min_attainment_date']
@CreatedAt
@Column({ field: 'created_at', type: DataType.DATE })
createdAt!: Date
declare createdAt: Course['createdAt']
@UpdatedAt
@Column({ field: 'updated_at', type: DataType.DATE })
updatedAt!: Date
declare updatedAt: Course['updatedAt']
@Column(DataType.JSONB)
substitutions!: string[]
declare substitutions: Course['substitutions']
@Column(DataType.STRING)
course_unit_type!: string
declare course_unit_type: Course['course_unit_type']
@Column({ field: 'main_course_code', type: DataType.STRING })
mainCourseCode!: string
declare mainCourseCode: Course['mainCourseCode']
}
@@ -10,23 +10,23 @@ import { OrganizationModel } from './organization'
modelName: 'course_provider',
tableName: 'course_providers',
})
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
export class CourseProviderModel extends Model implements CourseProvider {
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
coursecode!: string
declare coursecode: CourseProvider['coursecode']
@Column(DataType.JSONB)
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
declare shares: CourseProvider['shares']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organizationcode!: string
declare organizationcode: CourseProvider['organizationcode']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CourseProvider['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CourseProvider['updatedAt']
}
+7 -8
View File
@@ -1,8 +1,7 @@
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Course, CourseType } from '@oodikone/shared/models'
import type { CourseType } from '@oodikone/shared/models'
import { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
@Table({
@@ -10,22 +9,22 @@ import { CourseModel } from './course'
modelName: 'course_type',
tableName: 'course_types',
})
export class CourseTypeModel extends Model<CourseType> implements CourseType {
export class CourseTypeModel extends Model implements CourseType {
@PrimaryKey
@Column(DataType.STRING)
coursetypecode!: string
declare coursetypecode: CourseType['coursetypecode']
@HasMany(() => CourseModel)
courses!: Course[]
declare courses: CourseType['courses']
@Column(DataType.JSONB)
name!: Required<Name>
declare name: CourseType['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CourseType['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CourseType['updatedAt']
}
+26 -26
View File
@@ -11,7 +11,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
import type { Credit } from '@oodikone/shared/models'
import { CreditTypeCode } from '@oodikone/shared/types'
import { CourseModel } from './course'
@@ -27,85 +27,85 @@ import { TeacherModel } from './teacher'
modelName: 'credit',
tableName: 'credit',
})
export class CreditModel extends Model<Credit> implements Credit {
export class CreditModel extends Model implements Credit {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Credit['id']
@Column(DataType.STRING)
grade!: string
declare grade: Credit['grade']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
student_studentnumber!: string
declare student_studentnumber: Credit['student_studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Credit['student']
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
semester!: Semester
declare semester: Credit['semester']
@Column(DataType.DOUBLE)
credits!: number
declare credits: Credit['credits']
@ForeignKey(() => CreditTypeModel)
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
declare credittypecode: Credit['credittypecode']
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
creditType!: CreditType
declare creditType: Credit['creditType']
@Column(DataType.DATE)
createdate!: Date
declare createdate: Credit['createdate']
@Column(DataType.DATE)
attainment_date!: Date
declare attainment_date: Credit['attainment_date']
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
teachers!: Teacher[]
declare teachers: Credit['teachers']
@Column(DataType.STRING)
course_code!: string
declare course_code: Credit['course_code']
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
course!: Course
declare course: Credit['course']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
declare course_id: Credit['course_id']
@Column(DataType.STRING)
semester_composite!: string
declare semester_composite: Credit['semester_composite']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Credit['semestercode']
@Column(DataType.BOOLEAN)
isStudyModule!: boolean
declare isStudyModule: Credit['isStudyModule']
@Column(DataType.STRING)
org!: string
declare org: Credit['org']
@Column(DataType.STRING)
language!: string
declare language: Credit['language']
@Column(DataType.BOOLEAN)
is_open!: boolean
declare is_open: Credit['is_open']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyright_id!: string
declare studyright_id: Credit['studyright_id']
@BelongsTo(() => SISStudyRightModel)
studyright!: SISStudyRight
declare studyright: Credit['studyright']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Credit['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Credit['updatedAt']
static passed: ({ credittypecode }: { credittypecode: CreditTypeCode }) => boolean
+5 -5
View File
@@ -10,20 +10,20 @@ import { TeacherModel } from './teacher'
modelName: 'credit_teacher',
tableName: 'credit_teachers',
})
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
export class CreditTeacherModel extends Model implements CreditTeacher {
@ForeignKey(() => CreditModel)
@Column(DataType.STRING)
credit_id!: string
declare credit_id: CreditTeacher['credit_id']
@ForeignKey(() => TeacherModel)
@Column(DataType.STRING)
teacher_id!: string
declare teacher_id: CreditTeacher['teacher_id']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CreditTeacher['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CreditTeacher['updatedAt']
}
+4 -5
View File
@@ -1,7 +1,6 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { CreditType } from '@oodikone/shared/models'
import { CreditTypeCode, Name } from '@oodikone/shared/types'
@Table({
underscored: true,
@@ -11,16 +10,16 @@ import { CreditTypeCode, Name } from '@oodikone/shared/types'
export class CreditTypeModel extends Model<CreditType> implements CreditType {
@PrimaryKey
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
declare credittypecode: CreditType['credittypecode']
@Column(DataType.JSONB)
name!: Required<Name>
declare name: CreditType['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CreditType['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CreditType['updatedAt']
}
@@ -1,34 +1,33 @@
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
import type { CurriculumPeriod } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'curriculum_period',
tableName: 'curriculum_periods',
})
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
export class CurriculumPeriodModel extends Model implements CurriculumPeriod {
@Column({ type: DataType.STRING, primaryKey: true })
id!: string
declare id: CurriculumPeriod['id']
@Column({ type: DataType.JSONB, allowNull: false })
name!: Name
declare name: CurriculumPeriod['name']
@Column({ type: DataType.STRING, allowNull: false })
universityOrgId!: string
declare universityOrgId: CurriculumPeriod['universityOrgId']
@Column({ type: DataType.DATE, allowNull: false })
startDate!: Date
declare startDate: CurriculumPeriod['startDate']
@Column({ type: DataType.DATE, allowNull: false })
endDate!: Date
declare endDate: CurriculumPeriod['endDate']
@CreatedAt
@Column({ type: DataType.DATE, allowNull: false })
createdAt!: Date
declare createdAt: CurriculumPeriod['createdAt']
@UpdatedAt
@Column({ type: DataType.DATE, allowNull: false })
updatedAt!: Date
declare updatedAt: CurriculumPeriod['updatedAt']
}
+18 -19
View File
@@ -10,8 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
import { EnrollmentState } from '@oodikone/shared/types'
import type { Enrollment } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { SemesterModel } from './semester'
@@ -23,59 +22,59 @@ import { StudentModel } from './student'
modelName: 'enrollment',
tableName: 'enrollment',
})
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
export class EnrollmentModel extends Model implements Enrollment {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Enrollment['id']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Enrollment['studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Enrollment['student']
@Column(DataType.STRING)
course_code!: string
declare course_code: Enrollment['course_code']
@Column(DataType.STRING)
state!: EnrollmentState
declare state: Enrollment['state']
@Column(DataType.DATE)
enrollment_date_time!: Date
declare enrollment_date_time: Enrollment['enrollment_date_time']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
declare course_id: Enrollment['course_id']
@ForeignKey(() => SemesterModel)
@Column(DataType.STRING)
semester_composite!: string
declare semester_composite: Enrollment['semester_composite']
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
semester!: Semester
declare semester: Enrollment['semester']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Enrollment['semestercode']
@Column(DataType.BOOLEAN)
is_open!: boolean
declare is_open: Enrollment['is_open']
@ForeignKey(() => SISStudyRightModel)
@Column({ type: DataType.STRING, allowNull: true })
studyright_id!: string
declare studyright_id: Enrollment['studyright_id']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
studyright!: SISStudyRight
declare studyright: Enrollment['studyright']
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
course!: CourseModel
declare course: Enrollment['course']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Enrollment['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Enrollment['updatedAt']
}
@@ -1,28 +1,24 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
import type { CustomPopulationSearch } from '@oodikone/shared/models/kone'
@Table({
underscored: false,
modelName: 'custom_population_search',
tableName: 'custom_population_searches',
})
export class CustomPopulationSearchModel
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
implements CustomPopulationSearch
{
export class CustomPopulationSearchModel extends Model implements CustomPopulationSearch {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: CustomPopulationSearch['id']
@Column(DataType.BIGINT)
userId!: string
declare userId: CustomPopulationSearch['userId']
@Column(DataType.STRING)
name!: string
declare name: CustomPopulationSearch['name']
@Column(DataType.ARRAY(DataType.STRING))
students!: string[]
declare students: CustomPopulationSearch['students']
}
@@ -1,33 +1,32 @@
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
import type { ExcludedCourse } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'excluded_course',
tableName: 'excluded_courses',
})
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
export class ExcludedCourseModel extends Model implements ExcludedCourse {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
id!: CreationOptional<number>
declare id: ExcludedCourse['id']
@Column(DataType.STRING)
programme_code!: string
declare programme_code: ExcludedCourse['programme_code']
@Column(DataType.STRING)
course_code!: string
declare course_code: ExcludedCourse['course_code']
@Column(DataType.STRING)
curriculum_version!: string
declare curriculum_version: ExcludedCourse['curriculum_version']
@Default(DataType.NOW)
@Column(DataType.DATE)
createdAt!: CreationOptional<Date>
declare createdAt: ExcludedCourse['createdAt']
@Default(DataType.NOW)
@Column(DataType.DATE)
updatedAt!: CreationOptional<Date>
declare updatedAt: ExcludedCourse['updatedAt']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
import type { OpenUniPopulationSearch } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,21 +8,18 @@ import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@
modelName: 'open_uni_population_search',
tableName: 'open_uni_population_searches',
})
export class OpenUniPopulationSearchModel
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
implements OpenUniPopulationSearch
{
export class OpenUniPopulationSearchModel extends Model implements OpenUniPopulationSearch {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: OpenUniPopulationSearch['id']
@Column(DataType.BIGINT)
userId!: string
declare userId: OpenUniPopulationSearch['userId']
@Column(DataType.STRING)
name!: string
declare name: OpenUniPopulationSearch['name']
@Column(DataType.ARRAY(DataType.STRING))
courseCodes!: string[]
declare courseCodes: OpenUniPopulationSearch['courseCodes']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
import type { ProgressCriteria } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,50 +8,47 @@ import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/share
modelName: 'progress_criteria',
tableName: 'progress_criteria',
})
export class ProgressCriteriaModel
extends Model<ProgressCriteria, ProgressCriteriaCreation>
implements ProgressCriteria
{
export class ProgressCriteriaModel extends Model implements ProgressCriteria {
@PrimaryKey
@Column(DataType.STRING)
code!: string
declare code: ProgressCriteria['code']
@Column(DataType.STRING)
curriculumVersion!: CreationOptional<string>
declare curriculumVersion: ProgressCriteria['curriculumVersion']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearOne!: string[]
declare coursesYearOne: ProgressCriteria['coursesYearOne']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearTwo!: string[]
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearThree!: string[]
declare coursesYearThree: ProgressCriteria['coursesYearThree']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearFour!: string[]
declare coursesYearFour: ProgressCriteria['coursesYearFour']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearFive!: string[]
declare coursesYearFive: ProgressCriteria['coursesYearFive']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearSix!: string[]
declare coursesYearSix: ProgressCriteria['coursesYearSix']
@Column(DataType.INTEGER)
creditsYearOne!: number
declare creditsYearOne: ProgressCriteria['creditsYearOne']
@Column(DataType.INTEGER)
creditsYearTwo!: number
declare creditsYearTwo: ProgressCriteria['creditsYearTwo']
@Column(DataType.INTEGER)
creditsYearThree!: number
declare creditsYearThree: ProgressCriteria['creditsYearThree']
@Column(DataType.INTEGER)
creditsYearFour!: number
declare creditsYearFour: ProgressCriteria['creditsYearFour']
@Column(DataType.INTEGER)
creditsYearFive!: number
declare creditsYearFive: ProgressCriteria['creditsYearFive']
@Column(DataType.INTEGER)
creditsYearSix!: number
declare creditsYearSix: ProgressCriteria['creditsYearSix']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
import type { StudyGuidanceGroupTag } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,22 +8,19 @@ import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodi
modelName: 'study_guidance_group_tag',
tableName: 'study_guidance_group_tags',
})
export class StudyGuidanceGroupTagModel
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
implements StudyGuidanceGroupTag
{
export class StudyGuidanceGroupTagModel extends Model implements StudyGuidanceGroupTag {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id?: CreationOptional<string>
declare id: StudyGuidanceGroupTag['id']
@Unique
@Column(DataType.STRING)
studyGuidanceGroupId!: string
declare studyGuidanceGroupId: StudyGuidanceGroupTag['studyGuidanceGroupId']
@Column(DataType.STRING)
studyProgramme!: string | null
declare studyProgramme: StudyGuidanceGroupTag['studyProgramme']
@Column(DataType.STRING)
year!: string | null
declare year: StudyGuidanceGroupTag['year']
}
@@ -8,11 +8,11 @@ import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
modelName: 'study_programme_pin',
tableName: 'study_programme_pins',
})
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
export class StudyProgrammePinModel extends Model implements StudyProgrammePin {
@PrimaryKey
@Column(DataType.INTEGER)
userId!: number
declare userId: StudyProgrammePin['userId']
@Column(DataType.ARRAY(DataType.STRING))
studyProgrammes!: string[]
declare studyProgrammes: StudyProgrammePin['studyProgrammes']
}
+8 -9
View File
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
import type { Tag } from '@oodikone/shared/models/kone'
import { TagStudentModel } from './tagStudent'
@@ -9,25 +8,25 @@ import { TagStudentModel } from './tagStudent'
modelName: 'tag',
tableName: 'tag',
})
export class TagModel extends Model<Tag, TagCreation> implements Tag {
export class TagModel extends Model implements Tag {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagStudentModel)
@Column(DataType.BIGINT)
tag_id!: CreationOptional<string>
declare tag_id: Tag['tag_id']
@BelongsTo(() => TagStudentModel)
tagStudent!: CreationOptional<TagStudent>
declare tagStudent: Tag['tagStudent']
@Column(DataType.STRING)
tagname!: string
declare tagname: Tag['tagname']
@Column(DataType.STRING)
studytrack!: string
declare studytrack: Tag['studytrack']
@Column(DataType.STRING)
year!: string | null
declare year: Tag['year']
@Column(DataType.BIGINT)
personal_user_id!: string | null
declare personal_user_id: Tag['personal_user_id']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
import type { TagStudent } from '@oodikone/shared/models/kone'
import { StudentModel } from '../student'
import { TagModel } from './tag'
@@ -10,18 +9,18 @@ import { TagModel } from './tag'
modelName: 'tag_student',
tableName: 'tag_student',
})
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
export class TagStudentModel extends Model implements TagStudent {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagModel)
@Column(DataType.BIGINT)
tag_id!: string
declare tag_id: TagStudent['tag_id']
@PrimaryKey
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: TagStudent['studentnumber']
@BelongsTo(() => TagModel)
tag!: CreationOptional<Tag>
declare tag: TagStudent['tag']
}
+12 -13
View File
@@ -10,8 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import type { Organization } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { CourseProviderModel } from './courseProvider'
@@ -23,37 +22,37 @@ import { SISStudyRightModel } from './SISStudyRight'
modelName: 'organization',
tableName: 'organization',
})
export class OrganizationModel extends Model<Organization> implements Organization {
export class OrganizationModel extends Model implements Organization {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Organization['id']
@Column(DataType.STRING)
code!: string
declare code: Organization['code']
@Column(DataType.JSONB)
name!: Name
declare name: Organization['name']
@Column(DataType.STRING)
parent_id!: string
declare parent_id: Organization['parent_id']
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
children!: Organization[]
declare children: Organization['children']
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
programmeModules!: ProgrammeModule[]
declare programmeModules: Organization['programmeModules']
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
SISStudyRights!: SISStudyRight[]
declare SISStudyRights: Organization['SISStudyRights']
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
courses!: CourseModel[]
declare courses: Organization['courses']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Organization['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Organization['updatedAt']
}
+21 -22
View File
@@ -12,7 +12,6 @@ import {
} from 'sequelize-typescript'
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
import { OrganizationModel } from './organization'
import { ProgrammeModuleChildModel } from './programmeModuleChild'
@@ -22,62 +21,62 @@ import { ProgrammeModuleChildModel } from './programmeModuleChild'
modelName: 'programme_module',
tableName: 'programme_modules',
})
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
export class ProgrammeModuleModel extends Model implements ProgrammeModule {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: ProgrammeModule['id']
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
parents!: ProgrammeModule[]
declare parents: ProgrammeModule[]
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
children!: ProgrammeModule[]
declare children: ProgrammeModule[]
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
declare organization: Organization
@Column(DataType.STRING)
group_id!: string
declare group_id: ProgrammeModule['group_id']
@Column(DataType.STRING)
code!: string
declare code: ProgrammeModule['code']
@Column(DataType.JSONB)
name!: Name
declare name: ProgrammeModule['name']
@Column(DataType.STRING)
type!: 'course' | 'module'
declare type: ProgrammeModule['type']
@Column(DataType.INTEGER)
order!: number
declare order: ProgrammeModule['order']
@Column(DataType.STRING)
studyLevel!: string
declare studyLevel: ProgrammeModule['studyLevel']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organization_id!: string
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
organization!: Organization
declare organization_id: ProgrammeModule['organization_id']
@Column(DataType.DATE)
valid_from!: Date
declare valid_from: ProgrammeModule['valid_from']
@Column(DataType.DATE)
valid_to!: Date
declare valid_to: ProgrammeModule['valid_to']
@Column(DataType.ARRAY(DataType.STRING))
curriculum_period_ids!: string[]
declare curriculum_period_ids: ProgrammeModule['curriculum_period_ids']
@Column(DataType.STRING)
degreeProgrammeType!: DegreeProgrammeType | null
declare degreeProgrammeType: ProgrammeModule['degreeProgrammeType']
@Column(DataType.INTEGER)
minimumCredits!: number | null
declare minimumCredits: ProgrammeModule['minimumCredits']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: ProgrammeModule['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: ProgrammeModule['updatedAt']
}
@@ -9,23 +9,23 @@ import { ProgrammeModuleModel } from './programmeModule'
modelName: 'programme_module_child',
tableName: 'programme_module_children',
})
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
export class ProgrammeModuleChildModel extends Model implements ProgrammeModuleChild {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
declare composite: ProgrammeModuleChild['composite']
@ForeignKey(() => ProgrammeModuleModel)
@Column(DataType.STRING)
parentId!: string
declare parentId: ProgrammeModuleChild['parentId']
@Column(DataType.STRING)
childId!: string
declare childId: ProgrammeModuleChild['childId']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: ProgrammeModuleChild['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: ProgrammeModuleChild['updatedAt']
}
+13 -14
View File
@@ -1,50 +1,49 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Semester } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'semester',
tableName: 'semesters',
})
export class SemesterModel extends Model<Semester> implements Semester {
export class SemesterModel extends Model implements Semester {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
declare composite: Semester['composite']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Semester['semestercode']
@Column(DataType.JSONB)
name!: Name
declare name: Semester['name']
@Column(DataType.INTEGER)
startYear!: number
declare startYear: Semester['startYear']
@Column(DataType.DATE)
startdate!: Date
declare startdate: Semester['startdate']
@Column(DataType.DATE)
enddate!: Date
declare enddate: Semester['enddate']
@Column(DataType.INTEGER)
yearcode!: number
declare yearcode: Semester['yearcode']
@Column(DataType.STRING)
yearname!: string
declare yearname: Semester['yearname']
@Column(DataType.STRING)
org!: string
declare org: Semester['org']
@Column(DataType.INTEGER)
termIndex!: number
declare termIndex: Semester['termIndex']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Semester['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Semester['updatedAt']
}
+24 -26
View File
@@ -1,8 +1,6 @@
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { GenderCode } from '@oodikone/shared/types'
import type { Student } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
@@ -14,73 +12,73 @@ import { StudyplanModel } from './studyplan'
modelName: 'student',
tableName: 'student',
})
export class StudentModel extends Model<Student> implements Student {
export class StudentModel extends Model implements Student {
@PrimaryKey
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Student['studentnumber']
@Column(DataType.STRING)
lastname!: string
declare lastname: Student['lastname']
@Column(DataType.STRING)
firstnames!: string
declare firstnames: Student['firstnames']
@Column(DataType.STRING)
abbreviatedname!: string
declare abbreviatedname: Student['abbreviatedname']
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
enrollments!: Enrollment[]
declare enrollments: Student['enrollments']
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
studyRights!: SISStudyRight[]
declare studyRights: Student['studyRights']
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
studyplans!: Studyplan[]
declare studyplans: Student['studyplans']
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
credits!: Credit[]
declare credits: Student['credits']
@Column(DataType.DATE)
birthdate!: Date
declare birthdate: Student['birthdate']
@Column(DataType.INTEGER)
creditcount!: number
declare creditcount: Student['creditcount']
@Column(DataType.DATE)
dateofuniversityenrollment!: Date
declare dateofuniversityenrollment: Student['dateofuniversityenrollment']
@Column(DataType.STRING)
email!: string
declare email: Student['email']
@Column(DataType.STRING)
secondary_email!: string
declare secondary_email: Student['secondary_email']
@Column(DataType.STRING)
national_student_number!: string
declare national_student_number: Student['national_student_number']
@Column(DataType.STRING)
phone_number!: string
declare phone_number: Student['phone_number']
@Column(DataType.JSONB)
citizenships!: Name[]
declare citizenships: Student['citizenships']
@Column(DataType.STRING)
gender_code!: GenderCode
declare gender_code: Student['gender_code']
@Column(DataType.STRING)
sis_person_id!: string
declare sis_person_id: Student['sis_person_id']
@Column(DataType.BOOLEAN)
hasPersonalIdentityCode!: boolean
declare hasPersonalIdentityCode: Student['hasPersonalIdentityCode']
@Column(DataType.STRING)
preferredLanguage!: string
declare preferredLanguage: Student['preferredLanguage']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Student['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Student['updatedAt']
}
+15 -15
View File
@@ -10,7 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
import type { Studyplan } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@@ -20,48 +20,48 @@ import { StudentModel } from './student'
modelName: 'studyplan',
tableName: 'studyplan',
})
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
export class StudyplanModel extends Model implements Studyplan {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Studyplan['id']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Studyplan['studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Studyplan['student']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
sis_study_right_id!: string
declare sis_study_right_id: Studyplan['sis_study_right_id']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
studyRight!: SISStudyRight
declare studyRight: Studyplan['studyRight']
@Column(DataType.STRING)
programme_code!: string
declare programme_code: Studyplan['programme_code']
@Column(DataType.ARRAY(DataType.STRING))
included_courses!: string[]
declare included_courses: Studyplan['included_courses']
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
includedModules!: string[]
declare includedModules: Studyplan['includedModules']
@Column(DataType.STRING)
sisu_id!: string
declare sisu_id: Studyplan['sisu_id']
@Column(DataType.DOUBLE)
completed_credits!: number
declare completed_credits: Studyplan['completed_credits']
@Column(DataType.STRING)
curriculum_period_id!: string
declare curriculum_period_id: Studyplan['curriculum_period_id']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Studyplan['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Studyplan['updatedAt']
}
@@ -1,26 +1,25 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { StudyrightExtent } from '@oodikone/shared/models'
import { ExtentCode } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'studyright_extent',
tableName: 'studyright_extents',
})
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
export class StudyrightExtentModel extends Model implements StudyrightExtent {
@PrimaryKey
@Column(DataType.INTEGER)
extentcode!: ExtentCode
declare extentcode: StudyrightExtent['extentcode']
@Column(DataType.STRING)
name!: string
declare name: StudyrightExtent['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: StudyrightExtent['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: StudyrightExtent['updatedAt']
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Teacher, Credit } from '@oodikone/shared/models'
import type { Teacher } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { CreditTeacherModel } from './creditTeacher'
@@ -10,22 +10,22 @@ import { CreditTeacherModel } from './creditTeacher'
modelName: 'teacher',
tableName: 'teacher',
})
export class TeacherModel extends Model<Teacher> implements Teacher {
export class TeacherModel extends Model implements Teacher {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Teacher['id']
@Column(DataType.STRING)
name!: string
declare name: Teacher['name']
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
credits!: Credit[]
declare credits: Teacher['credits']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Teacher['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Teacher['updatedAt']
}
+9 -13
View File
@@ -1,11 +1,7 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { Language } from '@oodikone/shared/language'
import type { User, UserCreation } from '@oodikone/shared/models/user'
import type { Role } from '@oodikone/shared/types'
@Table({
underscored: true,
timestamps: false,
@@ -16,33 +12,33 @@ export class UserModel extends Model<User, UserCreation> implements User {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: User['id']
@Column(DataType.STRING)
fullName!: string
declare fullName: User['fullName']
@Unique
@Column(DataType.STRING)
username!: string
declare username: User['username']
@Column(DataType.STRING)
email!: string
declare email: User['email']
@Default('fi')
@Column(DataType.STRING)
language!: CreationOptional<Language>
declare language: User['language']
@Column(DataType.STRING)
sisuPersonId!: string
declare sisuPersonId: User['sisuPersonId']
@Column(DataType.DATE)
lastLogin!: Date
declare lastLogin: User['lastLogin']
@Default([])
@Column(DataType.ARRAY(DataType.STRING))
roles!: CreationOptional<Role[]>
declare roles: User['roles']
@Default([])
@Column(DataType.ARRAY(DataType.STRING))
programmeRights!: CreationOptional<string[]>
declare programmeRights: User['programmeRights']
}
@@ -1,7 +1,6 @@
const { orderBy } = require('lodash')
// dbConnections must be imported to avoid Sequelize errors even if it's not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dbConnections } = require('../database/connection')
const { computeCreditsProduced } = require('../services/providerCredits')
+3 -3
View File
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/node'
import compression from 'compression'
import cors from 'cors'
import express, { Express } from 'express'
import { Express, json as jsonExpress } from 'express'
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
import accessLogger from './middleware/accessLogger'
@@ -39,11 +39,11 @@ import usersFd from './routes/usersFd'
const routes = (app: Express, url: string) => {
app.use(cors({ credentials: true, origin: frontUrl }))
app.use(express.json())
app.use(jsonExpress())
app.use(compression())
app.use(headersMiddleware)
app.use(currentUserMiddleware)
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
app.use(accessLogger)
app.use(`${url}/login`, login)
app.use(url, courses)
@@ -1,12 +1,14 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
router.get('/', async (req: Request, res: Response) => {
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const { user } = req
if (hasFullAccessToStudentData(user.roles)) {
const result = await getCloseToGraduationData()
+84 -82
View File
@@ -1,101 +1,103 @@
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
CoursesMultiResBody,
CoursesMultiReqBody,
CoursesMultiQuery,
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { uniq } from '@oodikone/shared/util'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
interface GetCoursesRequest extends Request {
query: {
name: string
code: string
combineSubstitutions: string
}
}
router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) => {
const { name, code, combineSubstitutions } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code)
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = [
...new Set(
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
),
]
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
async (req, res) => {
const { name, code, combineSubstitutions, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
}
const mergedCourses = {}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
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 (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = uniq(
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
)
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
if (!groupId) continue
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
}
res.json({ courses: Object.values(mergedCourses) })
}
)
res.json({ courses: Object.values(mergedCourses) })
})
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
interface GetCourseYearlyStatsRequest extends Request {
query: {
codes: string[]
separate: string
combineSubstitutions: string
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/v3/courseyearlystats',
async (req, res) => {
const { codes, combineSubstitutions, separate } = req.query
if (!codes) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
const { roles, programmeRights } = req.user
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasAccessToCourseStats = userHasFullAccessToStudentData || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasAccessToCourseStats && !fullStudyProgrammeRights.length) {
return res.status(403).json({ error: 'No valid rights provided' })
}
// Student numbers should be obfuscated to all other users except admins,
// fullSisuAccess users, and users with rights to any specific degree programmes
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
res.json(results)
}
}
router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res: Response) => {
const { roles, programmeRights } = req.user
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
// If user has rights to see at least one programme, then they are allowed to see all of them
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
return res.status(403).json({ error: 'No programmes so no access to course stats' })
}
const { codes } = req.query
if (!codes) {
return res.status(422).send('Missing required query parameters')
}
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
const separate = req.query.separate === 'true'
// 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)
res.json(results)
})
)
export default router
@@ -1,11 +1,17 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CurriculumPeriod } from '@oodikone/shared/models'
import { CurriculumPeriodModel } from '../models'
const router = Router()
router.get('/', async (_req: Request, res: Response) => {
const result = await CurriculumPeriodModel.findAll()
type CurriculumPeriodsResBody = CurriculumPeriod[]
router.get<never, CurriculumPeriodsResBody>('/', async (_req, res) => {
const result: CurriculumPeriod[] = await CurriculumPeriodModel.findAll({
raw: true,
})
res.json(result)
})
@@ -23,7 +23,7 @@ type SearchQuery = {
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { courselist, startdate, enddate } = req.query
const courseCodes = (await safeJSONParse(courselist)) || []
const courseCodes = (await safeJSONParse(courselist)) ?? []
if (!Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courses must be of type array' })
}
@@ -1,5 +1,7 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
import { CanError } from '@oodikone/shared/routes'
import {
getCustomPopulationSearchesByUser,
createCustomPopulationSearch,
@@ -9,65 +11,92 @@ import {
const router = Router()
router.get('/', async (req: Request, res: Response) => {
type GetCustomPopulationSearchResBody = CustomPopulationSearch[]
router.get<never, GetCustomPopulationSearchResBody>('/', async (req, res) => {
const { id } = req.user
const customPopulationSearches = await getCustomPopulationSearchesByUser(id)
res.json(customPopulationSearches)
})
router.post('/', async (req: Request, res: Response) => {
const { name, students } = req.body
const { id } = req.user
type PostCustomPopulationSearchReqBody = {
name: string
students: string[]
}
type PostCustomPopulationSearchResBody = CustomPopulationSearch
if (!name) {
return res.status(400).json({ error: 'Name missing' })
router.post<never, CanError<PostCustomPopulationSearchResBody>, PostCustomPopulationSearchReqBody>(
'/',
async (req, res) => {
const { name, students } = req.body
const { id } = req.user
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (students && !Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
res.json(customPopulationSearch)
}
if (students && !Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
)
type PutCustomPopulationSearchReqBody = {
students: string[]
}
type PutCustomPopulationSearchResBody = CustomPopulationSearch
router.put<never, CanError<PutCustomPopulationSearchResBody>, PutCustomPopulationSearchReqBody>(
'/:id',
async (req, res) => {
const { students } = req.body
const { id } = req.params
const { id: userId } = req.user
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
if (!students) {
return res.status(400).json({ error: 'Students missing' })
}
if (!Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
if (!updatedPopulationSearch) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(updatedPopulationSearch)
}
)
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
res.json(customPopulationSearch)
})
type DeleteCustomPopulationSearchParams = {
id: string
}
type DeleteCustomPopulationSearchResBody = string
router.put('/:id', async (req: Request, res: Response) => {
const { students } = req.body
const { id } = req.params
const userId = req.user.id
router.delete<never, CanError<DeleteCustomPopulationSearchResBody>, never, DeleteCustomPopulationSearchParams>(
'/:id',
async (req, res) => {
const { id } = req.params
const userId = req.user.id
if (!id) {
return res.status(400).json({ error: 'Id missing' })
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
if (!deletedSuccessfully) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(id)
}
if (!students) {
return res.status(400).json({ error: 'Students missing' })
}
if (!Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
}
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
if (!updatedPopulationSearch) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(updatedPopulationSearch)
})
router.delete('/:id', async (req: Request, res: Response) => {
const { id } = req.params
const userId = req.user.id
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
if (!deletedSuccessfully) {
return res.status(404).json({ error: 'Custom population search not found' })
}
res.json(id)
})
)
export default router
+8 -6
View File
@@ -25,7 +25,7 @@ import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWr
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import logger from '../util/logger'
// Faculty uses a lot of tools designed for Study programme.
// Faculty uses a lot of tools designed for Degree programme.
// Some of them have been copied here and slightly edited for faculty purpose.
const router = Router()
@@ -56,7 +56,7 @@ router.get('/:id/basicstats', auth.roles(['facultyStatistics']), async (req: Get
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
@@ -96,7 +96,7 @@ router.get('/:id/thesisstats', auth.roles(['facultyStatistics']), async (req: Ge
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
@@ -130,7 +130,7 @@ router.get(
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes) {
return res.status(422).end()
}
@@ -193,6 +193,7 @@ router.get(
const { graduated, special_groups: specialGroups } = req.query
/* Fetch cached data if exists */
const data = await getFacultyStudentStats(code, specialGroups, graduated)
if (data) {
return res.json(data)
@@ -203,9 +204,10 @@ router.get(
return res.status(422).end()
}
let updatedStats: any = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
if (updatedStats) {
updatedStats = await setFacultyStudentStats(updatedStats, specialGroups, graduated)
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
}
return res.json(updatedStats)
}
+13 -5
View File
@@ -1,19 +1,27 @@
import { Request, Response, Router } from 'express'
import { omit } from 'lodash'
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types/user'
import { ApplicationError } from '../util/customErrors'
const router = Router()
router.get('/', (req: Request, res: Response) => {
type LoginReqBody = never
type LoginResBody = {
user: Omit<FormattedUser, 'studentsUserCanAccess'>
logoutUrl?: string
}
router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
const { user, logoutUrl } = req
if (!user) {
throw new ApplicationError('User not found', 404)
throw new ApplicationError('User not found', 401)
}
res.send({
user: omit(user, ['studentsUserCanAccess']),
user: omitKeys(user, ['studentsUserCanAccess']),
logoutUrl,
})
})
+210 -301
View File
@@ -1,100 +1,111 @@
import * as Sentry from '@sentry/node'
import crypto from 'crypto'
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { difference, intersection, uniq } from 'lodash'
import { CanError } from '@oodikone/shared/routes'
import { GenderCode } from '@oodikone/shared/types'
import type { CanError } from '@oodikone/shared/routes'
import {
type PopulationstatisticsQuery,
type PopulationstatisticsReqBody,
type PopulationstatisticsResBody,
type PopulationstatisticsbycourseResBody,
type PopulationstatisticsbycourseReqBody,
type PopulationstatisticsbycourseParams,
type GetCustomPopulationResBody,
type CustomPopulationQuery,
type PopulationstatisticsStudyprogrammesResBody,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
} from '@oodikone/shared/routes/populations'
import { Unification, Unarray } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import { SISStudyRightModel } from '../models'
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
import { encrypt, type EncrypterData } from '../services/encrypt'
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
import { getStudentTagMap } from '../services/populations/getStudentData'
import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
import { findByCourseAndSemesters } from '../services/students'
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
type EncryptedStudent = EncrypterData
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
return (student as EncryptedStudent)?.encryptedData !== undefined
}
const obfuscateStuff = ({
result,
export type PopulationstatisticsCoursesResBody = CanError<Bottlenecks>
export type PopulationstatisticsCoursesReqBody = {
// NOTE: Encrypted students have their iv in selectedStudents
selectedStudents: string[] | EncryptedStudent[]
selectedStudentsByYear?: { [year: string]: string[] }
courses?: string[]
}
programme,
combinedProgramme,
// NOTE: POST instead of GET because of too long params and "sensitive" data
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
'/v4/populationstatistics/courses',
async (req, res) => {
const { roles, studentsUserCanAccess } = req.user
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
userRoles,
userProgrammeRights,
studentsUserCanAccess,
}: {
result: Awaited<ReturnType<typeof statisticsOf>>
[key: string]: any
}): Awaited<ReturnType<typeof statisticsOf>> => {
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme)
const hasFullAccess = hasFullAccessToStudentData(roles)
const isEncrypted = selectedStudents.some(isEncryptedStudent)
const hasAccessToStudents =
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
if (!hasAccessToStudents && !hasFullAccess) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
}
const requiredFields = [selectedStudents, selectedCourses]
if (requiredFields.some(field => !field)) {
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
}
const result = await bottlenecksOf(
selectedStudents as string[],
selectedStudentsByYear,
selectedCourses,
isEncrypted
)
return res.json(result)
if (hasFullAccessToStudents || hasFullRightsToProgramme || hasFullRightsToCombinedProgramme) {
return result
}
)
export type PopulationstatisticsResBody = CanError<{ students: any }>
export type PopulationstatisticsReqBody = never
export type PopulationstatisticsQuery = {
semesters: string[]
studentStatuses?: string[]
// NOTE: This param is a JSON -object
studyRights: string
year: string
months?: string
years?: string[]
const { criteria, students, coursestatistics } = result
const encryptionMap = new Map<string, string>()
return {
criteria,
students: students.map(student => {
if (studentsUserCanAccess.has(student.studentNumber)) return student
// correct year for age distribution calculation but the date is always January 1st
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
encryptionMap.set(student.studentNumber, studentNumber)
return {
...student,
obfuscated: true,
iv,
studentNumber,
firstnames: '',
lastname: '',
phoneNumber: '',
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
tags: [],
birthdate: obfuscatedBirthDate,
}
}),
coursestatistics: {
courses: coursestatistics.courses,
enrollments: coursestatistics.enrollments.map(enrollment => ({
...enrollment,
studentnumber: encryptionMap.get(enrollment.studentnumber) ?? enrollment.studentnumber,
})),
credits: coursestatistics.credits.map(credit => ({
...credit,
student_studentnumber: encryptionMap.get(credit.student_studentnumber) ?? credit.student_studentnumber,
})),
},
}
}
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/v3/populationstatistics',
async (req, res) => {
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
const months = req.query.months ?? '12'
// NOTE: `year` isn't needed anymore if `years` is defined
const requiredFields = [year, semesters, requestedStudyRightsJSON]
const requiredFields = [years, semesters, programme]
if (requiredFields.some(field => !field)) {
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
}
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
@@ -107,268 +118,166 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
}
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
await safeJSONParse(requestedStudyRightsJSON)
if (requestedStudyRights === null) {
return res.status(400).json({ error: 'Invalid studyrights value!' })
}
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
let result
if (req.query.years) {
const upperYearBound = new Date().getFullYear() + 1
const multiYearStudents = Promise.all(
req.query.years.map(year => {
const yearAsNumber = +year
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
return optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months: monthsForCurrentYear,
})
})
)
const { startDate, endDate } = parseDateRangeFromParams({
...req.query,
years: req.query.years,
})
const populationStudentsMerger = (multiYearStudents: any) => {
const samples = { students: [], courses: [] as any[] }
const uniqueCourseCodes = new Set<string>()
const studentNumbers = await getStudentNumbersWithStudyRights({
programmeCodes: [programme],
startDate,
endDate,
studentStatuses,
})
for (const year of multiYearStudents) {
samples.students = samples.students.concat(year.students)
for (const course of year.courses) {
if (!uniqueCourseCodes.has(course.code)) {
uniqueCourseCodes.add(course.code)
samples.courses.push(course)
}
}
}
const studyRights = [programme]
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
return samples
}
const result = await statisticsOf(studentNumbers, studyRights, tagMap, startDate)
const processed = obfuscateStuff({
result,
result = populationStudentsMerger(await multiYearStudents)
} else {
result = await optimizedStatisticsOf({
...req.query,
userId,
studyRights: requestedStudyRights.programme,
year,
months,
})
}
programme,
combinedProgramme,
// Obfuscate if user has only limited study programme rights and there are any students
if (
'students' in result &&
!hasFullAccessToStudentData(userRoles) &&
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
) {
result.students = result.students.map(student => {
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
// correct year for age distribution calculation but the date is always January 1st
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
return {
...student,
firstnames: '',
lastname: '',
phoneNumber: '',
iv,
studentNumber,
name: '',
email: '',
secondaryEmail: '',
sis_person_id: '',
tags: [],
birthdate: obfuscatedBirthDate,
obfuscated: true,
}
})
}
userRoles,
userProgrammeRights,
studentsUserCanAccess: new Set(studentsUserCanAccess),
})
return res.json(result)
return res.json(processed)
}
)
interface GetPopulationStatisticsByCourseRequest extends Request {
query: {
coursecodes: string
from: string
to: string
separate: string
unifyCourses: UnifyStatus
}
}
router.get<
never,
CanError<PopulationstatisticsbycourseResBody>,
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/v3/populationstatisticsbycourse', async (req, res) => {
const {
id: userId,
roles: userRoles,
programmeRights: userProgrammeRights,
studentsUserCanAccess: allStudentsUserCanAccess,
} = req.user
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
if (!coursecodes || !from || !to) {
if (!coursecodeJSON || !from || !to) {
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
}
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(JSON.parse(coursecodes), Unification.REGULAR)
const isSeparate = separate === 'true'
const coursecodes = JSON.parse(coursecodeJSON)
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
const separate = separateString === 'true'
const requestedYearsToCreatePopulationFrom = Math.ceil(separate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
}
const studentNumbers = await findByCourseAndSemesters(
JSON.parse(coursecodes),
Number(from),
Number(to),
separate,
unifyCourses
)
const result: any = await optimizedStatisticsOf(
{
// Useless, because studentnumbers are already filtered above by from & to.
// We should probably refactor this to avoid more confusement.
userId,
year: '1900',
studyRights: undefined,
semesters: ['FALL', 'SPRING'],
},
studentNumbers
)
let courseProviders: string[] = []
if (!hasFullAccessToStudentData(roles)) {
courseProviders = await getCourseProvidersForCourses(JSON.parse(coursecodes))
}
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
const studentsUserCanAccess =
hasFullAccessToStudentData(roles) || found
? new Set(studentNumbers)
: new Set(intersection(studentNumbers, allStudentsUserCanAccess))
const studentsUserCanAccess = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
? new Set(studentNumbers)
: new Set(allStudentsUserCanAccess)
const randomHash = crypto.randomBytes(12).toString('hex')
const obfuscateStudent = ({
studyRights,
studentNumber,
courses,
gender_code,
}: {
studyRights: SISStudyRightModel[]
studentNumber: string
courses: ParsedCourse[]
gender_code: GenderCode
}) => ({
courses,
studyRights,
gender_code,
studentNumber: crypto.createHash('md5').update(`${studentNumber}${randomHash}`).digest('hex'),
firstnames: '',
lastname: '',
phone_number: '',
name: '',
email: '',
started: null,
credits: '',
tags: [],
obfuscated: true,
const studyRights = []
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
const processed = obfuscateStuff({
result,
userRoles,
userProgrammeRights,
studentsUserCanAccess,
})
if ('students' in result) {
result.students = result.students.map(student =>
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
)
}
res.json(result)
return res.json(processed)
})
interface PostByStudentNumbersRequest extends Request {
body: {
studentnumberlist: string[]
tags?: {
studyProgramme: string | null
year: string | null
}
}
}
// Used in custom population and single study guidance groups
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
'/v3/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
const { studentnumberlist, tags } = req.body
const { id: userId, roles, studentsUserCanAccess } = req.user
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
? studentnumberlist
: intersection(studentnumberlist, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
const result = await optimizedStatisticsOf(
{
userId,
year: tags?.year ?? '1900',
studyRights: studyProgrammeCode,
semesters: ['FALL', 'SPRING'],
},
filteredStudentNumbers
)
const { startDate } = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
: { startDate: undefined }
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
})
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const allRights = uniq(programmeRights.map(programme => programme.code))
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
allRights.push('KH90_001', 'MH90_001')
}
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
})
interface GetMaxYearsRequest extends Request {
query: {
courseCodes: string
}
}
router.get(
'/v3/populationstatistics/maxYearsToCreatePopulationFrom',
async (req: GetMaxYearsRequest, res: Response) => {
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
return res.json({
openCourses: maxYearsToCreatePopulationFromOpen,
uniCourses: maxYearsToCreatePopulationFromUni,
unifyCourses: maxYearsToCreatePopulationFromBoth,
})
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/v3/populationstatistics/studyprogrammes',
async (req, res) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
const allRights = uniq(programmeRights.map(programme => programme.code))
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
allRights.push('KH90_001', 'MH90_001')
}
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
}
)
router.get<
never,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
never,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
return res.json({
openCourses: maxYearsToCreatePopulationFromOpen,
uniCourses: maxYearsToCreatePopulationFromUni,
unifyCourses: maxYearsToCreatePopulationFromBoth,
})
})
export default router
@@ -12,12 +12,13 @@ router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) =
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
const { code, periodIds } = req.params
const result = await getCoursesAndModules(code, periodIds)
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
res.json({
defaultProgrammeCourses: result.defaultProgrammeCourses.courses,
defaultProgrammeModules: result.defaultProgrammeCourses.modules,
secondProgrammeCourses: result.secondProgrammeCourses?.courses,
secondProgrammeModules: result.secondProgrammeCourses?.modules,
defaultProgrammeCourses: defaultProgrammeCourses.courses,
defaultProgrammeModules: defaultProgrammeCourses.modules,
secondProgrammeCourses: secondProgrammeCourses.courses,
secondProgrammeModules: secondProgrammeCourses.modules,
})
})
+46 -53
View File
@@ -1,65 +1,58 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import type { CanError } from '@oodikone/shared/routes'
import type {
GetStudentRequestResBody,
GetStudentRequestReqBody,
GetStudentRequestQuery,
GetStudentDetailParams,
GetStudentDetailResBody,
GetStudentDetailReqBody,
} from '@oodikone/shared/routes/students'
import { splitByEmptySpace } from '@oodikone/shared/util'
import { bySearchTermAndStudentNumbers, withStudentNumber } from '../services/students'
import { hasFullAccessToStudentData, splitByEmptySpace } from '../util'
import { ApplicationError } from '../util/customErrors'
import { hasFullAccessToStudentData } from '../util'
const router = Router()
interface GetStudentsRequest extends Request {
query: {
searchTerm: string
router.get<never, CanError<GetStudentRequestResBody>, GetStudentRequestReqBody, GetStudentRequestQuery>(
'/',
async (req, res) => {
const { searchTerm } = req.query
const trimmedSearchTerm = searchTerm?.trim()
const validSearchTerm =
trimmedSearchTerm &&
!splitByEmptySpace(trimmedSearchTerm)
.slice(0, 2)
.find(term => term.length > 2)
if (!trimmedSearchTerm || validSearchTerm) {
return res.status(400).json({ error: 'at least one search term must be longer than 2 characters' })
}
const { roles, studentsUserCanAccess } = req.user
const results: ReturnType<typeof bySearchTermAndStudentNumbers> = hasFullAccessToStudentData(roles)
? bySearchTermAndStudentNumbers(trimmedSearchTerm)
: bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
return res.json(await results)
}
}
)
router.get('/', async (req: GetStudentsRequest, res: Response) => {
const {
user: { roles, studentsUserCanAccess },
query: { searchTerm },
} = req
router.get<GetStudentDetailParams, CanError<GetStudentDetailResBody>, GetStudentDetailReqBody>(
'/:studentNumber',
async (req, res) => {
const { studentNumber } = req.params
const { id, roles, studentsUserCanAccess } = req.user
const trimmedSearchTerm = searchTerm ? searchTerm.trim() : undefined
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber))
return res.status(403).json({ error: 'User does not have permission to view current student.' })
if (
trimmedSearchTerm &&
!splitByEmptySpace(trimmedSearchTerm)
.slice(0, 2)
.find(term => term.length > 2)
) {
throw new ApplicationError('at least one search term must be longer than 2 characters', 400)
const student = await withStudentNumber(studentNumber, id)
if (!student) return res.status(404).json({ error: 'Student not found' })
res.json(student)
}
let results: Awaited<ReturnType<typeof bySearchTermAndStudentNumbers>> = []
if (trimmedSearchTerm) {
results = hasFullAccessToStudentData(roles)
? await bySearchTermAndStudentNumbers(trimmedSearchTerm)
: await bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
}
res.json(results)
})
interface GetStudentRequest extends Request {
params: {
studentNumber: string
}
}
router.get('/:studentNumber', async (req: GetStudentRequest, res: Response) => {
const { studentNumber } = req.params
const {
user: { id, roles, studentsUserCanAccess },
} = req
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber)) {
return res
.status(403)
.json({ error: `User does not have permission to view data for student number ${studentNumber}.` })
}
const student = await withStudentNumber(studentNumber, id)
if (!student) {
return res.status(404).json({ error: `Student not found with student number ${studentNumber}.` })
}
res.json(student)
})
)
export default router
@@ -180,7 +180,7 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
const data = await getStudyTracksForProgramme(code)
return res.json(data)
} catch (error) {
logger.error({ message: `Failed to get study tracks for study programme ${code}`, meta: `${error}` })
logger.error({ message: `Failed to get study tracks for degree programme ${code}`, meta: `${error}` })
}
})
+6 -5
View File
@@ -1,12 +1,12 @@
import { Request, Response, Router } from 'express'
import { mapToProviders } from '@oodikone/shared/util'
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
const router = Router()
@@ -28,9 +28,10 @@ router.get('/', fullAccessAuth(), async (req: GetTeachersRequest, res: Response)
}
const trimmedSearchTerm = searchTerm.trim()
const stringSearchTermIsInvalid = !splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
const numericSearchTermIsInvalid = !Number.isNaN(Number(trimmedSearchTerm)) && trimmedSearchTerm.length < 6
if (stringSearchTermIsInvalid || numericSearchTermIsInvalid) {
const searchTermIsInvalid =
!validateParamLength(trimmedSearchTerm, 4) &&
!splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
if (searchTermIsInvalid) {
return res.status(400).json({ error: 'Invalid search term' })
}
+1 -1
View File
@@ -112,7 +112,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
const allFaculties = await getSortedFaculties()
const facultyCodes = allFaculties.map(faculty => faculty.code)
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
const programmeFilter = serviceProvider === 'toska' ? 'NEW_STUDY_PROGRAMMES' : 'ALL_PROGRAMMES'
const programmeFilter = serviceProvider === 'toska' ? 'NEW_DEGREE_PROGRAMMES' : 'ALL_PROGRAMMES'
for (const facultyCode of facultyCodes) {
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
if (!data) {
+3 -3
View File
@@ -88,10 +88,10 @@ router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
})
router.post('/refresh_study_programmes_v2', async (req: Request, res: Response) => {
logger.info(`${req.user.username} requested refresh of study programmes`)
router.post('/refresh_degree_programmes_v2', async (req: Request, res: Response) => {
logger.info(`${req.user.username} requested refresh of degree programmes`)
await refreshProgrammes()
res.status(200).json('Added job for refreshing study programme overviews')
res.status(200).json('Added job for refreshing degree programme overviews')
})
router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
+92 -73
View File
@@ -1,113 +1,132 @@
import { Request, Response, Router } from 'express'
import { Router } from 'express'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import { Role } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const router = Router()
router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
const results = await userService.findAll()
res.json(results)
})
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
type GetUserRolesResBody = readonly string[]
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
res.json(roles)
})
interface UidRequest extends Request {
params: {
uid: string
type GetUserByIdParams = { uid: string }
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { data: user, error } = await tryCatch(userService.findOne(uid))
if (error) return res.status(400).json({ error: error.message })
return res.json(user)
}
)
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
'/modify-roles',
auth.roles(['admin']),
async (req, res) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
return res.status(204).end()
}
)
type UserEmailPreviewResBody = {
subject: string
html: string
}
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
const { uid } = req.params
const { data: user, error } = await tryCatch(userService.findOne(uid))
if (error) return res.status(400).json({ error: error.message })
return res.json(user)
})
interface ModifyRolesRequest extends Request {
body: {
username: string
roles: Record<Role, boolean>
}
}
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
const { username, roles } = req.body
const { error } = await tryCatch(userService.modifyAccess(username, roles))
if (error) {
return res.status(400).json(error)
}
return res.status(204).end()
})
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
res.json({ subject: accessMessageSubject, html: accessMessageText })
})
interface EmailRequest extends Request {
body: {
email: string
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
'/email',
auth.roles(['admin']),
async (req, res) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
}
}
)
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
const { email } = req.body
if (!email) return res.status(400).json({ error: 'Email address is missing' })
type ElementsParams = { uid: string }
type ElementsReqBody = { codes: string[] }
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
type PostUserUIDElementsResBody = void
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
interface ElementsRequest extends Request {
params: {
uid: string
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, true)
res.status(204).end()
}
body: {
codes: string[]
)
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, false)
res.status(204).end()
}
}
)
router.post('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, true)
res.status(204).end()
})
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.delete('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
const { uid } = req.params
const { codes } = req.body
await userService.modifyElementDetails(uid, codes, false)
res.status(204).end()
})
interface ChangeLanguageRequest extends Request {
body: {
language: Language
}
}
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
return res.status(400).json('Invalid language')
return res.status(400).json({ error: 'Invalid language' })
}
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
@@ -1,6 +1,4 @@
import moment from 'moment'
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
import { Graduated, SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
import { redisClient } from './redis'
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
@@ -49,7 +47,7 @@ export const setBasicStats = async (data, yearType: YearType, specialGroups: Spe
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -84,7 +82,7 @@ export const setCreditStats = async (data, isAcademicYear: boolean, specialGroup
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!isRelevantProgramme(id) && !isFaculty(id)) {
return dataToRedis
@@ -117,7 +115,7 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -141,16 +139,16 @@ export const getStudyTrackStats = async (
if (!dataFromRedis) {
return null
}
return JSON.parse(dataFromRedis)
return JSON.parse(dataFromRedis) as StudyTrackStats & { status: string; lastUpdated: string }
}
export const setStudyTrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
export const setStudyTrackStats = async (data: StudyTrackStats, graduated: Graduated, specialGroups: SpecialGroups) => {
const { id } = data
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
const dataToRedis = {
...data,
status: 'DONE',
lastUpdated: moment().format(),
lastUpdated: new Date().toISOString(),
}
if (!shouldSaveToRedis(id)) {
return dataToRedis
@@ -129,7 +129,7 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
return formattedEnrollments
}
const getStudents = async (studentNumbers: string[]) => {
const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses[]> => {
const students = await StudentModel.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
@@ -147,9 +147,9 @@ const getStudents = async (studentNumbers: string[]) => {
})
return students.map(student => {
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
const { studyplans, ...rest }: StudentWithStudyplanNested = student.toJSON()
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
return { ...rest, coursesInStudyPlan } as StudentWithCourses
return { ...rest, coursesInStudyPlan }
})
}

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