Compare commits

...
165 Commits
Author SHA1 Message Date
Santeri Rajamäki 3c367f5b93 [META] Fix Fetchios lint error 2026-04-10 13:34:54 +03:00
Santeri Rajamäki ee2240f457 [META] Update Fetchios return value handling 2026-04-10 13:34:54 +03:00
Santeri Rajamäki fb4ecc7785 [META] Convert Fetchios to use Result 2026-04-10 13:34:54 +03:00
Santeri Rajamäki 594ffb460b [META] Add relative path support for browsers 2026-04-10 13:34:54 +03:00
Santeri Rajamäki d9d7f2ae80 [META] Rearrange fetchios url in one-shots 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 8425643639 [META] Fix fetchios url appending 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 33d5a04dc8 [META] Add node types to tsconfig 2026-04-10 13:33:01 +03:00
Santeri Rajamäki abbecf412e [Shared] Convert fetchios fetch to globalThis.fetch 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 6809c668e0 [META] Change axios to fetchios 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 0db4db47ac [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-10 13:33:01 +03:00
Santeri Rajamäki ab420572eb [META] Introduce Fetchios 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 657a96abc0 [Frontend] Remove axios dependency 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 1760a165e4 [Frontend] Swap api caller from axios to fetch 2026-04-10 13:33:01 +03:00
Santeri Rajamäki 3f311dee69 [Frontend] Remove legacy RTKApi middleware 2026-04-01 13:57:39 +03:00
Santeri Rajamäki eb6b683296 [Frontend] Convert apiConnections to typescript 2026-04-01 13:56:02 +03:00
Aaro Koivukangas b2153ec3dd [ClassStats][Courses] Add better handling for null substitution_groups (only in test-data) 2026-04-01 13:34:40 +03:00
Aaro Koivukangas 8949e7baf0 [Class stats][Courses] Better substitution group type handling 2026-03-31 15:18:47 +03:00
joonas-a 1fa10f7812 [Scripts] fix incorrect container name 2026-03-31 14:47:33 +03:00
Aaro Koivukangas fbe5db8a43 [E2E] Fix lint errors and e2e tests 2026-03-31 14:42:57 +03:00
Aaro Koivukangas d4c7a4014b [Class stats][Courses] Handle substitution groups correctly + fix
include substitutions button
2026-03-31 14:42:57 +03:00
Santeri Rajamäki 1177c2f848 [OT] Use 0th index in pagination when table data changes 2026-03-31 14:40:53 +03:00
Santeri Rajamäki 0f8d72fd1d [Frontend] Remove manual pagination from OT implementations 2026-03-31 14:36:45 +03:00
Santeri Rajamäki 47e3e2e26e [OT] Fix pagination 2026-03-31 14:36:05 +03:00
Santeri Rajamäki a312a14944 [Frontend] Disallow prop forwarding for all internal StyledTable attributes 2026-03-31 12:01:33 +03:00
joonas-a e3ec33f8dc [Course stat tables] try to add 0s 2026-03-30 16:10:22 +03:00
joonas-a 9c55d3b2ab [Highcharts] fix median time bar chart threshold lines 2026-03-30 16:07:51 +03:00
joonas-a 17ce83660c [e2e] programme overview graduation time fixes 2026-03-30 15:40:59 +03:00
joonas-a 22d3877b8e [Faculty][Graduation times] update graduation time targets in highcharts 2026-03-30 14:58:20 +03:00
joonas-a c60bee34fd [Backend][Faculty] update graduation time targets 2026-03-30 14:51:32 +03:00
joonas-a 15240050b7 [Faculty] graduation time infobox update 2026-03-30 14:31:18 +03:00
joonas-a 7da09c8b02 [Programme overview] update graduation time infoboxes 2026-03-30 14:31:18 +03:00
joonas-a cfb2d4d5c0 [Frontend][Graduation times] replace months with semesters 2026-03-30 14:31:18 +03:00
joonas-a 39b594d795 [Backend] Use semesters with graduation times 2026-03-30 14:31:18 +03:00
Santeri Rajamäki ff5bce1713 [E2E] Scroll coursestatistics data table into view 2026-03-30 10:26:56 +03:00
Santeri Rajamäki 8e288f3b26 [Frontend] Remove columnOrder from course statistics tables 2026-03-30 10:07:06 +03:00
Santeri Rajamäki 4134c8b64b [Frontend] Use manualPagination on course stats tables 2026-03-27 15:47:17 +02:00
Santeri Rajamäki ddc1a22492 [Frontend] Cleanup 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 8ff1cb54dc [Frontend] Convert AttemptsTable to OT 2026-03-27 15:47:17 +02:00
Santeri Rajamäki 777de520f9 [Frontend] Convert StudentsTable to OT 2026-03-27 15:47:17 +02:00
joonas-a a9e325fe72 [Updater] Add transfer info to study rights 2026-03-26 17:41:17 +02:00
joonas-a befc3db3a8 [Filters][Studentnumber] Fix blacklist blacklisting all students always 2026-03-26 17:41:17 +02:00
Santeri Rajamäki e1b8ebd17f [Frontend] Readd EquivalenceGroupChip 2026-03-26 13:13:05 +02:00
Santeri Rajamäki 883129bee0 [Frontend] Unify course page secondary chip with course search table chips 2026-03-26 12:13:51 +02:00
Santeri Rajamäki 18eb04beeb [Frontend] Rework course search table and equivalence group chips 2026-03-26 12:12:19 +02:00
Santeri Rajamäki eb12d7e5ec [OT] Add cell width under size attribute 2026-03-26 12:08:56 +02:00
Santeri Rajamäki 26ca0a3bc1 [META] Remove redundant reset_jami_data script from setup script 2026-03-25 17:34:47 +02:00
Santeri Rajamäki b79c97780b [META] Update JAMI url in docker-compose 2026-03-25 17:29:04 +02:00
Santeri Rajamäki 0cd3bf28ff [Frontend] Replace MRT with StyledTable in StudyProgrammeTable 2026-03-25 16:20:55 +02:00
Santeri Rajamäki 20b0ba5a30 [Frontend] Enhance StyledTable 2026-03-25 16:19:12 +02:00
Aaro Koivukangas 67001a9db1 [Updater] Remove limit of 1000 entries from update-meta in dev environment 2026-03-23 14:15:51 +02:00
Aaro Koivukangas 8c268395b0 [E2E] Fix broken test 2026-03-20 16:32:41 +02:00
Aaro Koivukangas 235d2b03b2 [CourseStat][Search] Use substitution_groups when searching for courses 2026-03-20 15:53:11 +02:00
Aaro Koivukangas cbe20f1ad3 [Updater] Handle substitution_groups with null/undefined codes better 2026-03-20 15:53:11 +02:00
Matti Luukkainen 47211dcf89 [faculty/study programme] moar compact credits produced table (fits in screen) 2026-03-20 12:48:41 +02:00
joonas-a 26c6c653e5 [Custom pop] Enable hetu filter 2026-03-19 15:52:56 +02:00
Aaro Koivukangas 13bdf698cf [CI] Skip very flaky test 2026-03-19 15:29:25 +02:00
Aaro Koivukangas fd6a65925a [CI][DegreeProgrammes] Fix hetu-e2e tests once again 2026-03-19 15:03:14 +02:00
Aaro Koivukangas 435d2920fb [CI][DegreeProgramme] More e2e test fixes 2026-03-19 14:47:27 +02:00
Aaro Koivukangas 4ae93e0b7e [Updater] Add latest executed migration to starting logs 2026-03-19 14:45:18 +02:00
Aaro Koivukangas dc5200da2f [CI] Fix broken hetu-tests 2026-03-19 14:27:20 +02:00
joonas-a 373c8f9de8 [CI] remove debug print 2026-03-19 13:34:00 +02:00
joonas-a 34cc022830 [Custom pop][General tab] Fetch hops credits from acual hops
Also integrated CI test
2026-03-19 13:32:03 +02:00
Aaro Koivukangas ab311ddc66 [CI] Don't trigger all workflows if lint fails 2026-03-19 13:27:50 +02:00
Aaro Koivukangas 833fdad3bb [CI] Fix lint errors 2026-03-19 13:21:52 +02:00
joonas-a 4b634e032d [Eslint] Disable root imports
These cause silent backend crashes which are hard to debug
2026-03-19 13:18:41 +02:00
Aaro Koivukangas a16fd13413 [CI] Fix broken import in mailServices 2026-03-19 13:11:46 +02:00
Aaro Koivukangas 58913b4684 [Docker] Make .dockerignore work by moving it to context root 2026-03-19 12:58:55 +02:00
joonas-a 05c8993ba3 [Backend] Fix path import 2026-03-19 12:52:49 +02:00
joonas-a d3090ae725 [Ooditable] Highlight hovered row 2026-03-19 12:18:49 +02:00
joonas-a c870061fec [Excel exports] Show acual values in preview
No more empty strings
2026-03-19 12:00:49 +02:00
Aaro Koivukangas ec43699c3c [CI] Add backend and frontend logs to e2e tests 2026-03-19 11:57:56 +02:00
joonas-a 1b1e380a13 [Frontend] make lowercasing consistent 2026-03-17 17:21:14 +02:00
joonas-a 5c13b5c0ab [Programme overview][Faculties] hetu/ton credits produced 2026-03-17 17:18:02 +02:00
joonas-a f35fd41bfb [Backend] Include hetu/ton split in provider credits computation 2026-03-17 17:15:31 +02:00
joonas-a 6548cdd42b [Course population] Enable HETU filter 2026-03-17 15:16:30 +02:00
joonas-a 49b0da760d [Filters] New filter: Personal identity code 2026-03-17 15:16:30 +02:00
joonas-a 34e9cf5667 [Backend] Add "has hetu" to students 2026-03-17 15:16:30 +02:00
Aaro Koivukangas 288d32c591 [Backend][MailService] Backend doesn't crash if it tries to send emails in non-production environments 2026-03-17 13:16:04 +02:00
joonas-a 5001d55843 [Programme courses] Display hetuttomat/hetulliset 2026-03-17 12:25:41 +02:00
joonas-a eaedfe99db [Backend][Programme courses] Split open uni students (hetuton/hetu on) 2026-03-17 12:25:41 +02:00
Aaro Koivukangas 6cf375936a [Meta] Lint fixes 2026-03-17 12:02:12 +02:00
Aaro Koivukangas 8bc5c3ea65 [Updater][Substitutions] Add og. substitutions back and move the new substitutions to substitution_groups 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 3e4e749625 [CoursesSearch] Rebuild substitution combination 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas 114af7522d [Backend] Try apply fix for courseFinder 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas c0b29d69c7 [Updater] Fix substitution .map() for modules 2026-03-17 11:49:49 +02:00
Santeri RajamäkiandAaro Koivukangas fb541ffa3a [Updater] Build course unit substitutions as substitution groups/arrays 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 0ed4ce7bb4 [Updater] Clarify updateCourses function 2026-03-17 11:49:20 +02:00
Santeri RajamäkiandAaro Koivukangas 87e1e332a8 [Frontend] Convert sentry util to typescript 2026-03-17 11:49:20 +02:00
joonas-a 914440cef9 [Programme courses] Enable stripes in table 2026-03-17 10:52:06 +02:00
Aaro Koivukangas 901a4de21c [Updater] Allow updater workflow to trigger 2026-03-12 13:35:45 +02:00
Aaro Koivukangas 2bc28c7dd3 [Student] Convert BachelorHonors to ts 2026-03-12 13:00:57 +02:00
Aaro Koivukangas 2dd43b1ccd [StudyGuidanceGroups] Typo fix 2026-03-12 10:51:59 +02:00
Aaro Koivukangas 3fc1bc161b [Models] Add substitution_groups to course table in sis-db 2026-03-11 18:54:38 +02:00
Aaro Koivukangas 1edffd437e [ClassStats][CoursesOfPopulation] Remove module stats from Show all view 2026-03-11 17:14:49 +02:00
Aaro Koivukangas 4ed5e610bf [StudyGuidanceGroups] Change close-to-graduation link once more 2026-03-11 13:09:54 +02:00
Aaro Koivukangas 90ab1e80f7 [ClassStats][CoursesOfPopulation] Remove unecessary fields from module stats 2026-03-11 12:31:54 +02:00
Santeri Rajamäki 5b9f3c0184 [Meta] Update docker CI actions 2026-03-10 13:35:17 +02:00
Santeri Rajamäki 3f81fff6aa [Meta] Update swc plugin for vite 2026-03-10 13:32:17 +02:00
Santeri Rajamäki 5058a54099 [Meta] Update Vite for frontend 2026-03-10 13:27:10 +02:00
Santeri Rajamäki c5fb781f50 [Meta] Remove Vite from backend 2026-03-10 13:26:03 +02:00
Santeri Rajamäki 6c45c5cd76 [Meta] Upgrade Redux toolkit
This upgrade should bring noticeable improvments to frontend mankelointi times
2026-03-10 13:11:43 +02:00
Santeri Rajamäki b7e3df2e6b [Meta] Update sentry packages 2026-03-10 12:58:32 +02:00
Santeri Rajamäki 35ce0388eb [Meta] Update sequelize
Patch for CVE in JSON where parsing
2026-03-10 12:31:04 +02:00
Santeri Rajamäki 5252f3b816 [Meta] Update node-postgres and add type package for redundancy 2026-03-10 12:31:04 +02:00
Aaro Koivukangas 48f108c41d [StudyGuidanceGroup] Move filtering to the correct component 2026-03-10 12:16:07 +02:00
Santeri Rajamäki bade936f3c [Meta] Upgrade actions checkout, upload-artifact and download-artifact 2026-03-10 12:00:50 +02:00
Aaro Koivukangas c17721bd54 [StudyGuidanceGroup] Remove duplicate entries from Associated programme selector 2026-03-10 11:53:45 +02:00
Santeri Rajamäki a2e9064376 [Frontend] Move primitive functions to primitive file 2026-03-10 11:33:04 +02:00
Aaro Koivukangas 4b92ce4bd9 [StudyGuidanceGroup] Add better description to a link to close to graduation 2026-03-10 11:05:01 +02:00
Aaro Koivukangas f2348db941 [StudyGuidanceGroup] Fix empty table and add courses <-> modules switch to the sgg population view 2026-03-06 15:14:42 +02:00
Aaro Koivukangas b828904c3d [StudyGuidanceGroup] Migrate SGG population courses to ts 2026-03-06 14:49:14 +02:00
Aaro Koivukangas ed82a26e45 [Meta] Update remaining useGetProgrammesQuery users 2026-03-06 11:24:17 +02:00
Aaro Koivukangas b87ee36ebf [Students] Fix style 2026-03-05 17:10:15 +02:00
Aaro Koivukangas 7695315843 [Meta] keyBy is now used, letsgoo! 2026-03-05 16:31:09 +02:00
Aaro Koivukangas 0a292057cb [StudyGuidanceGroups] Add all degree programmes as options when selecting associated programmes for SGG 2026-03-05 16:24:12 +02:00
Aaro Koivukangas 0f5c672f5d [StudyGuidanceGroups] Change getCombinedProgrammeName to use switch case 2026-03-04 17:42:17 +02:00
joonas-a 8ec509aeed [General tab] before starting -> prior to x 2026-03-03 20:57:14 +02:00
joonas-a 7f4eab62a7 [General tab] Rephrase start date col 2026-03-03 20:10:35 +02:00
Aaro Koivukangas 6d82475696 [StudyGuidance] Correctly set initial year when adding starting year to group 2026-03-03 11:36:31 +02:00
Santeri RajamäkiandAaro Koivukangas bdcdd43230 [META] Remove toska sub- and prefixes from function names 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas 80e91d7bfb [Chore] Remove unused modules re-emerged after rebase 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas b15a56b177 [Frontend] Remove unused modules 2026-02-27 16:36:24 +02:00
Santeri RajamäkiandAaro Koivukangas c2684a4e8e [META] Remove apparent service provider specific code 2026-02-27 16:36:24 +02:00
Aaro Koivukangas 4ed8b3d2e9 [LanguageCenter] Add a course name/code filter 2026-02-27 14:34:59 +02:00
Santeri Rajamäki d6c55ed4f9 [Filters] Fix courses filter .courses being read-only 2026-02-27 13:58:13 +02:00
Aaro Koivukangas 97804b645e [ClassStats][CoursesOfClass] Fix filter buttons not working 2026-02-26 14:31:45 +02:00
Aaro Koivukangas 6b1c3e358a [CloseToGraduation] Add placeholders for text filters 2026-02-25 14:47:54 +02:00
Aaro Koivukangas 1935e36a3a [Meta] Fix graduated count in ProgressBars 2026-02-25 13:49:26 +02:00
Aaro Koivukangas 15d0acae13 [ClassStats][ProgressTab] Remove accidental text from link 2026-02-25 13:02:53 +02:00
Aaro Koivukangas ca31e0ed31 [ClassStats][ProgressTab] Add link to create progress criteria if it doesn't exist 2026-02-25 12:57:53 +02:00
joonas-a ab4c7c9ec3 [Cleanup] Replace HOPS -> studyplan and fix typo 2026-02-24 18:29:18 +02:00
joonas-a 6cd3e1bf4f [General tab] Enable study time months col for bsc programmes 2026-02-24 18:28:18 +02:00
joonas-a f836b3dacd [General tab] Better column grouping and ordering 2026-02-24 17:54:00 +02:00
joonas-a 066e1267f1 [Custom pop General tab] Add new cols if programme explicitly set 2026-02-24 17:11:17 +02:00
joonas-a eb29a4d261 [SGG General tab] Show new cols if programme set 2026-02-24 17:05:04 +02:00
joonas-a 85fc25eec7 [General tab] New cols: credits and courses attained before starting in programme
* that are in study plan for current programme
2026-02-24 16:57:39 +02:00
Santeri Rajamäki 27d8d73e3d [Frontend] Move color lists to theme palette 2026-02-24 11:08:31 +02:00
Aaro Koivukangas 4df339fa25 [PopulationStatistics][Courses of class] Rename 'Show all courses or modules' 2026-02-24 10:48:31 +02:00
Aaro Koivukangas 0b2349d1c1 [ClassStats][Progress] Fix missing course names/codes 2026-02-20 13:35:48 +02:00
Aaro Koivukangas 55f18a4cef [ClassStats][Progress] Table filtering works on all columns 2026-02-20 13:35:48 +02:00
Aaro Koivukangas 022efc22bd [ClassStats][Progress] Implement types to ProgressTable 2026-02-20 13:35:48 +02:00
Aaro Koivukangas a02e4adfa7 [ClassStats][Progress] Convert Progress tab to typescript 2026-02-20 13:35:48 +02:00
joonas-a 92db3af85f [General tab] remove excess Date casting 2026-02-19 17:07:21 +02:00
joonas-a e112bd3482 [General tab] New col: Study time in months for masters programmes 2026-02-19 16:44:16 +02:00
joonas-a 205a3079ad [Courses of class] quick and dirty too narrow column fix 2026-02-18 15:10:21 +02:00
Aaro Koivukangas 29a0447b3f [ClassStats][CoursesOfX] Change default Link visibility 2026-02-18 15:00:32 +02:00
Aaro Koivukangas ee65aac42a [ClassStats][CoursesOfPopulation] Add stats to modules 2026-02-18 14:52:02 +02:00
joonas-a 56c59cb86b [Frontend / Ooditable] Fix too large boxshadow in multiline headers 2026-02-18 13:42:59 +02:00
Aaro Koivukangas e404fe09e3 [E2E][ProgrammeCourses] Change more header names in tests 2026-02-18 10:27:23 +02:00
Aaro Koivukangas 613164b3da [E2E][ProgrammeCourses] Fix header name in e2e test 2026-02-18 10:01:53 +02:00
joonas-a 7446760798 [Frontend][Programme courses] Add final info texts 2026-02-17 20:01:56 +02:00
joonas-a a86a99ef96 [Programme courses] Update infobox 2026-02-17 19:46:57 +02:00
joonas-a df7f2b75da [Programme courses] Add docstring and sanity check 2026-02-17 18:32:54 +02:00
joonas-a 3f40896f3b [E2E] adjust numbers 2026-02-17 16:29:59 +02:00
joonas-a 975d37142a [DX][Nodemon] Fix backend hotreload on helix/nvim 2026-02-17 14:55:49 +02:00
Matti Luukkainen 6026f04fcf try optimize workflow 2026-02-16 00:50:41 +02:00
Matti Luukkainen 12182193e1 feat: if course population covers only one semester, indicate it in the name 2026-02-16 00:20:03 +02:00
Aaro Koivukangas ca8ad3ea04 [PopulationStatistics][Courses of class] Show all courses/modules changes text based on Course <-> Module toggle 2026-02-14 12:57:42 +02:00
Aaro Koivukangas b21693ba1d [PopulationStatistics][Courses of class] Fix table dissapearing if Course <-> Module tab returns 0 rows 2026-02-14 12:57:42 +02:00
Aaro Koivukangas b99d3ff649 [PopulationStatistics][Courses of class] Add a toggle to show only courses or modules in "Show all courses" view 2026-02-14 12:57:42 +02:00
joonas-a 2ca49bbe32 [Faculty][Degree programme overview] Remove special category switch
Instead show everything by default, also add "other" category.

Commit powered by AI btw
2026-02-13 18:31:47 +02:00
joonas-a 9015be8458 [Programme courses] Remove merging of AY courses 2026-02-13 17:26:20 +02:00
joonas-a 8dc95c7a89 [Programme courses] Rework credit categories 2026-02-13 16:56:39 +02:00
Santeri Rajamäki 4537bd792b [Frontend] Hide progress tab (once again) from multi year populations 2026-02-13 15:09:27 +02:00
Santeri Rajamäki fcea050b9d [FilterView] Cleanup 2026-02-06 16:35:42 +02:00
213 changed files with 4481 additions and 4627 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ runs:
using: composite
steps:
- name: 'Login to toska Docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
@@ -38,7 +38,7 @@ runs:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
@@ -47,4 +47,4 @@ runs:
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml logs
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
+6 -6
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Get current time'
id: get_current_time
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -85,7 +85,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -119,7 +119,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
@@ -157,7 +157,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -178,7 +178,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
@@ -37,7 +37,7 @@ jobs:
node-version: 24
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
@@ -88,7 +88,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get current time
id: get_current_time
@@ -64,7 +64,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build backend image
id: build-image
@@ -99,7 +99,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -118,7 +118,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+58 -21
View File
@@ -16,7 +16,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -25,10 +25,10 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build image and export it'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
@@ -41,7 +41,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: backend-image
path: /tmp/backend.tar
@@ -50,7 +50,7 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
@@ -76,7 +76,7 @@ jobs:
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
@@ -98,9 +98,9 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -120,15 +120,15 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Download backend image'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: check_migrations
with:
@@ -157,13 +157,13 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load built backend image'
run: docker load --input backend.tar
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -182,12 +182,12 @@ jobs:
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled()
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -204,6 +204,25 @@ jobs:
exit 1;
fi
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
@@ -218,15 +237,24 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
@@ -281,13 +309,22 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+14 -16
View File
@@ -13,7 +13,7 @@ jobs:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -26,10 +26,10 @@ jobs:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-worker/
push: false
@@ -38,7 +38,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: updater-worker-image
path: /tmp/worker.tar
@@ -47,10 +47,10 @@ jobs:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-scheduler/
push: false
@@ -59,7 +59,7 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
@@ -72,15 +72,15 @@ jobs:
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
@@ -115,9 +115,8 @@ jobs:
name: 'Build and release Updater worker'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build worker image
id: build-image
@@ -145,9 +144,8 @@ jobs:
name: 'Build and release Updater scheduler'
runs-on: ubuntu-latest
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Build scheduler image
id: build-image
-3
View File
@@ -65,9 +65,6 @@ instrumented/
.vscode/launch.json
.vscode/terminals.json
# Funidata specific
environments/
# Target of shared symlink
services/**/src/shared
+1 -1
View File
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
}
const checkTableContents = contents => {
cy.get('table tbody').scrollIntoView()
cy.get('table tbody').within(() => {
contents.forEach((values, trIndex) => {
cy.get('tr')
@@ -396,7 +397,6 @@ describe('Basic user', () => {
cy.contains('Search for courses')
searchByCourseCode('TKT')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
cy.contains('Select all search results').should('not.exist')
cy.cs('select-multiple-courses-toggle').click()
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
+1 -1
View File
@@ -12,7 +12,7 @@ const save = () => {
}
const selectSavedPopulation = name => {
cy.cs('history-search').click().type(`${name}{enter}`)
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
+68 -62
View File
@@ -93,31 +93,14 @@ describe('Degree programme overview', () => {
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 209],
[2021, 5133, 5108, 0, 25, 428],
[2020, 5801, 5796, 0, 5, 94],
[2019, 5305, 5305, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 21],
[2017, 1211, 1211, 0, 0, 189],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it("Toggling 'Show special categories' on displays additional information", () => {
cy.cs('special-categories-toggle').click()
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 222],
[2022, 3235, 3205, 0, 30, 0, 0, 209],
[2021, 5133, 5108, 0, 25, 0, 0, 428],
[2020, 5801, 5796, 0, 5, 0, 0, 94],
[2019, 5305, 5305, 0, 0, 0, 0, 162],
[2018, 3442, 3432, 0, 10, 0, 0, 21],
[2017, 1211, 1211, 0, 0, 0, 0, 189],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
@@ -190,14 +173,14 @@ describe('Degree programme overview', () => {
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 67],
['2022 - 2023', 2725, 2720, 0, 5, 337],
['2021 - 2022', 4092, 4042, 0, 50, 198],
['2020 - 2021', 5420, 5415, 0, 5, 321],
['2019 - 2020', 6043, 6043, 0, 0, 101],
['2018 - 2019', 4856, 4851, 0, 5, 107],
['2017 - 2018', 2350, 2345, 0, 5, 26],
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
]
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
@@ -231,9 +214,9 @@ describe('Degree programme overview', () => {
cy.contains('2022')
cy.contains('48 graduated').trigger('mouseover')
cy.contains('48 students graduated in year 2022')
cy.contains('median study time: 36 months')
cy.contains('27 graduated on time')
cy.contains('9 graduated max year overtime')
cy.contains('median study time: 6 semesters')
cy.contains('26 graduated on time')
cy.contains('10 graduated max year overtime')
cy.contains('12 graduated over year late')
})
@@ -262,7 +245,7 @@ describe('Degree programme overview', () => {
cy.contains('2021')
cy.contains('2 graduated').trigger('mouseover')
cy.contains('2 students graduated in year 2021')
cy.contains('median study time: 25 months')
cy.contains('median study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
@@ -274,7 +257,7 @@ describe('Degree programme overview', () => {
cy.contains('2023')
cy.contains('11 graduated').trigger('mouseover')
cy.contains('11 students graduated in year 2023')
cy.contains('median study time: 67 months')
cy.contains('median study time: 12 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -432,12 +415,12 @@ describe('Degree programme overview', () => {
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.contains('On time: 16')
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 23. On time."]').click()
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 14')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
@@ -446,7 +429,7 @@ describe('Degree programme overview', () => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').trigger('mouseover')
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 33 months')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
@@ -539,12 +522,12 @@ describe('Degree programme overview', () => {
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(9)
.eq(8)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21001')
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
cy.get('td').eq(3).contains('1220')
cy.get('td').eq(1).contains('MAT11004')
cy.get('td').eq(2).contains('Differentiaalilaskenta')
cy.get('td').eq(3).contains('1170')
})
})
})
@@ -582,7 +565,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('1330')
cy.get('td').eq(3).contains('1220')
})
})
})
@@ -641,13 +624,32 @@ describe('Degree programme overview', () => {
})
it("'Show credits/Show students' toggle works", () => {
cy.get('thead tr').within(() => {
cy.get('th').should('have.length', 8)
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
cy.get('thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 6)
const headers = ['Type', 'Code', 'Name', 'Total credits', 'Credits produced by', 'Transferred credits']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Degree students',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
'Other',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
})
cy.cs('show-credits-students-toggle').click()
@@ -659,9 +661,9 @@ describe('Degree programme overview', () => {
'Type',
'Code',
'Name',
'Total',
'Total students',
'Breakdown of total',
'Breakdown of passed students',
'Breakdown of passed',
'Not included in total or passed',
]
headers.forEach((header, index) => {
@@ -671,14 +673,18 @@ describe('Degree programme overview', () => {
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 6)
cy.get('th').should('have.length', 10)
const headers = [
'Passed',
'Not completed',
'Major students',
'Non-major students',
'Non-degree students',
'Transferred students',
'Degree students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
'Other students',
'Students with transferred credits',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
@@ -692,7 +698,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('309')
cy.get('td').eq(3).contains('375')
})
})
})
@@ -704,7 +710,7 @@ describe('Degree programme overview', () => {
})
// FIXME: Flaky
it('test', () => {
it.skip('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
+1 -1
View File
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
-47
View File
@@ -1,47 +0,0 @@
services:
oodikone-backend:
build:
context: ${OODIKONE_REPO:-.}/services
dockerfile: backend/Dockerfile
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-backend:${DOCKER_IMAGE_TAG:-dev}
oodikone-frontend:
build:
context: ${OODIKONE_REPO:-.}/services
dockerfile: frontend/Dockerfile
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
SIS_URL: 'https://sis-qa.funidata.fi'
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
updater-scheduler:
build:
context: ${OODIKONE_REPO:-.}/updater/sis-updater-scheduler
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-scheduler:${DOCKER_IMAGE_TAG:-dev}
updater-worker:
build:
context: ${OODIKONE_REPO:-.}/updater/sis-updater-worker
args:
SENTRY_ENVIRONMENT: ''
SENTRY_RELEASE: ''
SENTRY_DSN: ''
SERVICE_PROVIDER: fd
NODE_ENV: production
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-worker:${DOCKER_IMAGE_TAG:-dev}
-190
View File
@@ -1,190 +0,0 @@
x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
dockerfile: dev.Dockerfile
services:
adminer:
<<: *defaults
container_name: adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
ports:
- 5050:8080
volumes:
- ./adminer-plugins:/var/www/html/plugins-enabled
backend:
<<: *defaults
build:
<<: *build_defaults
context: services/backend
depends_on:
- kone-db
- redis
- sis-db
- user-db
env_file:
- .env
environment:
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
DB_URL_USER: postgres://postgres@user-db:5432/user-db
FRONT_URL: http://localhost:3000
REDIS: redis
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://localhost:3005'
JAMI_URL: 'http://jami:3003/'
SERVICE_PROVIDER: fd
SIS_HOST: ${SIS_HOST}
SIS_GRAPHQL_APPLICATION_ACCOUNT: ${SIS_GRAPHQL_APPLICATION_ACCOUNT}
SIS_GRAPHQL_APPLICATION_KEY: ${SIS_GRAPHQL_APPLICATION_KEY}
IMPORTER_DB_API_USER: dev_user
IMPORTER_DB_API_PASSWORD: dev_password
ROOT_ORG_ID: ${ROOT_ORG_ID}
LANGUAGE_CENTER_VIEW_ENABLED: 'false'
ports:
- 8080:8080
volumes:
- backend-nodemod:/opt/app-root/backend/node_modules
- ./environments/testu:/opt/app-root/backend/src/environment
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
frontend:
<<: *defaults
build:
<<: *build_defaults
context: services/frontend
container_name: frontend
ports:
- 3000:3000
environment:
REACT_APP_SERVICE_PROVIDER: fd
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
volumes:
- frontend-nodemod:/opt/app-root/frontend/node_modules
- ./services/frontend/:/opt/app-root/frontend
- ./services/shared/:/opt/app-root/shared
kone-db:
<<: *defaults
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
ports:
- 12349:5432
volumes:
- kone-db-data:/data
redis:
<<: *defaults
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:6.2.16
ports:
- '6379:6379'
volumes:
- redis-data:/data
sis-db:
<<: *defaults
container_name: sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
volumes:
- sis-db-data:/data
shm_size: 2gb
ports:
- 12345:5432
sis-updater-scheduler:
<<: *defaults
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- redis
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SERVICE_PROVIDER: fd
ports:
- 8082:8082
volumes:
- ./updater/sis-updater-scheduler/:/opt/app-root/src
sis-updater-worker:
<<: *defaults
deploy:
replicas: 2
mode: replicated
build:
<<: *build_defaults
context: updater/sis-updater-worker
depends_on:
- redis
- sis-db
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
REDIS_PORT: 6379
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
SIS_IMPORTER_PASSWORD: dev
SIS_IMPORTER_USER: dev
SIS_PASSWORD: postgres
ROOT_ORG_ID: ${ROOT_ORG_ID}
SERVICE_PROVIDER: fd
volumes:
- ./updater/sis-updater-worker/:/opt/app-root/src
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
volumes:
- user-db-data:/data
ports:
- 12347:5432
volumes:
backend-nodemod:
frontend-nodemod:
kone-db-data:
redis-data:
sis-db-data:
user-db-data:
networks:
default:
name: oodikone
importer:
name: importer_network
external: true
+1 -1
View File
@@ -170,7 +170,7 @@ services:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
+8 -1
View File
@@ -75,7 +75,14 @@ export default tseslint.config(
'prefer-const': 'error',
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
'no-restricted-imports': ['error',
{
'patterns':
[
{ 'regex': '^@mui/[^/]+$', 'message': 'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.' },
{ 'regex': '^src/.*', 'message': 'Root imports will not resolve in production build (instead silently crash).' }
]
}],
'quotes': ['error', 'single',
{ avoidEscape: true, allowTemplateLiterals: false }
],
+3 -2
View File
@@ -28,9 +28,10 @@
"prepare": "husky",
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"i:both": "npm run install:both",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
-24
View File
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
## services. The real data databases inside service will have names suffixed by "-real".
@@ -139,29 +138,6 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_jami_data() {
local database="jami-db"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Removing database and related volume"
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
infomsg "Starting postgres in the background"
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
infomsg "Populating Jami"
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
docker-compose stop "$database"
msg ""
}
reset_all_real_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
+7
View File
@@ -0,0 +1,7 @@
*/.dockerignore
*/.eslintrc.json
*/.gitignore
*/*Dockerfile
*/build
*/dist
*/node_modules
-3
View File
@@ -1,3 +0,0 @@
.dockerignore
*Dockerfile
node_modules/
+1 -1
View File
@@ -10,4 +10,4 @@ COPY . .
RUN npm ci
EXPOSE 8080
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.ts"]
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
+655 -929
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -2,15 +2,15 @@
"name": "backend",
"private": true,
"scripts": {
"build": "tsc",
"build": "tsc -p .",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"@types/pg": "^8.18.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
@@ -22,14 +22,13 @@
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"papaparse": "^5.5.3",
"pg": "^8.16.0",
"pg": "^8.20.0",
"redis": "^4.7.1",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.7",
"sequelize": "^6.37.8",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"unfuck-utf8-headers-middleware": "^1.0.1",
"vite": "^7.1.12",
"winston": "^3.17.0",
"winston-gelf-transporter": "^1.0.2",
"winston-loki": "^6.1.3"
@@ -41,6 +40,7 @@
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
-6
View File
@@ -33,12 +33,6 @@ export const baseUrl = isDev ? '/api' : ''
export const frontUrl = process.env.FRONT_URL
export const backendPort = 8080
// System run in whose environment
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
// Optional logout-url configuration
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
// Other stuff
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
+4 -36
View File
@@ -2,9 +2,8 @@ import * as Sentry from '@sentry/node'
import { NextFunction, Request, Response } from 'express'
import { intersection } from 'lodash-es'
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
import { FormattedUser } from '../types'
import { requiredGroup } from '../config'
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -25,7 +24,7 @@ type Headers = {
'x-show-as-user'?: string
}
const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction) => {
export default async (req: Request, _: Response, next: NextFunction) => {
const {
'shib-session-id': sessionId,
'x-show-as-user': showAsUser,
@@ -61,7 +60,7 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
const user =
showAsUser && specialGroup?.superAdmin
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
: await getUserToska({
: await getUser({
username,
name: name!,
email: email!,
@@ -80,34 +79,3 @@ const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction
next()
}
const fdUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
const { remote_user: remoteUser, 'x-show-as-user': showAsUser } = req.headers
if (!remoteUser) {
throw new ApplicationError('Not enough data in request headers, remote_user was missing', 403, { configLogoutUrl })
}
let user: FormattedUser | null
// getMockedUser in production requires the superAdmin role, which is only available
// via iamGroups, so it's now only implemented for the dev environment
if (showAsUser && isDev) {
user = await getMockedUserFd({ userToMock: showAsUser as string, mockedBy: remoteUser as string })
} else {
user = await getUserFd({ username: remoteUser as string })
}
if (!user) {
throw new ApplicationError(`Could not grant access with the eppn ${remoteUser}.`, 403, { configLogoutUrl })
}
req.user = user
req.logoutUrl = configLogoutUrl
next()
}
const currentUserMiddleware = serviceProvider === 'toska' ? toskaUserMiddleware : fdUserMiddleware
export default currentUserMiddleware
+1 -7
View File
@@ -1,10 +1,4 @@
import headersMiddleware from 'unfuck-utf8-headers-middleware'
import { serviceProvider } from '../config'
const headersToska = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
const headersOther = ['remote_user']
const headers = serviceProvider === 'toska' ? headersToska : headersOther
const headers = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
export default headersMiddleware(headers)
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
@Column(DataType.JSONB)
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@Column(DataType.JSONB)
declare transferInfo: SISStudyRight['transferInfo']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
declare credits: SISStudyRight['credits']
+3
View File
@@ -73,6 +73,9 @@ export class CourseModel extends Model implements Course {
@Column(DataType.JSONB)
declare substitutions: Course['substitutions']
@Column(DataType.JSONB)
declare substitution_groups: Course['substitution_groups']
@Column(DataType.STRING)
declare course_unit_type: Course['course_unit_type']
+3 -9
View File
@@ -3,7 +3,7 @@ import compression from 'compression'
import cors from 'cors'
import { Express, json as jsonExpress } from 'express'
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
import { frontUrl, languageCenterViewEnabled } from './config'
import accessLogger from './middleware/accessLogger'
import * as auth from './middleware/auth'
import currentUserMiddleware from './middleware/currentUser'
@@ -34,8 +34,7 @@ import tags from './routes/tags'
import teachers from './routes/teachers'
import university from './routes/university'
import updater from './routes/updater'
import usersToska from './routes/users'
import usersFd from './routes/usersFd'
import users from './routes/users'
const routes = (app: Express, url: string) => {
app.use(cors({ credentials: true, origin: frontUrl }))
@@ -66,12 +65,7 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/university`, university)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, teachers)
if (serviceProvider === 'toska') {
app.use(`${url}/users`, usersToska)
} else {
app.use(`${url}/users`, usersToska)
app.use(`${url}/users`, usersFd)
}
app.use(`${url}/users`, users)
app.use(`${url}/feedback`, feedback)
app.use(`${url}/custom-population-search`, customPopulationSearch)
app.use(`${url}/studyguidancegroups`, auth.roles(['studyGuidanceGroups']), studyGuidanceGroups)
+18 -10
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isDev, isTest } from '../config'
const router = Router()
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
return res.status(200).json(fakeRelease)
}
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
const { data } = await Fetchios.get<Record<string, any>[]>(
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
{}
)
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
if (data) {
const releasesFromAPI: Release[] = data.map(release => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
}
res.status(500).send()
})
export default router
@@ -46,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
: { data: null, error: null }
if (error) {
+11 -31
View File
@@ -10,9 +10,8 @@ import {
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { uniq } from '@oodikone/shared/util'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
@@ -20,49 +19,30 @@ const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/v2/coursesmulti',
async (req, res) => {
const { name, code, combineSubstitutions, includeSpecial } = req.query
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
if (combineSubstitutions === 'false') {
const courseCodes = courses.map(course => course.code)
const substitutions = uniq(
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
)
const substitutionDetails = await getCoursesByCodes(substitutions)
for (const substitution of substitutionDetails) {
courses.push(substitution.toJSON())
}
}
const mergedCourses: Record<string, CourseWithSubsId> = {}
const mergedCourses = new Map<string, CourseWithSubsId>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
if (!groupId) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
}
} else {
const mergedCourse = mergedCourses[groupId]
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Object.values(mergedCourses) })
res.json({ courses: Array.from(mergedCourses.values()) })
}
)
+2 -2
View File
@@ -1,6 +1,6 @@
import { Request, Response, Router } from 'express'
import { sendFeedbackToToska } from '../services/mailService'
import { sendFeedback } from '../services/mailService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -20,7 +20,7 @@ router.post('/email', async (req: EmailRequest, res: Response) => {
throw new ApplicationError('User not found', 404)
}
await sendFeedbackToToska({
await sendFeedback({
feedbackContent: content,
user,
})
+10 -11
View File
@@ -15,12 +15,12 @@ import {
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
} from '@oodikone/shared/routes/populations'
import { Unification, Unarray } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { Unification } from '@oodikone/shared/types'
import { keyBy, mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
import { getStudentTagMap } from '../services/populations/getStudentData'
import { parseDateRangeFromParams } from '../services/populations/shared'
import { statisticsOf } from '../services/populations/statisticsOf'
@@ -260,14 +260,13 @@ router.get<never, PopulationstatisticsStudyprogrammesResBody>(
const filteredProgrammes = hasFullAccessToStudentData(roles)
? programmes
: programmes.filter(programme => allRights.includes(programme.code))
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
(acc, curr) => {
acc[curr.code] = curr
return acc
},
{}
)
res.json(formattedProgrammes)
const formattedFilteredProgrammes = keyBy(filteredProgrammes, 'code')
const formattedAllProgrammes = keyBy(programmes, 'code')
res.json({
allProgrammes: formattedAllProgrammes,
filteredProgrammes: formattedFilteredProgrammes,
})
}
)
+1 -3
View File
@@ -1,7 +1,6 @@
import { Request, Response, Router } from 'express'
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
import { serviceProvider } from '../config'
import * as auth from '../middleware/auth'
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
@@ -12,8 +11,7 @@ const router = Router()
const iamGroupsGivingFullAccess = ['hy-dekaanit', 'hy-varadekaanit-opetus']
const fullAccessAuth = () =>
serviceProvider === 'toska' ? auth.roles([], iamGroupsGivingFullAccess) : auth.roles(['teachers'])
const fullAccessAuth = () => auth.roles([], iamGroupsGivingFullAccess)
interface GetTeachersRequest extends Request {
query: {
+1 -2
View File
@@ -2,7 +2,6 @@ import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash-es'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
import { magicFacultyCode } from '../config/organizationConstants'
import { OrganizationModel } from '../models'
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
@@ -125,7 +124,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
const allFaculties = await getSortedFaculties()
const facultyCodes = allFaculties.map(faculty => faculty.code)
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
const programmeFilter = serviceProvider === 'toska' ? 'NEW_DEGREE_PROGRAMMES' : 'ALL_PROGRAMMES'
const programmeFilter = 'NEW_DEGREE_PROGRAMMES'
for (const facultyCode of facultyCodes) {
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
if (!data) {
+1 -1
View File
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
programmes: refreshProgrammesByList,
}
if (typeof typeToJob[type] !== 'function') {
return res.status(400).json('Bad job type')
return res.status(400).json('Updater does not recognize this job type: ' + type)
}
const response = await typeToJob[type](list)
if (response) {
-68
View File
@@ -1,68 +0,0 @@
import { Request, Response, Router } from 'express'
import { isDev } from '../config'
import * as auth from '../middleware/auth'
import * as userService from '../services/userService'
const router = Router()
interface NewUserEppnRequest extends Request {
params: {
newUserEppn: string
}
}
router.get('/from-sisu-by-eppn/:newUserEppn', auth.roles(['admin']), async (req: NewUserEppnRequest, res: Response) => {
let { username: requesterEppn } = req.user
const { newUserEppn } = req.params
// In order to test this feature in the dev environment we need to set an eppn that demo sisu will recognize
if (isDev && requesterEppn === 'mluukkai') {
requesterEppn = newUserEppn
}
const person = await userService.getUserFromSisuByEppn(requesterEppn, newUserEppn)
res.json(person)
})
interface User {
id: string
first_name: string
last_name: string
call_name: string
eppn: string
email_address: string
student_number: string
phone_number: string
document_state: string
}
interface AddUserRequest extends Request {
body: {
user: User
}
}
interface DeleteUserRequest extends Request {
body: {
userId: string
}
}
router.post('/add', auth.roles(['admin']), async (req: AddUserRequest, res: Response) => {
const { user } = req.body
const oldUser = await userService.findByUsername(user.eppn)
if (oldUser) return res.status(400).json({ error: 'Trying to add a user already present in the user database.' })
const success = await userService.addNewUser(user)
res.json(success)
})
router.delete('/delete', auth.roles(['admin']), async (req: DeleteUserRequest, res: Response) => {
const { userId } = req.body
try {
await userService.deleteUserById(userId)
res.json({ deleted: userId })
} catch (error) {
return res.status(400).json({ error: 'Deletion was unsuccessful with the user id included in the request' })
}
})
export default router
@@ -47,18 +47,19 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
...nameLikeTerm(name),
...codeLikeTerm(code),
},
raw: true,
})
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
const rawCourses = await getRawCourses(name, code, includeSpecial)
return rawCourses
.map(course => ({
...course.toJSON(),
...course,
substitutions: orderBy(course.substitutions, [
substitution => {
if (/^A/.exec(substitution)) return 4 // open university codes come last
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
]),
@@ -3,7 +3,6 @@ import { QueryTypes } from 'sequelize'
import { Organization } from '@oodikone/shared/models'
import { ProgrammeModuleWithRelevantAttributes } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { dbConnections } from '../../database/connection'
import { OrganizationModel } from '../../models'
@@ -57,9 +56,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
const programmesWithProgIds = programmesOfOrganization.map(programme => ({
...programme,
progId:
programme.code in programmeCodes && serviceProvider !== 'fd'
? programmeCodes[programme.code as keyof typeof programmeCodes]
: programme.code,
programme.code in programmeCodes ? programmeCodes[programme.code as keyof typeof programmeCodes] : programme.code,
}))
const programmesGroupedByCode = groupBy(orderBy(programmesWithProgIds, ['valid_from'], ['desc']), prog => prog.code)
const curriculumPeriods = await getCurriculumPeriods()
@@ -3,6 +3,7 @@ import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
@@ -253,21 +254,22 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
} = await getStatsByStartYear(programmesOfFaculty)
const goals = {
bachelor: 36,
bcMsCombo: faculty === 'H90' ? 72 : 60,
master: 24,
doctor: 48,
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
MH30_004: 6, // months more
'420420-ma': 6,
'420074-ma': 6,
'420119-ma': 6,
MH30_001: 48,
'320011-ma': 48,
'320001-ma': 48,
MH30_003: 42,
'320002-ma': 42,
'320009-ma': 42,
// Exceptions are additions to the base amount
MH30_004: GraduationTarget.HALF_YEAR,
'420420-ma': GraduationTarget.HALF_YEAR,
'420074-ma': GraduationTarget.HALF_YEAR,
'420119-ma': GraduationTarget.HALF_YEAR,
MH30_001: GraduationTarget.FOUR_YEARS,
'320011-ma': GraduationTarget.FOUR_YEARS,
'320001-ma': GraduationTarget.FOUR_YEARS,
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
} as Record<string, number>,
}
@@ -154,7 +154,7 @@ export const combineFacultyStudentProgress = async (
}
for (const year of Object.keys(graduatedCountOfProgramme)) {
graduatedCount[level][year] = cloneDeep(graduatedCountOfProgramme[year])
graduatedCount[level][year] += cloneDeep(graduatedCountOfProgramme[year])
}
}
}
@@ -1,13 +1,27 @@
export const countTimeCategories = (times: number[], goal: number) => {
/** Map years to semesters */
export const GraduationTarget = {
HALF_YEAR: 1,
ONE_YEAR: 2,
TWO_YEARS: 4,
THREE_YEARS: 6,
THREE_POINT_FIVE_YEARS: 7,
FOUR_YEARS: 8,
FIVE_YEARS: 10,
SIX_YEARS: 12,
} as const
/** Uses semesters, not months */
export const countTimeCategories = (times: number[], target: number) => {
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
times.forEach(time => {
if (time <= goal) {
statistics.onTime += 1
} else if (time <= goal + 12) {
statistics.yearOver += 1
for (const time of times) {
if (time <= target) {
statistics.onTime++
} else if (time <= target + GraduationTarget.ONE_YEAR) {
statistics.yearOver++
} else {
statistics.wayOver += 1
statistics.wayOver++
}
})
}
return statistics
}
+8 -15
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
const pateClient = Fetchios.create({
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
params: {
token: pateToken,
},
@@ -22,18 +21,12 @@ const baseSettings = {
}
const sendEmail = async (options = {}) => {
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('', options, {})
}
export const sendFeedbackToToska = async ({
feedbackContent,
user,
}: {
feedbackContent: string
user: FormattedUser
}) => {
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
const { name, userId, email } = user
const userDetails = `Sent by ${name}, userid: ${userId}, email: ${email}`
const text = [feedbackContent, userDetails].join('<br />')
+28 -42
View File
@@ -1,44 +1,45 @@
const axios = require('axios')
import { Fetchios } from '@oodikone/shared/util/fetchios'
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
const { ApplicationError } = require('../util/customErrors')
const getSisuAccessToken = async eppn => {
export const getSisuAccessToken = async eppn => {
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
let sisuResponse
try {
sisuResponse = await axios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
} catch (error) {
const { data: sisuResponse, error } = await Fetchios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
if (error) {
throw new ApplicationError(
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
404
)
}
return sisuResponse.data.authToken
return sisuResponse.authToken
}
const decodeJwtTokenPayloadToObject = token => {
export const decodeJwtTokenPayloadToObject = token => {
const payload = token.split('.')[1]
const paddedPayload = `${payload}==`
const bufferObj = Buffer.from(paddedPayload, 'base64')
return JSON.parse(bufferObj.toString('utf-8'))
}
const getSisuAuthData = async eppn => {
export const getSisuAuthData = async eppn => {
const accessToken = await getSisuAccessToken(eppn)
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
return { accessToken, tokenData }
}
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
export const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
private_person(id: $subjectUserId) {
id
first_name:firstNames
@@ -52,32 +53,17 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
}
}`
const getGraphqlData = async (accessToken, queryObject) => {
const graphQlUrl = `${sisUrl}/api`
const authHeader = `Application ${accessToken}`
const config = {
url: graphQlUrl,
method: 'post',
headers: {
Authorization: authHeader,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
data: queryObject,
}
let graphQlResponse
export const getGraphqlData = async (accessToken, queryObject) => {
try {
graphQlResponse = await axios(config)
const graphQlResponse = await Fetchios.post(`${sisUrl}/api`, queryObject, {
headers: {
Authorization: `Application ${accessToken}`,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
})
return graphQlResponse.data.data.private_person
} catch (error) {
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
}
return graphQlResponse.data.data.private_person
}
module.exports = {
getSisuAccessToken,
decodeJwtTokenPayloadToObject,
getSisuAuthData,
personSearchQuery,
getGraphqlData,
}
@@ -69,6 +69,7 @@ export const formatStudentForAPI = (
sis_person_id: student.sis_person_id,
citizenships: student.citizenships,
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
hasPersonalIdentityCode: student.hasPersonalIdentityCode,
tags,
transferredStudyright,
@@ -79,6 +79,7 @@ type StudentPersonalData = Pick<
| 'gender_code'
| 'birthdate'
| 'sis_person_id'
| 'hasPersonalIdentityCode'
>
type StudentStudyRightElement = Pick<
@@ -125,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
'gender_code',
'birthdate',
'sis_person_id',
'hasPersonalIdentityCode',
],
include: [
{
@@ -1,6 +1,7 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { CourseStats } from '@oodikone/shared/routes/populations'
import { Name, DegreeProgrammeType } from '@oodikone/shared/types'
import { SISStudyRightElementModel, CourseModel } from '../../models'
import { SemesterStart } from '../../util/semester'
@@ -75,9 +76,9 @@ export const getOptionsForStudents = (
return optionMap
}
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>> =>
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
CourseModel.findAll({
attributes: ['code', 'name', 'substitutions'],
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
where: {
code: { [Op.in]: courses },
},
@@ -5,6 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
import { getCreditStats, setCreditStats } from './analyticsService'
import { getCourseCodesOfProvider } from './providers'
import { getCreditsForProvider, getTransferredCredits } from './studyProgramme/creditGetters'
import { getStudentHetuStateMap } from './studyProgramme/studentGetters'
import { defineYear } from './studyProgramme/studyProgrammeHelpers'
import { getSISStudyRightsOfStudents } from './studyProgramme/studyRightFinders'
@@ -47,15 +48,17 @@ const basicDegreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCo
This is probably very rare to cause differences, only one such was found in MH60_001
*/
// TODO: How should licentiate and doctorate studies be categorized?
const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
const getCategory = (hasHetu: boolean, extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
if (degreeStudyRightExtentCode && basicDegreeExtentCodes.includes(degreeStudyRightExtentCode)) {
return 'basic'
}
switch (extentCode) {
case ExtentCode.OPEN_UNIVERSITY_STUDIES:
case ExtentCode.SUMMER_AND_WINTER_SCHOOL:
return 'open-uni'
if (hasHetu) {
return 'open-uni-hetu'
} else {
return 'open-uni-no-hetu'
}
case ExtentCode.MASTER:
case ExtentCode.BACHELOR_AND_MASTER:
case ExtentCode.BACHELOR:
@@ -63,6 +66,7 @@ const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: Exten
case ExtentCode.NON_DEGREE_STUDIES:
case ExtentCode.NON_DEGREE_PEGAGOGICAL_STUDIES_FOR_TEACHERS:
case ExtentCode.NON_DEGREE_PROGRAMME_FOR_SPECIAL_EDUCATION_TEACHERS:
case ExtentCode.SPECIALIZATION_STUDIES:
return 'separate'
case ExtentCode.EXCHANGE_STUDIES:
case ExtentCode.EXCHANGE_STUDIES_POSTGRADUATE:
@@ -72,9 +76,9 @@ const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: Exten
case ExtentCode.DOCTOR:
case ExtentCode.LICENTIATE:
case ExtentCode.CONTINUING_EDUCATION:
case ExtentCode.SPECIALIZATION_STUDIES:
case ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY:
case ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS:
case ExtentCode.SUMMER_AND_WINTER_SCHOOL:
case undefined:
return 'other'
default:
@@ -122,38 +126,43 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
const studyRights = await getSISStudyRightsOfStudents(students)
const [studyRights, studentToHetuStateMap] = await Promise.all([
getSISStudyRightsOfStudents(students),
getStudentHetuStateMap(students),
])
const studyRightIdToStudyRightMap = studyRights.reduce<Record<string, (typeof studyRights)[number]>>((obj, cur) => {
obj[cur.id] = cur
return obj
}, {})
const studyRightIdToStudyRightMap = new Map(studyRights.map(sr => [sr.id, sr]))
const studentNumberToStudyRightsMap = studyRights.reduce<Record<string, typeof studyRights>>((obj, cur) => {
obj[cur.studentNumber] ??= []
obj[cur.studentNumber].push(cur)
return obj
}, {})
const studentNumberToStudyRightsMap = studyRights.reduce((map, cur) => {
// Node does not support getOrInsert as of writing :-(
if (map.has(cur.studentNumber)) {
map.get(cur.studentNumber)!.push(cur)
} else {
map.set(cur.studentNumber, [cur])
}
return map
}, new Map<string, [(typeof studyRights)[number]]>())
const stats: Record<string, Record<string, number>> = {}
for (const { attainmentDate, studentNumber, credits: numOfCredits, studyrightId, semestercode } of credits) {
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap[studyrightId]
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap.get(studyrightId)
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
const degreeStudyRight = getBasicDegreeStudyRight(
studentNumberToStudyRightsMap[studentNumber],
studentNumberToStudyRightsMap.get(studentNumber),
attainmentDate,
semestercode
)
const hasHetu = !!studentToHetuStateMap.get(studentNumber)
if (!studyRightLinkedToAttainment && !degreeStudyRight) {
continue
}
const category = getCategory(studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
const category = getCategory(hasHetu, studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
stats[attainmentYear] ??= {}
stats[attainmentYear][category] ??= 0
stats[attainmentYear][category] += numOfCredits || 0
stats[attainmentYear][category] += numOfCredits
}
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
@@ -165,7 +174,7 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
const attainmentYear = defineYear(createdate, isAcademicYear)
stats[attainmentYear] ??= {}
stats[attainmentYear].transferred ??= 0
stats[attainmentYear].transferred += credits || 0
stats[attainmentYear].transferred += credits
})
return { stats, id: providerCode }
@@ -1,46 +1,48 @@
import axios from 'axios'
import { Op } from 'sequelize'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
import { StudyplanModel } from '../models'
const client = axios.create({ baseURL: SIS_UPDATER_URL })
const params = { params: { token: SECRET_TOKEN } }
const client = Fetchios.create({
baseUrl: SIS_UPDATER_URL,
params: { token: SECRET_TOKEN ?? '' },
})
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
const response = await client.get<{ message: string }>('/v1/abort', {})
return response.data?.message
}
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
const data = { coursecodes }
const response = await client.post('/v1/courses', data, params)
const response = await client.post('/v1/courses', data, {})
return response.data
}
export const updateSISMetadata = async () => {
const response = await client.get('/v1/meta', params)
const response = await client.get('/v1/meta', {})
return response.data
}
export const updateSISProgrammes = async () => {
const response = await client.get('/v1/programmes', params)
const response = await client.get('/v1/programmes', {})
return response.data
}
export const updateSISRedisCache = async () => {
const response = await client.get('/v1/rediscache', params)
const response = await client.get('/v1/rediscache', {})
return response.data
}
export const updateSISStudents = async () => {
const response = await client.get('/v1/students', params)
const response = await client.get('/v1/students', {})
return response.data
}
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
const data = { studentnumbers }
const response = await client.post('/v1/students', data, params)
const response = await client.post('/v1/students', data, {})
return response.data
}
@@ -54,6 +56,6 @@ export const updateSISStudyPlans = async (days: number) => {
limit: 1000,
})
const studentnumbers = result.map(r => r.studentnumber)
const response = await client.post('v1/studyplans', { studentnumbers }, params)
const response = await client.post('v1/studyplans', { studentnumbers }, {})
return response.data
}
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash-es'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
@@ -12,22 +11,23 @@ const importerClient = getImporterClient()
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
if (!importerClient || !sisPersonId) return []
try {
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
`/person-groups/person/${sisPersonId}`
)
const { data, error } = await importerClient.get<Record<string, StudyGuidanceGroup>>(
`/person-groups/person/${sisPersonId}`,
{}
)
return response?.data ? Object.values(response.data) : []
} catch (error) {
if (error) {
logger.error('Could not fetch study guidance groups!')
if (error instanceof AxiosError) {
if (error instanceof Error) {
logger.error(error.stack)
logger.error(JSON.stringify(error.response?.data))
logger.error(JSON.stringify(error.message))
Sentry.captureException(error)
}
return []
}
return []
return data ? Object.values(data) : []
}
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
@@ -52,15 +52,13 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
},
})
const allEnrollments = {} as Record<string, string[]>
const allEnrollments = new Map<string, Set<string>>()
for (const { studentnumber, course_code: courseCode } of enrollmentsCourses) {
const code = getCourseCode(courseCode)
if (!(code in allEnrollments)) {
allEnrollments[code] = []
}
if (!allEnrollments[code].includes(studentnumber)) {
allEnrollments[code].push(studentnumber)
if (!allEnrollments.has(code)) {
allEnrollments.set(code, new Set())
}
allEnrollments.get(code)!.add(studentnumber)
}
const credits = await CreditModel.findAll({
@@ -75,7 +73,7 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
},
})
const courseNames = (
const courseCodeToName = (
await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
@@ -87,63 +85,50 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
return acc
}, new Map())
const creditCourses = credits.map(credit => {
return {
code: getCourseCode(credit.course_code),
studentNumber: credit.student_studentnumber,
creditTypeCode: credit.credittypecode,
courseName: courseNames.get(credit.course_code)!,
isStudyModule: credit.isStudyModule,
}
})
const creditCourses = credits.map(credit => ({
code: getCourseCode(credit.course_code),
studentNumber: credit.student_studentnumber,
creditTypeCode: credit.credittypecode,
courseName: courseCodeToName.get(credit.course_code)!,
isStudyModule: credit.isStudyModule,
}))
const passedByCourseCodes = {} as Record<string, string[]>
const notCompletedByCourseCodes = {} as Record<string, string[]>
const courses: Record<string, { code: string; name: Name; isStudyModule: boolean }> = {}
for (const course of creditCourses) {
if (!(course.code in courses)) {
courses[course.code] = {
code: course.code,
name: course.courseName,
isStudyModule: course.isStudyModule,
}
passedByCourseCodes[course.code] = []
notCompletedByCourseCodes[course.code] = []
const passedByCourseCodes = new Map<string, Set<string>>()
const notCompletedByCourseCodes = new Map<string, Set<string>>()
const courses = new Map<string, { name: Name; isStudyModule: boolean }>()
for (const { code, courseName, creditTypeCode, isStudyModule, studentNumber } of creditCourses) {
if (!courses.has(code)) {
courses.set(code, {
isStudyModule,
name: courseName,
})
passedByCourseCodes.set(code, new Set())
notCompletedByCourseCodes.set(code, new Set())
}
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(course.creditTypeCode)) {
passedByCourseCodes[course.code].push(course.studentNumber)
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(creditTypeCode)) {
passedByCourseCodes.get(code)!.add(studentNumber)
}
if (
course.creditTypeCode === CreditTypeCode.FAILED &&
!passedByCourseCodes[course.code].includes(course.studentNumber)
) {
notCompletedByCourseCodes[course.code].push(course.studentNumber)
if (creditTypeCode === CreditTypeCode.FAILED && !passedByCourseCodes.get(code)!.has(studentNumber)) {
notCompletedByCourseCodes.get(code)!.add(studentNumber)
}
}
// If student has enrollments, but no attainment for a particular course, they have no credit info.
programmeCourses.forEach(courseCode => {
if (allEnrollments[courseCode]) {
allEnrollments[courseCode].forEach(studentnumber => {
if (passedByCourseCodes[courseCode] && !passedByCourseCodes[courseCode].includes(studentnumber)) {
notCompletedByCourseCodes[courseCode].push(studentnumber)
}
})
}
allEnrollments.get(courseCode)?.forEach(studentnumber => {
if (passedByCourseCodes.get(courseCode)?.has(studentnumber)) {
notCompletedByCourseCodes.get(courseCode)?.add(studentnumber)
}
})
})
return Object.keys(courses)
.reduce<Array<{ code: string; name: Name; isStudyModule: boolean }>>(
(acc, val) => [...acc, { ...courses[val] }],
[]
)
.map(course => ({
code: course.code,
name: course.name,
type: 'notCompleted' as const,
isStudyModule: course.isStudyModule,
totalNotCompleted: [...new Set(notCompletedByCourseCodes[course.code])].length,
}))
return [...courses.entries()].map(([code, { isStudyModule, name }]) => ({
code,
name,
isStudyModule,
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
}))
} catch (error) {
logger.error(`getNotCompletedForProgrammeCourses failed ${error}`)
return []
@@ -1,348 +1,261 @@
import { QueryTypes } from 'sequelize'
import { Op, QueryTypes } from 'sequelize'
import { Name, ExtentCode } from '@oodikone/shared/types'
import { CreditTypeCode, ExtentCode, Name } from '@oodikone/shared/types'
import { dbConnections } from '../../database/connection'
import { StudentModel } from '../../models'
import logger from '../../util/logger'
const { sequelize } = dbConnections
// Categories here are as per Linjaukset.md in internal docs.
// Refer to them before making changes
const degreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCode.BACHELOR_AND_MASTER]
type QueryResult = {
totalStudents: string
totalCredits: number
code: string
const exchangeStudentExtentCodes = [ExtentCode.EXCHANGE_STUDIES, ExtentCode.EXCHANGE_STUDIES_POSTGRADUATE]
const otherUniversityExtentCodes = [ExtentCode.CONTRACT_TRAINING]
const separateStudyExtentCodes = [
ExtentCode.NON_DEGREE_STUDIES,
ExtentCode.NON_DEGREE_PEGAGOGICAL_STUDIES_FOR_TEACHERS,
ExtentCode.NON_DEGREE_PROGRAMME_FOR_SPECIAL_EDUCATION_TEACHERS,
ExtentCode.SPECIALIZATION_STUDIES,
]
const otherExtentCodes = [
ExtentCode.CONTINUING_EDUCATION,
ExtentCode.DOCTOR,
ExtentCode.LICENTIATE,
ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY, // Includes veterinary
ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS,
ExtentCode.SUMMER_AND_WINTER_SCHOOL,
]
const openUniversityExtentCodes = [ExtentCode.OPEN_UNIVERSITY_STUDIES]
export type ProgrammeCourseCreditRow = {
courseCode: string
courseName: Name
studentNumber: string
attainmentDate: string
credits: number
variant: 'degree' | 'exchange' | 'separate' | 'otherUniversity' | 'other' | 'openUni' | 'none'
isStudyModule: boolean
}
export const getStudentsForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
dist."courseName",
dist."isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'passed' as const,
isStudyModule: course.isStudyModule,
totalPassed: parseInt(course.totalStudents, 10),
totalAllCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getStudentsForProgrammeCourses() function failed ${error}`)
return []
}
export type TransferCourseCreditRow = {
courseCode: string
courseName: Name
studentNumber: string
attainmentDate: string
credits: number
isStudyModule: boolean
}
export const getOwnStudentsForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[],
studyProgramme?: string
) => {
if (!programmeCourses.length) {
const { sequelize } = dbConnections
/**
NOTE:
1. There are some attainments that are linked to a valid study right, but the attainmentdate
is outside the study right's validity period. (maybe human error when the credits were registered)
2. There are some attainments with no attached study right at all.
3. Possibly some other edge cases.
Such cases get variant "none" and are not included in any category
Order of cases is somewhat important. refer to linjaukset.md, but TLDR;
degree student -> has any degree (bsc / msc) study right in the university
openuni -> only if no other study rights
**/
export const getProgrammeCourseAggregates = async (params: {
courseCodes: string[]
from: Date
to: Date
}): Promise<ProgrammeCourseCreditRow[]> => {
const { courseCodes, from, to } = params
if (courseCodes.length === 0) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
return await sequelize.query<ProgrammeCourseCreditRow>(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
sis_study_rights sr
ON sr.student_number = cr.student_studentnumber
INNER JOIN
sis_study_right_elements se
ON se.study_right_id = sr.id
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND (
se.code = :studyProgramme
AND cr.attainment_date BETWEEN se.start_date AND se.end_date
WITH filtered_credits AS (
SELECT
cr.course_code AS "courseCode",
co.name AS "courseName",
cr.student_studentnumber AS "studentNumber",
cr.attainment_date AS "attainmentDate",
cr.credits AS credits,
cr."isStudyModule" AS "isStudyModule"
FROM
credit cr
INNER JOIN course co ON co.code = cr.course_code
WHERE
cr.course_code IN (:courseCodes)
AND cr.attainment_date BETWEEN :from AND :to
AND cr.credittypecode = :creditType
)
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses, studyProgramme },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'ownProgramme' as const,
totalProgrammeStudents: parseInt(course.totalStudents, 10),
totalProgrammeCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getOwnStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getOtherStudentsForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[],
studyProgramme?: string
) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
SELECT
fc."courseCode",
fc."courseName",
fc."studentNumber",
fc."attainmentDate",
fc.credits,
fc."isStudyModule",
CASE
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:degreeExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'degree'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:exchangeStudentExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'exchange'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:separateStudyExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'separate'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:otherUniversityExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'otherUniversity'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:otherExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'other'
WHEN EXISTS (
SELECT
1
FROM
sis_study_rights sr
WHERE
sr.student_number = fc."studentNumber"
AND sr.extent_code IN (:openUniversityExtentCodes)
AND fc."attainmentDate" BETWEEN sr.start_date AND COALESCE(sr.end_date, 'infinity'::date)
) THEN 'openUni'
ELSE 'none'
END AS variant
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND cr.student_studentnumber NOT IN (
SELECT
sr.student_number
FROM
sis_study_rights sr
INNER JOIN
sis_study_right_elements sre
ON sre.study_right_id = sr.id
AND sre.code = :studyProgramme
AND cr.attainment_date BETWEEN sre.start_date AND sre.end_date
WHERE
sr.student_number = cr.student_studentnumber
)
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses, studyProgramme },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'otherProgramme' as const,
isStudyModule: course.isStudyModule,
totalOtherProgrammeStudents: parseInt(course.totalStudents, 10),
totalOtherProgrammeCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getOtherStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getTransferStudentsForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 9
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
`,
{
type: QueryTypes.SELECT,
replacements: { from, to, programmeCourses },
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'transfer' as const,
isStudyModule: course.isStudyModule,
totalTransferStudents: parseInt(course.totalStudents, 10),
totalTransferCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getTransferStudentsForProgrammeCourses() function failed ${error}`)
return []
}
}
export const getStudentsWithoutStudyRightForProgrammeCourses = async (
from: Date,
to: Date,
programmeCourses: string[]
) => {
if (!programmeCourses.length) {
return []
}
try {
const result: QueryResult[] = await sequelize.query(
`
WITH Dist AS (
SELECT DISTINCT
cr.student_studentnumber AS student,
cr.credits AS credits,
co.code AS code,
co.name AS "courseName",
cr."isStudyModule"
FROM
credit cr
INNER JOIN
course co
ON cr.course_code = co.code
LEFT JOIN
sis_study_rights sr
ON sr.student_number = cr.student_studentnumber
AND sr.extent_code IN (:degreeExtentCodes)
AND cr.attainment_date BETWEEN sr.start_date AND sr.end_date
WHERE
cr.attainment_date BETWEEN :from AND :to
AND cr.course_code IN (:programmeCourses)
AND cr.credittypecode = 4
AND sr.student_number IS NULL
)
SELECT
COUNT(student) AS "totalStudents",
SUM(credits) AS "totalCredits",
code,
"courseName",
"isStudyModule"
FROM
Dist
GROUP BY
dist.code,
"courseName",
"isStudyModule";
filtered_credits fc
`,
{
type: QueryTypes.SELECT,
replacements: {
courseCodes,
from,
to,
programmeCourses,
degreeExtentCodes: [
ExtentCode.BACHELOR,
ExtentCode.MASTER,
ExtentCode.BACHELOR_AND_MASTER,
ExtentCode.DOCTOR,
ExtentCode.LICENTIATE,
ExtentCode.SPECIALIST_TRAINING_IN_MEDICINE_AND_DENTISTRY,
],
creditType: CreditTypeCode.PASSED,
degreeExtentCodes,
otherUniversityExtentCodes,
separateStudyExtentCodes,
exchangeStudentExtentCodes,
openUniversityExtentCodes,
otherExtentCodes,
},
}
)
return result.map(course => ({
code: course.code,
name: course.courseName,
type: 'noStudyright' as const,
totalWithoutStudyRightStudents: parseInt(course.totalStudents, 10),
totalWithoutStudyRightCredits: course.totalCredits,
}))
} catch (error) {
logger.error(`getStudentsWithoutStudyRightForProgrammeCourses() function failed ${error}`)
logger.error('getProgrammeCourseAggregates failed', { error })
return []
}
}
export const getTransferCourseAggregates = async (params: {
courseCodes: string[]
from: Date
to: Date
}): Promise<TransferCourseCreditRow[]> => {
const { courseCodes, from, to } = params
if (courseCodes.length === 0) {
return []
}
try {
return await sequelize.query<TransferCourseCreditRow>(
`
SELECT
cr.course_code AS "courseCode",
co.name AS "courseName",
cr.student_studentnumber AS "studentNumber",
cr.attainment_date AS "attainmentDate",
cr.credits AS credits,
cr."isStudyModule" AS "isStudyModule"
FROM
credit cr
INNER JOIN course co ON co.code = cr.course_code
WHERE
cr.course_code IN (:courseCodes)
AND cr.attainment_date BETWEEN :from AND :to
AND cr.credittypecode = :creditType
`,
{
type: QueryTypes.SELECT,
replacements: {
courseCodes,
from,
to,
creditType: CreditTypeCode.APPROVED,
},
}
)
} catch (error) {
logger.error('getTransferCourseAggregates failed', { error })
return []
}
}
export const getStudentHetuStateMap = async (studentNumbers: string[]): Promise<Map<string, boolean>> => {
if (!studentNumbers.length) return new Map()
try {
return new Map(
(
await StudentModel.findAll({
attributes: ['studentnumber', 'hasPersonalIdentityCode'],
where: {
studentnumber: {
[Op.in]: studentNumbers,
},
},
raw: true,
})
).map(student => [student.studentnumber, student.hasPersonalIdentityCode])
)
} catch (error) {
logger.error('getOpenUniHetutonSplit failed', { error })
return new Map()
}
}
@@ -1,201 +1,293 @@
import { range } from 'lodash-es'
import { Name, StudyProgrammeCourse } from '@oodikone/shared/types'
import { Name, StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
import { mapToProviders } from '@oodikone/shared/util'
import { getOpenUniCourseCode } from '../../util'
import { getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses, getAllProgrammeCourses } from '.'
import {
getOtherStudentsForProgrammeCourses,
getOwnStudentsForProgrammeCourses,
getStudentsForProgrammeCourses,
getStudentsWithoutStudyRightForProgrammeCourses,
getTransferStudentsForProgrammeCourses,
} from './studentGetters'
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
const providerCode = mapToProviders([studyProgramme])[0]
const normalCourses = await getAllProgrammeCourses(providerCode)
return normalCourses.reduce((acc, curr) => {
acc.push(curr.code)
if (curr.substitutions?.includes(`AY${curr.code}`)) {
acc.push(`AY${curr.code}`)
}
return acc
}, [] as string[])
}
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
const START_YEAR = 2017
const JULY = 6
const AUGUST = 7
const getCurrentYearStartDate = () => new Date(new Date().getFullYear(), 0, 1)
const getFrom = (academicYear: string, year: number) =>
academicYear === 'ACADEMIC_YEAR' ? new Date(year, 7, 1, 0, 0, 0) : new Date(year, 0, 1, 0, 0, 0)
const getFrom = (yearType: YearType, year: number) =>
yearType === 'ACADEMIC_YEAR' ? new Date(year, AUGUST, 1, 0, 0, 0) : new Date(year, 0, 1, 0, 0, 0)
const getTo = (academicYear: string, year: number) =>
academicYear === 'ACADEMIC_YEAR' ? new Date(year + 1, 6, 31, 23, 59, 59) : new Date(year, 11, 31, 23, 59, 59)
const getTo = (yearType: YearType, year: number) =>
yearType === 'ACADEMIC_YEAR' ? new Date(year + 1, JULY, 31, 23, 59, 59) : new Date(year, 11, 31, 23, 59, 59)
const promiseKeys = ['passed', 'notCompleted', 'ownProgramme', 'noStudyright', 'otherProgramme', 'transfer'] as const
const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
const providerCode = mapToProviders([studyProgramme])[0]
if (!providerCode) return []
type CommonFields = {
const normalCourses = await getAllProgrammeCourses(providerCode)
const courseCodes = new Set<string>()
for (const course of normalCourses) {
courseCodes.add(course.code)
if (course.substitutions?.includes(`AY${course.code}`)) {
courseCodes.add(`AY${course.code}`)
}
}
return [...courseCodes]
}
const getYearKey = (date: Date, isAcademicYear: boolean) => {
const year = date.getFullYear()
if (!isAcademicYear) return year
return date.getMonth() >= 7 ? year : year - 1
}
type YearAccumulator = {
stats: YearStats
allPassed: Set<string>
degreeStudents: Set<string>
exchangeStudents: Set<string>
otherUniversityStudents: Set<string>
separateStudents: Set<string>
otherStudents: Set<string>
openStudentsHasHetu: Set<string>
openStudentsNoHetu: Set<string>
transferStudents: Set<string>
}
type CourseAccumulator = {
code: string
name: Name
year: number
type: (typeof promiseKeys)[number]
isStudyModule?: boolean
isStudyModule: boolean
years: Map<number, YearAccumulator>
}
type YearlyFields = StudyProgrammeCourse['years'][number]
type CombinedPromises = Promise<(CommonFields & Partial<YearlyFields>)[]>[]
const makeYearlyPromises = (
years: number[],
academicYear: string,
type: (typeof promiseKeys)[number],
programmeCourses: string[],
studyProgramme: string
): CombinedPromises =>
years.map(async year => {
const from = getFrom(academicYear, year)
const to = getTo(academicYear, year)
const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
stats: createEmptyStats(isStudyModule),
allPassed: new Set(),
degreeStudents: new Set(),
exchangeStudents: new Set(),
otherUniversityStudents: new Set(),
separateStudents: new Set(),
otherStudents: new Set(),
const addYear = <T extends object>(course: T) => Object.assign(course, { year })
openStudentsHasHetu: new Set(),
openStudentsNoHetu: new Set(),
transferStudents: new Set(),
})
switch (type) {
case 'passed':
return (await getStudentsForProgrammeCourses(from, to, programmeCourses)).map(addYear)
case 'notCompleted':
return (await getNotCompletedForProgrammeCourses(from, to, programmeCourses)).map(addYear)
case 'ownProgramme':
return (await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)).map(addYear)
case 'noStudyright':
return (await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)).map(addYear)
case 'otherProgramme':
return (await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)).map(addYear)
case 'transfer':
return (await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)).map(addYear)
default:
// This is to satisfy ts-eslint, never acually reached
throw new Error('Invalid arg in studyProgrammeCourses')
}
})
const ensureCourse = (
courseMap: Map<string, CourseAccumulator>,
code: string,
name: Name,
isStudyModule: boolean
): CourseAccumulator => {
if (!courseMap.has(code)) {
courseMap.set(code, {
code,
name,
isStudyModule,
years: new Map(),
})
}
const emptyStats = {
totalAllStudents: 0,
totalPassed: 0,
totalNotCompleted: 0,
totalAllCredits: 0,
totalProgrammeStudents: 0,
totalProgrammeCredits: 0,
totalOtherProgrammeStudents: 0,
totalOtherProgrammeCredits: 0,
totalWithoutStudyRightStudents: 0,
totalWithoutStudyRightCredits: 0,
totalTransferCredits: 0,
totalTransferStudents: 0,
} as const
const course = courseMap.get(code)!
course.isStudyModule = course.isStudyModule || isStudyModule
course.name = course.name ?? name
return course
}
const ensureYear = (course: CourseAccumulator, year: number, isStudyModule: boolean) => {
if (!course.years.has(year)) {
course.years.set(year, createYearAccumulator(isStudyModule))
}
const yearAccumulator = course.years.get(year)!
yearAccumulator.stats.isStudyModule ??= isStudyModule
return yearAccumulator
}
const finalizeYearStats = (yearAccumulator: YearAccumulator): YearStats => {
const { stats } = yearAccumulator
stats.allPassed = yearAccumulator.allPassed.size
stats.degreeStudents = yearAccumulator.degreeStudents.size
stats.exchangeStudents = yearAccumulator.exchangeStudents.size
stats.otherUniversityStudents = yearAccumulator.otherUniversityStudents.size
stats.separateStudents = yearAccumulator.separateStudents.size
stats.otherStudents = yearAccumulator.otherStudents.size
stats.openStudentsHasHetu = yearAccumulator.openStudentsHasHetu.size
stats.openStudentsNoHetu = yearAccumulator.openStudentsNoHetu.size
stats.transferStudents = yearAccumulator.transferStudents.size
stats.allStudents = stats.allPassed + stats.allNotPassed
return stats
}
const getYearRange = async (unixMillis: number, isAcademicYear: boolean) => {
const startDate = isAcademicYear ? await getCurrentStudyYearStartDate(unixMillis) : getCurrentYearStartDate()
const lastYear = startDate.getFullYear()
return range(START_YEAR, lastYear + 1)
}
/**
* @endpoint "programme courses"
*/
export const getStudyProgrammeCoursesForStudyTrack = async (
unixMillis: number,
studyProgramme: string,
academicYear: string,
academicYear: YearType,
combinedProgramme: string
) => {
const startDate =
academicYear === 'ACADEMIC_YEAR' ? await getCurrentStudyYearStartDate(unixMillis) : getCurrentYearStartDate()
const startYear = startDate.getFullYear()
const yearRange = range(2017, startYear + 1)
const isAcademicYear = academicYear === 'ACADEMIC_YEAR'
const yearRange = await getYearRange(unixMillis, isAcademicYear)
if (yearRange.length === 0) {
return []
}
const mainProgrammeCourses = await getAllStudyProgrammeCourses(studyProgramme)
const secondProgrammeCourses = combinedProgramme ? await getAllStudyProgrammeCourses(combinedProgramme) : []
const programmeCourses = [...mainProgrammeCourses, ...secondProgrammeCourses]
const programmeCourses = [...new Set([...mainProgrammeCourses, ...secondProgrammeCourses])]
const courses = (
await Promise.all(
promiseKeys.flatMap(key => makeYearlyPromises(yearRange, academicYear, key, programmeCourses, studyProgramme))
)
).flat()
if (programmeCourses.length === 0) {
return []
}
let maxYear = 0
const allCourses = courses.reduce(
(acc, currentCourseStats) => {
if (currentCourseStats.year > maxYear) {
maxYear = currentCourseStats.year
}
const firstYear = yearRange[0]
const lastYear = yearRange[yearRange.length - 1]
const from = getFrom(academicYear, firstYear)
const to = getTo(academicYear, lastYear)
acc[currentCourseStats.code] ??= {
code: currentCourseStats.code,
name: currentCourseStats.name,
isStudyModule: !!currentCourseStats.isStudyModule,
years: {},
}
const [programmeCredits, transferCredits] = await Promise.all([
getProgrammeCourseAggregates({
courseCodes: programmeCourses,
from,
to,
}),
getTransferCourseAggregates({
courseCodes: programmeCourses,
from,
to,
}),
])
acc[currentCourseStats.code].years[currentCourseStats.year] ??= {
...emptyStats,
isStudyModule: !!currentCourseStats.isStudyModule,
}
const currentYear = acc[currentCourseStats.code].years[currentCourseStats.year]
switch (currentCourseStats.type) {
case 'passed':
currentYear.totalPassed += currentCourseStats.totalPassed ?? 0
currentYear.totalAllStudents += currentYear.totalPassed
currentYear.totalAllCredits += currentCourseStats.totalAllCredits ?? 0
break
case 'notCompleted':
currentYear.totalNotCompleted += currentCourseStats.totalNotCompleted ?? 0
currentYear.totalAllStudents += currentYear.totalNotCompleted
break
case 'ownProgramme':
currentYear.totalProgrammeStudents += currentCourseStats.totalProgrammeStudents ?? 0
currentYear.totalProgrammeCredits += currentCourseStats.totalProgrammeCredits ?? 0
break
case 'otherProgramme':
currentYear.totalOtherProgrammeStudents += currentCourseStats.totalOtherProgrammeStudents ?? 0
currentYear.totalOtherProgrammeCredits += currentCourseStats.totalOtherProgrammeCredits ?? 0
break
case 'noStudyright':
currentYear.totalWithoutStudyRightStudents += currentCourseStats.totalWithoutStudyRightStudents ?? 0
currentYear.totalWithoutStudyRightCredits += currentCourseStats.totalWithoutStudyRightCredits ?? 0
break
case 'transfer':
currentYear.totalTransferStudents += currentCourseStats.totalTransferStudents ?? 0
currentYear.totalTransferCredits += currentCourseStats.totalTransferCredits ?? 0
break
}
return acc
},
{} as Record<string, StudyProgrammeCourse>
const openUniStudentHetuMap = await getStudentHetuStateMap(
programmeCredits.filter(c => c.variant === 'openUni').map(c => c.studentNumber)
)
const ayCourses = Object.keys(allCourses).filter(courseCode => courseCode.startsWith('AY'))
const properties = Object.keys(emptyStats)
const courseMap = new Map<string, CourseAccumulator>()
let maxYear = 0
ayCourses.forEach(ayCourse => {
const openUniCourseCode = getOpenUniCourseCode(ayCourse)
if (!openUniCourseCode) return
for (const row of programmeCredits) {
const attainmentDate = new Date(row.attainmentDate)
const year = getYearKey(attainmentDate, isAcademicYear)
const normCode = openUniCourseCode[1]
if (!yearRange.includes(year)) continue
maxYear = Math.max(maxYear, year)
if (allCourses[normCode]) {
const mergedCourse = {} as StudyProgrammeCourse
mergedCourse.code = allCourses[normCode].code
mergedCourse.name = allCourses[normCode].name
mergedCourse.years = {}
const course = ensureCourse(courseMap, row.courseCode, row.courseName, row.isStudyModule)
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
const { stats } = yearAccumulator
yearRange
.filter(year => year <= maxYear)
.forEach(year => {
if (!allCourses[normCode].years[year]) {
mergedCourse.years[year] = emptyStats
} else {
mergedCourse.years[year] = { ...allCourses[normCode].years[year] }
}
if (allCourses[ayCourse].years[year]) {
properties.forEach(prop => {
mergedCourse.years[year][prop] += allCourses[ayCourse].years[year][prop]
})
}
})
allCourses[normCode] = mergedCourse
delete allCourses[ayCourse]
stats.allCredits += row.credits
yearAccumulator.allPassed.add(row.studentNumber)
switch (row.variant) {
case 'degree':
yearAccumulator.degreeStudents.add(row.studentNumber)
stats.degreeStudentsCredits += row.credits
break
case 'exchange':
yearAccumulator.exchangeStudents.add(row.studentNumber)
stats.exchangeStudentsCredits += row.credits
break
case 'separate':
yearAccumulator.separateStudents.add(row.studentNumber)
stats.separateStudentsCredits += row.credits
break
case 'otherUniversity':
yearAccumulator.otherUniversityStudents.add(row.studentNumber)
stats.otherUniversityCredits += row.credits
break
case 'other':
yearAccumulator.otherStudents.add(row.studentNumber)
stats.otherStudentsCredits += row.credits
break
case 'openUni':
if (openUniStudentHetuMap.get(row.studentNumber)) {
stats.openStudentsHasHetuCredits += row.credits
yearAccumulator.openStudentsHasHetu.add(row.studentNumber)
} else {
stats.openStudentsNoHetuCredits += row.credits
yearAccumulator.openStudentsNoHetu.add(row.studentNumber)
}
break
default:
break
}
}
for (const row of transferCredits) {
const attainmentDate = new Date(row.attainmentDate)
const year = getYearKey(attainmentDate, isAcademicYear)
if (!yearRange.includes(year)) continue
maxYear = Math.max(maxYear, year)
const course = ensureCourse(courseMap, row.courseCode, row.courseName, row.isStudyModule)
const yearAccumulator = ensureYear(course, year, row.isStudyModule)
yearAccumulator.transferStudents.add(row.studentNumber)
yearAccumulator.stats.transferStudentsCredits += row.credits
}
const notCompletedResults = await Promise.all(
yearRange.map(year => {
const yearFrom = getFrom(academicYear, year)
const yearTo = getTo(academicYear, year)
return getNotCompletedForProgrammeCourses(yearFrom, yearTo, programmeCourses)
})
)
notCompletedResults.forEach((results, index) => {
const year = yearRange[index]
if (!results) return
if (results.length > 0) {
maxYear = Math.max(maxYear, year)
}
for (const result of results) {
const course = ensureCourse(courseMap, result.code, result.name, result.isStudyModule ?? false)
const yearAccumulator = ensureYear(course, year, result.isStudyModule ?? false)
yearAccumulator.stats.allNotPassed += result.allNotPassed
}
})
return Object.values(allCourses)
if (maxYear === 0) {
maxYear = lastYear
}
const coursesRecord: Record<string, StudyProgrammeCourse> = {}
for (const [code, course] of courseMap.entries()) {
const years: Record<number, YearStats> = {}
for (const [year, yearAccumulator] of course.years.entries()) {
if (year > maxYear) continue
years[year] = finalizeYearStats(yearAccumulator)
}
if (Object.keys(years).length === 0) {
continue
}
coursesRecord[code] = {
code,
name: course.name,
isStudyModule: course.isStudyModule,
years,
}
}
return Object.values(coursesRecord)
}
@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import dayjs, { Dayjs } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import { indexOf, orderBy } from 'lodash-es'
import {
@@ -10,12 +11,14 @@ import {
ExtentCode,
Phase,
EnrollmentType,
TransferInfo,
} from '@oodikone/shared/types'
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { mapToProviders } from '@oodikone/shared/util'
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import logger from '../../util/logger'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
import { getGraduatedStats } from './studyProgrammeBasics'
@@ -34,6 +37,8 @@ import {
} from './studyProgrammeHelpers'
import { getStudyRightsInProgramme } from './studyRightFinders'
dayjs.extend(isSameOrAfter)
/**
* For bachelor + master study right combo. This rules out master's programmes that don't
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
@@ -47,19 +52,6 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
return minimumCredits !== null && minimumCredits <= 180
}
const calculateAbsenceInMonths = (
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
startDate: Date,
endDate: Date
) => {
if (endDate < absence.startdate || absence.enddate < startDate) {
return 0
}
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
}
/**
* Absence rules:
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
@@ -67,29 +59,58 @@ const calculateAbsenceInMonths = (
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
*
* Additional _non-statutory_ absences will consume semesters
*
* @returns duration of studies in SEMESTERS (not months!)
*/
export const calculateDurationOfStudies = (
startDate: Date,
graduationDate: Date,
semesterEnrollments: SemesterEnrollment[],
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
transferInfo?: TransferInfo | null
) => {
const allowedNonStatutoryAbsences = dayjs(startDate).isBefore(dayjs('2015-08-01'), 'day') ? 4 : 2
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
dayjs('2015-08-01'),
'day'
)
? 4
: 2
const semestersWithStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
/** A semester ending and the next one starting overlap, this takes care of that */
const getCorrectSemester = (date: Dayjs) =>
Object.values(semesters).find(
({ startdate: semesterStart, enddate: semesterEnd }) =>
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
)?.semestercode
const semestersWithNonStatutoryAbsence = semesterEnrollments
.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.map(enrollment => enrollment.semester)
.slice(0, allowedNonStatutoryAbsences)
const startSemester = getCorrectSemester(dayjs(startDate))
const endSemester = getCorrectSemester(dayjs(graduationDate))
const monthsToSubtract = [...semestersWithStatutoryAbsence, ...semestersWithNonStatutoryAbsence].reduce(
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
0
if (!startSemester || !endSemester) {
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
return -1
}
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
const semestersWithStatutoryAbsence =
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
const semestersWithNonStatutoryAbsence = Math.min(
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
.length + (transferInfo?.usedAbsenceTerms ?? 0),
allowedNonStatutoryAbsences
)
return (
(transferInfo?.usedTerms ?? 0) +
endSemester -
startSemester -
semestersWithStatutoryAbsence -
semestersWithNonStatutoryAbsence +
1
)
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
}
export type GraduationTimes = {
@@ -188,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
const graduationDate = correctStudyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
const graduationYear = defineYear(graduationDate, isAcademicYear)
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
@@ -204,7 +231,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
const goal = await getGoal(studyProgramme)
const times: ChartGraduationTimes = { medians: [], goal }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + 36 }
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
for (const year of years.toReversed()) {
const median = getMedian(graduationTimes[year])
@@ -219,7 +246,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
if (doCombo) {
const median = getMedian(graduationTimesCombo[year])
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
comboTimes.medians.push({
y: median,
amount: graduationTimesCombo[year].length,
@@ -3,10 +3,10 @@ import { Op } from 'sequelize'
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import { programmeCodes } from '../../config/programmeCodes'
import { ProgrammeModuleModel } from '../../models'
import { getDegreeProgrammeType } from '../../util'
import { GraduationTarget } from '../graduationHelpers'
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
@@ -45,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
}
export const getMedian = (values: number[]) => {
if (values.length === 0) {
return 0
if (!values.length) return 0
// compareFn mandatory
const sorted = values.toSorted((a, b) => a - b)
const half = Math.floor(sorted.length / 2)
if (sorted.length % 2) {
return sorted[half]
}
values.sort((a, b) => a - b)
const half = Math.floor(values.length / 2)
if (values.length % 2) {
return values[half]
}
return (values[half - 1] + values[half]) / 2.0
return (sorted[half - 1] + sorted[half]) / 2.0
}
export function defineYear(date: Date, isAcademicYear: true): string
@@ -97,17 +98,11 @@ export const getThesisType = async (studyProgramme: string) => {
const degreeProgrammeType = await getDegreeProgrammeType(studyProgramme)
if (degreeProgrammeType === DegreeProgrammeType.MASTER) {
const mastersThesisTypes = ['urn:code:course-unit-type:masters-thesis']
if (serviceProvider !== 'toska') {
mastersThesisTypes.push('urn:code:course-unit-type:amk-masters-thesis')
}
return mastersThesisTypes
}
if (degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
const bachelorsThesisTypes = ['urn:code:course-unit-type:bachelors-thesis']
if (serviceProvider !== 'toska') {
bachelorsThesisTypes.push('urn:code:course-unit-type:amk-bachelors-thesis')
}
return bachelorsThesisTypes
}
@@ -167,8 +162,7 @@ export const tableTitles = {
} as const
export const getId = (code: string) => {
if (serviceProvider !== 'fd') return code in programmeCodes ? programmeCodes[code as keyof typeof programmeCodes] : ''
return code
return code in programmeCodes ? programmeCodes[code as keyof typeof programmeCodes] : ''
}
export const getGoal = async (programme?: string) => {
@@ -191,9 +185,9 @@ export const getGoal = async (programme?: string) => {
return 0
}
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
return 48
return GraduationTarget.FOUR_YEARS
}
return (minimumCredits / 60) * 12
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
}
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
@@ -14,15 +14,18 @@ export const getStudyRightsInProgramme = async (
where.graduated = true
}
const studyRights = await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
})
const studyRightIds = (
await SISStudyRightModel.findAll({
attributes: ['id'],
include: {
model: SISStudyRightElementModel,
as: 'studyRightElements',
attributes: [],
where,
},
raw: true,
})
).map(sr => sr.id)
const include: Includeable[] = [
{
@@ -54,11 +57,11 @@ export const getStudyRightsInProgramme = async (
return (
await SISStudyRightModel.findAll({
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
include,
where: {
id: {
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
[Op.in]: studyRightIds,
},
},
})
@@ -10,7 +10,7 @@ import {
SemesterEnrollment,
} from '@oodikone/shared/types'
import { createLocaleComparator, keysOf } from '../../util'
import { countTimeCategories } from '../graduationHelpers'
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
import { getSemestersAndYears } from '../semesters'
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
combinedProgramme?: string
) => {
const goal = await getGoal(combinedProgramme ?? studyProgramme)
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
const calculateGraduationTimes = (
graduationTimes: Record<string, Record<string, number[]>>,
@@ -276,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
const graduationDate = studyRightElement.endDate
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
const duration = calculateDurationOfStudies(
startDate,
graduationDate,
studyRight.semesterEnrollments,
semesters,
studyRight.transferInfo
)
if (countAsBachelorMaster) {
if (!graduationTimesCombo[programmeOrStudyTrack]) {
@@ -313,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
bachelorStartDate,
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
studyRight.semesterEnrollments,
semesters
semesters,
studyRight.transferInfo
)
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
@@ -411,11 +418,7 @@ const getMainStatsByTrackAndYear = async (
const graduatedCount: Record<string, number> = {}
years.forEach(year => {
if (!yearlyStats[year]) {
graduatedCount[year] = 0
} else {
graduatedCount[year] = yearlyStats[year][studyProgramme].graduated
}
graduatedCount[year] = yearlyStats[year] ? yearlyStats[year][studyProgramme].graduated : 0
})
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
+5 -37
View File
@@ -3,7 +3,6 @@ import { LRUCache } from 'lru-cache'
import { User } from '@oodikone/shared/models/user'
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
import { serviceProvider } from '../config'
import { roles } from '../config/roles'
import { sequelizeUser } from '../database/connection'
import { UserModel } from '../models/user'
@@ -11,13 +10,12 @@ import { ExpandedUser, FormattedUser, IamAccess } from '../types'
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
import * as jami from '../util/jami'
import mami from '../util/mami'
import { sendNotificationAboutNewUser } from './mailService'
import { getSisuAuthData, personSearchQuery, getGraphqlData } from './oriProvider'
import { getStudentnumbersByElementdetails } from './students'
import { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } from './studyGuidanceGroups'
const userAccessUtils = serviceProvider === 'toska' ? jami : mami
const userAccessUtils = jami
const { getAllUserAccess, getUserIams, getUserIamAccess } = userAccessUtils
const courseStatisticsGroup = 'grp-oodikone-basic-users'
@@ -68,7 +66,7 @@ export const updateUser = async (username: string, fields: Partial<User>) => {
userDataCache.delete(username)
}
const getIamBasedRights = (iamAccess: IamAccess): DetailedProgrammeRights[] => {
const getIamBasedRights = (iamAccess: IamAccess | undefined): DetailedProgrammeRights[] => {
if (!iamAccess) {
return []
}
@@ -89,8 +87,8 @@ const getUserProgrammesRights = (userProgrammes: string[]): DetailedProgrammeRig
}
const getStudyProgrammeRights = (
iamAccess: IamAccess,
specialGroup: Record<string, boolean>,
iamAccess: IamAccess | undefined,
specialGroup: Record<string, boolean> | undefined,
userProgrammes: string[]
): DetailedProgrammeRights[] => {
const hasFullSisuAccess = specialGroup?.fullSisuAccess
@@ -233,15 +231,7 @@ export const getMockedUser = async ({ userToMock, mockedBy }: { userToMock: stri
return mockedUser
}
export const getMockedUserFd = async ({ userToMock, mockedBy }: { userToMock: string; mockedBy: string }) => {
const mockedByFromDb = (await UserModel.findOne({ where: { username: mockedBy } }))?.toJSON()
if (!mockedByFromDb) {
return null
}
return await getMockedUser({ userToMock, mockedBy })
}
export const getUserToska = async ({
export const getUser = async ({
username,
name,
email,
@@ -283,28 +273,6 @@ export const getUserToska = async ({
return user
}
export const getUserFd = async ({ username }: { username: string }) => {
if (userDataCache.has(username)) {
return userDataCache.get(username) as FormattedUser
}
const userFromDbOrm = await UserModel.findOne({ where: { username } })
if (!userFromDbOrm) {
return null
}
userFromDbOrm.lastLogin = new Date()
await userFromDbOrm.save()
const userFromDb = userFromDbOrm.toJSON()
const detailedProgrammeRights = getStudyProgrammeRights({}, {}, userFromDb.programmeRights)
const user = await formatUser({ ...userFromDb, iamGroups: [], detailedProgrammeRights })
userDataCache.set(username, user)
return user
}
export const addNewUser = async user => {
const name = user.first_name.concat(' ', user.last_name)
try {
+6 -32
View File
@@ -1,43 +1,17 @@
import axios from 'axios'
import { importerDbApiPassword, importerDbApiUser, importerToken, importerUrl, serviceProvider } from '../config'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, importerUrl } from '../config'
import logger from './logger'
const createBasicAuthHeader = () => {
const auth = `${importerDbApiUser}:${importerDbApiPassword}`
const token = Buffer.from(auth).toString('base64')
return {
Authorization: `Basic ${token}`,
}
}
const toskaImporterClient = axios.create({
const importerClient = Fetchios.create({
headers: { token: importerToken },
baseURL: importerUrl,
baseUrl: importerUrl,
timeout: 10000,
})
const fdImporterClient = axios.create({
headers: createBasicAuthHeader(),
baseURL: importerUrl,
})
const missingBasicAuthCredentials = !importerDbApiUser || !importerDbApiPassword
const getFdImporterClient = () => {
if (missingBasicAuthCredentials) {
logger.error("Basic auth credentials not set, can't return client!")
return null
}
return fdImporterClient
}
const getToskaImporterClient = () => {
export const getImporterClient = () => {
if (!importerToken) {
logger.error("Importer token not set, can't return client!")
return null
}
return toskaImporterClient
return importerClient
}
export const getImporterClient = serviceProvider === 'toska' ? getToskaImporterClient : getFdImporterClient
+31 -28
View File
@@ -1,12 +1,12 @@
import * as Sentry from '@sentry/node'
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, isStaging, jamiUrl, serviceProvider } from '../config'
import { importerToken, isStaging, jamiUrl } from '../config'
import { IamAccess } from '../types'
import logger from './logger'
const jamiClient = axios.create({
baseURL: jamiUrl,
const jamiClient = Fetchios.create({
baseUrl: jamiUrl,
params: {
token: importerToken,
},
@@ -24,29 +24,34 @@ export const getUserIamAccess = async (
): Promise<UserIamAccess> => {
if (iamGroups.length === 0) return {}
try {
const { data } = await jamiClient.post('/', {
const { data, error } = await jamiClient.post<Pick<UserIamAccess, 'specialGroup'>>(
'',
{
userId: sisPersonId,
getSisuAccess: !isStaging && !!sisPersonId,
iamGroups,
})
},
{}
)
if (error === null) {
const { specialGroup, ...resetOfIamAccess } = data
return { iamAccess: resetOfIamAccess, specialGroup }
} catch (error) {
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
export const getUserIams = async (userId: string) => {
try {
const { data } = await jamiClient.get(`/${userId}`)
const { data } = await jamiClient.get<any>(userId, {})
return data.iamGroups as string[]
} catch (error: any) {
if (error.response.status !== 404) {
@@ -65,18 +70,16 @@ type Access = {
}
export const getAllUserAccess = async (userIds: string[]) => {
const { data } = await jamiClient.post('access-and-special-groups', { userIds })
const { data } = await jamiClient.post('access-and-special-groups', { userIds }, {})
return data as Access[]
}
if (serviceProvider === 'toska') {
jamiClient
.get('/ping', { timeout: 4000 })
.then(() => {
logger.info('JAMI connected')
})
.catch(error => {
logger.error('JAMI not responding :(', { error })
logger.info('Are you sure you are using the latest JAMI image?')
})
}
jamiClient
.get('ping', { timeout: 4000 })
.then(() => {
logger.info('JAMI connected')
})
.catch(error => {
logger.error('JAMI not responding :(', { error })
logger.info('Are you sure you are using the latest JAMI image?')
})
+18 -20
View File
@@ -3,29 +3,27 @@ import * as winston from 'winston'
import { WinstonGelfTransporter } from 'winston-gelf-transporter'
import LokiTransport from 'winston-loki'
import { isProduction, serviceProvider } from '../config'
import { isProduction } from '../config'
const transports: winston.transport[] = [new winston.transports.Console()]
if (serviceProvider !== 'fd') {
transports.push(
new LokiTransport({
host: 'http://loki-svc.toska-lokki.svc.cluster.local:3100',
labels: { app: 'oodikone', environment: process.env.NODE_ENV ?? 'production' },
}),
new WinstonGelfTransporter({
handleExceptions: true,
host: 'svm-116.cs.helsinki.fi',
port: 9503,
protocol: 'udp',
hostName: os.hostname(),
additional: {
app: 'oodikone',
environment: 'production',
},
}) as winston.transport
)
}
transports.push(
new LokiTransport({
host: 'http://loki-svc.toska-lokki.svc.cluster.local:3100',
labels: { app: 'oodikone', environment: process.env.NODE_ENV ?? 'production' },
}),
new WinstonGelfTransporter({
handleExceptions: true,
host: 'svm-116.cs.helsinki.fi',
port: 9503,
protocol: 'udp',
hostName: os.hostname(),
additional: {
app: 'oodikone',
environment: 'production',
},
}) as winston.transport
)
const { colorize, combine, timestamp, printf } = winston.format
const logger = winston.createLogger({
-17
View File
@@ -1,17 +0,0 @@
const getUserIamAccess = async (_, __, ___) => {
return {}
}
const getUserIams = async _ => {
return []
}
const getAllUserAccess = async _ => {
return []
}
module.exports = {
getUserIamAccess,
getUserIams,
getAllUserAccess,
}
+2 -1
View File
@@ -17,7 +17,8 @@
"sourceMap": true,
"sourceRoot": "/",
"inlineSources": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["index.ts", "src", "tests"],
"exclude": ["../frontend"]
-6
View File
@@ -1,6 +0,0 @@
.dockerignore
.eslintrc.json
.gitignore
*Dockerfile
build/
node_modules/
+3 -4
View File
@@ -22,15 +22,14 @@ ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
ARG SENTRY_DSN=''
ENV REACT_APP_SENTRY_DSN=$SENTRY_DSN
ARG SERVICE_PROVIDER='toska'
ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
ARG LANGUAGE_CENTER_VIEW_ENABLED
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
ARG SIS_URL='https://sisu.helsinki.fi'
ENV REACT_APP_SIS_URL=$SIS_URL
RUN npm install -g serve@^14
WORKDIR /opt/app-root/shared
COPY ./shared .
@@ -39,7 +38,7 @@ COPY ./frontend/package*.json .
RUN npm ci --include=dev
COPY ./frontend .
RUN npm run build && npm install -g serve@^14
RUN npm run build
EXPOSE 5000
CMD ["serve", "--single", "--listen", "5000", "build"]
+215 -462
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -16,11 +16,10 @@
"@mui/material": "^6.4.12",
"@mui/x-date-pickers": "^7.29.4",
"@oodikone/shared": "file:../shared",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/browser": "^10.5.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"lodash-es": "^4.17.23",
@@ -41,9 +40,9 @@
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^4.0.0",
"@vitejs/plugin-react-swc": "^4.2.3",
"typescript": "^5.9.2",
"vite": "^7.0.0"
"vite": "^7.3.1"
},
"overrides": {
"react-highcharts": {
@@ -1 +0,0 @@
{ "sisUrl": "https://sis-qa.funidata.fi" }
@@ -1,14 +1,12 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import * as Sentry from '@sentry/browser'
import axios from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { actionHistory } from '@/redux/actionHistory'
import { formatToArray } from '@oodikone/shared/util'
import { Fetchios } from '@oodikone/shared/util/fetchios'
// Set up dev user for development environment, mimicking production admin user
const baseHeaders = isDev
const baseHeaders: Record<string, string> = isDev
? {
uid: 'mluukkai',
displayName: 'Matti Luukkainen',
@@ -31,68 +29,53 @@ const getHeaders = () => {
return headers
}
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
const api = Fetchios.create({ baseUrl: apiBasePath })
const actionTypes = prefix => ({
attempt: `${prefix}ATTEMPT`,
failure: `${prefix}FAILURE`,
success: `${prefix}SUCCESS`,
})
type ApiMethods = 'get' | 'post' | 'put' | 'delete'
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
const options = { headers: getHeaders(), timeout }
/**
* Calls API with minimal preprocessing.
* NOTE! `data` -attribute has to be JSON.stringified in-order for the predefined
* 'Content-Type' to work and the receiving Express instance to parse the body correctly.
*/
export const callApi = async (
url: string,
method: ApiMethods = 'get',
data?: any,
params: Record<string, any> = {},
timeout = 30_000
) => {
const apiRequestController = new AbortController()
const signal = timeout !== 0 ? apiRequestController.signal : undefined
const onDownloadProgress = ({ loaded, total }) => {
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
const options = {
params,
signal,
headers: {
'Content-Type': 'application/json',
...getHeaders(),
},
}
// const buildUrl = () => e{
// const urlParams = new URLSearchParams(params)
// return apiBasePath + url + urlParams.toString()
// }
switch (method) {
case 'get':
return api.get(url, { ...options, params, onDownloadProgress })
return api.get(url, options)
case 'post':
return api.post(url, data, options)
case 'put':
return api.put(url, data, options)
case 'delete':
return api.delete(url, { headers: options.headers, data })
return api.delete(url, options)
default:
return Promise.reject(new Error('Invalid http method'))
throw new Error('Invalid method used for API call.')
}
}
const handleError = (error, actionHistory = []) => {
const { config, request, response } = error
if (response && response.status) {
Sentry.withScope(scope => {
scope.setExtra('config', config)
scope.setExtra('request', request)
scope.setExtra('response', response)
scope.setExtra('actionHistory', JSON.stringify(actionHistory))
Sentry.captureException(error)
})
}
}
export const handleRequest = store => next => async action => {
if (action.requestSettings) {
const { success, failure } = actionTypes(prefix)
const { route, method, data, prefix, query, params, onProgress } = action.requestSettings
// Based no the previous config. We don't actually care when this finishes.
void callApi(route, method, data, params, 0, onProgress)
.then(res => store.dispatch({ type: success, response: res.data, query }))
.catch(error => {
// Handle error first to avoid redux minified error #3 in production.
// We don't need to add the error to the actionHistory as it is already passed to Sentry.
handleError(error, actionHistory)
store.dispatch({ type: failure, response: error, query })
})
}
next(action)
}
// Redux-toolkit query based API
// Api can be extended with enhanceEndpoints method
// All tags used for invalidating cache must be defined here
@@ -20,11 +20,11 @@ export const facultyToolTips: Record<string, string> = {
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
Lisäksi painamalla "Show special categories" -valintaa, saat näkyviin myös harvinaisemmat kategoriat:
**Separate**: Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
**Other university**: Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
**Other**: Sisältää tohtorin- ja lisensiaatintutkintoon liittyvät opintopisteet, täydennyskoulutuksen, erikoistumiskoulutuksen, erikoislääkäri- ja erikoishammaslääkärikoulutuksen sekä lukiolaisten opintopisteet.
`,
graduatedOfTheFaculty: `
Sisältää kyseisenä vuonna tiedekunnasta valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty.
@@ -47,12 +47,11 @@ export const facultyToolTips: Record<string, string> = {
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
- **Breakdown**/**Median study times**:
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), 12 kuukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
- **Graduation year**/**Starting year**:
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
`,
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
studentProgress: `
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
@@ -8,6 +8,7 @@ export const filterToolTips = {
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
`,
hetu: 'Rajaa opiskelijoita sen mukaan onko heillä suomalaista henkilötunnusta. Filters students based on if they have a Finnish personal identity code.',
startingYear:
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
studentNumber:
@@ -29,45 +29,40 @@ export const studyProgrammeToolTips: Record<string, string> = {
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija. Avoimen opiskelijat ovat eroteltu suomalaisen henkilötunnuksen omaaviin / hetuttomiin.
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
**Transferred**: Hyväksiluetut opintopisteet. Tilastointipäivämäärä on hyväksilukupäivämäärä, eli vaikka suoritus olisi vuodelta 2021, jos opintopiste hyväksiluetaan 2024, se lasketaan vuodelle 2024.
Lisäksi painamalla "Show special categories" -valintaa, saat näkyviin myös harvinaisemmat kategoriat:
**Separate**: Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
**Other university**: Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
**Other**: Sisältää tohtorin- ja lisensiaatintutkintoon liittyvät opintopisteet, täydennyskoulutuksen, erikoistumiskoulutuksen, erikoislääkäri- ja erikoishammaslääkärikoulutuksen sekä lukiolaisten opintopisteet.
`,
creditsOfProgrammeCourses: `
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan.
programmeCoursesByCreditType: `
Opintopisteet ja opiskelijamäärät suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan. Mukana ovat kurssit ja opintokokonaisuudet.
**Major credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan. Sisältää myös ohjelmaan siirtyneet ensisijaiset opiskelijat.
**Degree students**: Suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus koulutusohjelmaan (alempi tai ylempi). Sisältää myös ohjelmaan siirtyneet ensisijaiset opiskelijat.
**Non-major credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla ei ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan.
Sisältää esimerkiksi vaihto-opiskelijat ja erillisopinto-oikeudella opiskelevat. Sisältää myös opintopisteet, jotka opiskelija on suorittanut koulutusohjelmaan ennen opintojen aloittamista koulutusohjelmassa tai opintojen päätyttyä.
**Open university**: Suorittajalla on ollut suoritushetkellä vain avoimen yliopiston opinto-oikeus.
**Non-degree credits**: Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
**Exchange students**: Vaihto-opiskelijan opinto-oikeudella tehdyt suoritukset.
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet. **Luku ei sisälly total-sarakkeeseen**.
**Other universities**: Korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opinto-oikeudella tehdyt suoritukset.
**Separate studies**: Erillisopinto-oikeudella tai opettajankoulutuksen erillisopinto-oikeudella tehdyt suoritukset.
**Type**: Kertoo onko kyseessä kurssi (course) vai opintokokonaisuus (module).
**Other**: Täydennyskoulutuksen, tohtoreiden, lisensiaattien, lääketieteen/hammaslääketieteen/eläinlääketieten -erikoistumisopintojen, lukiolaisten ja kesäkoulun opinto-oikeuksilla tehdyt suoritukset.
**Transferred credits**: Hyväksiluetut suoritukset (ei lasketa total-sarakkeisiin).
Huom: Oodi/Sisu -datassa olevien puutteiden vuoksi kategorioiden välillä voi esiintyä pieniä eroja (esim. suoritukseen ei liitetty opinto-oikeutta, suoritus kirjattu opinto-oikeuden voimassaoloaikojen ulkopuolelle, yms.).
`,
studentsOfProgrammeCourses: `
Sisältää kurssien opiskelijat.
**Not completed**: Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
**Major students**: Sisältää opiskelijat, joilla on ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan.
**Non-major students**: Sisältää kurssin opiskelijat, joilla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan. Sisältää
esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat.
**Transferred students**: Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet. Lukua **ei lasketa mukaan Total-sarakkeeseen**.
**Type**: Kertoo onko kyseessä kurssi (course) vai opintokokonaisuus (module).
programmeCoursesStudentSwitch: `
**Show credits**: Näyttää hyväksytyistä suorituksesta kertyneet **opintopisteet**
**Show students**: Näyttää kaikki ilmoittautuneet ja/tai arvosanan saaneet **opiskelijat**
`,
graduatedAndThesisWritersOfTheProgramme: `
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatin- tai maisterintutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
@@ -77,22 +72,22 @@ export const studyProgrammeToolTips: Record<string, string> = {
averageGraduationTimes: `
Yksittäinen palkki kertoo, kuinka moni opiskelija on valmistunut kyseisenä vuonna/lukuvuonna.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
`,
averageGraduationTimesStudyTracks: `
Yksittäinen palkki kertoo, kuinka moni kyseisenä **lukuvuotena ALOITTANEISTA** opiskelijoista on valmistunut.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
+18 -9
View File
@@ -1,8 +1,9 @@
import dayjs, { extend as dayjsExtend } from 'dayjs'
import dayjs, { Dayjs, extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { maxBy, orderBy, range } from 'lodash-es'
import { serviceProvider } from '@/conf'
import { SemestersData } from '@/redux/semesters'
import type { Absence } from '@/types/students'
import type { SISStudyRightElement } from '@oodikone/shared/models'
@@ -18,6 +19,8 @@ import {
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
dayjsExtend(isBetween)
dayjsExtend(isSameOrAfter)
dayjsExtend(isSameOrBefore)
export const isFall = (semester: number) => semester % 2 === 1
@@ -344,6 +347,19 @@ export const getSemesterCodeAt = (allSemesters?: SemestersData['semesters'], tar
)?.semestercode
}
/**
* @returns all semesters from semesterEnrollments that occurred partially or fully during a given period
*/
export const getSemestersBetweenRange = (start: Dayjs, end: Dayjs, allSemesters?: SemestersData['semesters']) =>
Object.values(allSemesters ?? {}).filter(semester => {
const semesterStart = dayjs(semester.startdate)
const semesterEnd = dayjs(semester.enddate)
return dayjs(semesterStart).isSameOrBefore(end)
? semesterEnd.isSameOrAfter(start)
: semesterStart.isSameOrBefore(end) && semesterEnd.isSameOrAfter(start)
})
/**
* Extracts items from a string separated by commas, semicolons, spaces, or newlines.
* @param {string} input - The input string containing items.
@@ -420,13 +436,6 @@ export const getEnrollmentTypeTextForExcel = (type: number, statutoryAbsence?: b
return 'No study right'
}
export const isDefaultServiceProvider = () => {
if (!serviceProvider) {
return false
}
return serviceProvider === 'toska'
}
export const formatContent = (content: string) => content.replace(/\n +/g, '\n')
export const filterInternalReleases = (release: Release) => !release.title.startsWith('Internal:')
@@ -1,9 +1,11 @@
import { getFilteredRowModel } from '@tanstack/react-table'
import { useMemo } from 'react'
import {
calculateTotals,
emptyFields,
useColorizedCoursesTableContext,
CourseFilter,
} from '@/components/ColorizedCoursesTable/common'
import '@/components/ColorizedCoursesTable/index.css'
import {
@@ -14,6 +16,7 @@ import {
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { useDebouncedState } from '@/hooks/debouncedState'
import { useGetFacultiesQuery } from '@/redux/facultyStats'
import { useColumns } from './logic'
@@ -24,6 +27,8 @@ export const FacultiesTab = () => {
const { getTextIn } = useLanguage()
const facultyQuery = useGetFacultiesQuery()
const [courseFilter, setCourseFilter] = useDebouncedState('', 250)
const facultyMap = useMemo(
() =>
facultyQuery.data?.reduce((obj, cur) => {
@@ -91,7 +96,10 @@ export const FacultiesTab = () => {
initialState: { columnPinning: { left: ['Course'] } },
state: {
useZebrastripes: colorMode === 'none',
columnFilters: [{ id: 'Course', value: courseFilter }],
},
onColumnFiltersChange: setCourseFilter,
getFilteredRowModel: getFilteredRowModel(),
}
return (
@@ -106,7 +114,12 @@ export const FacultiesTab = () => {
cy="ooditable-faculties"
data={tableData}
options={tableOptions}
toolbarContent={<OodiTableExcelExport data={excelData} exportColumnKeys={cols.map(({ id }) => id)} />}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={cols.map(({ id }) => id)} />
<CourseFilter setCourseFilter={setCourseFilter} />
</>
}
/>
</div>
)
@@ -37,6 +37,12 @@ export const useColumns = (getTextIn, faculties, numberMode, colorMode, facultyM
},
aggregationRows: () => [{ id: 'total', value: undefined }],
aggregatedCell: () => <CourseContainer code="Total" getTextIn={getTextIn} name={{ en: 'All courses total' }} />,
// TODO: pls DRY. This is same as in SemesterTab
filterFn: (row, columnId, filterValue) => {
const { code, name } = row.original
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return code.toLowerCase().includes(filterValue) || getTextIn(name).toLowerCase().includes(filterValue)
},
}),
columnHelper.accessor('total-column', {
id: 'Total',
@@ -1,9 +1,11 @@
import { getFilteredRowModel } from '@tanstack/react-table'
import { useMemo } from 'react'
import {
calculateNewTotalColumnValues,
calculateTotals,
useColorizedCoursesTableContext,
CourseFilter,
} from '@/components/ColorizedCoursesTable/common'
import {
ColorModeSelector,
@@ -14,11 +16,13 @@ import '@/components/ColorizedCoursesTable/index.css'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { useDebouncedState } from '@/hooks/debouncedState'
import { useColumns } from './logic'
export const SemestersTab = () => {
const { getTextIn } = useLanguage()
const { semesters, numberMode, colorMode, selectedSemesters, data } = useColorizedCoursesTableContext()
const [courseFilter, setCourseFilter] = useDebouncedState('', 250)
const totalRow = useMemo(() => {
if (!data) return {}
@@ -50,10 +54,13 @@ export const SemestersTab = () => {
)
const tableOptions = {
initialState: { columnPinning: { left: ['code'] } },
initialState: { columnPinning: { left: ['Course'] } },
state: {
useZebrastripes: colorMode === 'none',
columnFilters: [{ id: 'Course', value: courseFilter }],
},
onColumnFiltersChange: setCourseFilter,
getFilteredRowModel: getFilteredRowModel(),
}
return (
@@ -68,7 +75,12 @@ export const SemestersTab = () => {
cy="ooditable-semesters"
data={tableData}
options={tableOptions}
toolbarContent={<OodiTableExcelExport data={excelData} exportColumnKeys={cols.map(({ header }) => header)} />}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={cols.map(({ header }) => header)} />
<CourseFilter setCourseFilter={setCourseFilter} />
</>
}
/>
</div>
)
@@ -20,6 +20,7 @@ export const useColumns = (getTextIn, semesters, numberMode, colorMode, allTotal
return useMemo(
() => [
columnHelper.accessor('code', {
id: 'Course',
header: 'Course',
cell: ({ row }) => {
const { code, name } = row.original
@@ -27,6 +28,12 @@ export const useColumns = (getTextIn, semesters, numberMode, colorMode, allTotal
},
aggregationRows: () => [{ id: 'total', value: undefined }],
aggregatedCell: () => <CourseContainer code="Total" getTextIn={getTextIn} name={{ en: 'All courses total' }} />,
// TODO: pls DRY. This is same as in FacultiesTab
filterFn: (row, columnId, filterValue) => {
const { code, name } = row.original
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return code.toLowerCase().includes(filterValue) || getTextIn(name).toLowerCase().includes(filterValue)
},
}),
columnHelper.accessor('all', {
header: 'Total',
@@ -1,4 +1,5 @@
import { createContext, useContext } from 'react'
import TextField from '@mui/material/TextField'
import { createContext, useContext, useState, useEffect } from 'react'
export const ColorizedCoursesTableContext = createContext(null)
@@ -82,3 +83,15 @@ export const getColor = (stats, columnAmount, colorMode, numberMode, courseTotal
backgroundColor: `rgba(${color},${Math.min(1.0, relativeAlphaValue)}) !important`,
}
}
// NOTE: Give setCourseFilter as a useDebouncedState to make the text box work nicely
export const CourseFilter = ({ setCourseFilter }) => {
const [textField, setTextField] = useState('')
useEffect(() => {
setCourseFilter(textField)
}, [textField])
return (
<TextField label="Course name/code filter" onChange={event => setTextField(event.target.value)} value={textField} />
)
}
@@ -13,6 +13,7 @@ import {
courseFilter,
creditsEarnedFilter,
genderFilter,
hetuFilter,
gradeFilter,
programmeFilter,
startYearAtUniFilter,
@@ -102,12 +103,17 @@ export const CoursePopulation = () => {
}
}
const { dateFrom, dateTo } = getFromToDates(from, to, separate ? JSON.parse(separate) : false)
const isSeparate = separate ? JSON.parse(separate) : false
const { dateFrom, dateTo } = getFromToDates(from, to, isSeparate)
// Dates must be set
if (!dateFrom || !dateTo) return null
const dateRange = `${new Date(dateFrom).getFullYear()}-${new Date(dateTo).getFullYear()}`
const singleSemester =
from === to && isSeparate ? Object.values(allSemesters).find(s => s.semestercode === Number(from)) : null
const dateRange = singleSemester
? (getTextIn(singleSemester.name) ?? '')
: `${new Date(dateFrom).getFullYear()}-${new Date(dateTo).getFullYear()}`
if (!population || !semesters) return <PageLoading isLoading />
@@ -203,6 +209,7 @@ export const CoursePopulation = () => {
ageFilter(),
courseFilter({ courses: population?.coursestatistics.courses }),
creditsEarnedFilter(),
hetuFilter(),
startYearAtUniFilter(),
programmeFilter({
additionalModes: [
@@ -48,7 +48,7 @@ export const CustomPopulationContent = ({
isFetchingPopulation: boolean
resetState: () => void
}) => {
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
const studyProgrammes = useFilteredAndFormattedStudyProgrammes().filteredProgrammes
const [studentAmountLimit, setStudentAmountLimit] = useDebouncedState(0, 1000)
const { getTextIn } = useLanguage()
@@ -41,7 +41,9 @@ export const CustomPopulationSearchForm = ({
const [selectedSearch, setSelectedSearch] = useState<CustomPopulationSearch | null>(null)
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
// Only show programmes the current user has access to,
// see customPopulationInfo above
const studyProgrammes = useFilteredAndFormattedStudyProgrammes().filteredProgrammes
const { data: searches, isFetching } = useGetCustomPopulationSearchesQuery(undefined)
const [createSearch] = useCreateCustomPopulationSearchMutation()
@@ -88,7 +90,7 @@ export const CustomPopulationSearchForm = ({
}
}
const onSearch = event => {
const onSearch = (event: React.MouseEvent) => {
event.preventDefault()
const studentNumbers = extractItems(studentNumberInput)
@@ -6,6 +6,7 @@ import {
courseFilter,
creditDateFilter,
creditsEarnedFilter,
hetuFilter,
enrollmentStatusFilter,
genderFilter,
hopsFilter,
@@ -51,6 +52,7 @@ export const CustomPopulationWrapper = ({
transferredToProgrammeFilter(),
startYearAtUniFilter(),
tagsFilter(),
hetuFilter(),
programmeFilter(),
creditDateFilter(),
enrollmentStatusFilter({
@@ -69,7 +71,7 @@ export const CustomPopulationWrapper = ({
coursestatistics={population?.coursestatistics}
displayTray={populationStudents.length > 0}
filters={filters}
initialOptions={[]}
initialOptions={{}}
name="CustomPopulation"
students={populationStudents}
>
@@ -8,7 +8,14 @@ export const useColumns = ({ programme }): [string[], string[]] => {
const nameColumns = namesVisible ? ['lastName', 'firstNames'] : []
const populationWithProgrammeColumns = programme
? ['option', 'transferredFrom', 'semesterEnrollments', 'curriculumPeriod']
? [
'creditsBeforeStarting',
'coursesBeforeStarting',
'option',
'transferredFrom',
'semesterEnrollments',
'curriculumPeriod',
]
: ['primaryProgramme']
const adminColumns = isAdmin ? ['extent', 'updatedAt'] : []
@@ -76,7 +76,10 @@ export const courseFilter = createFilter({
{} as Record<string, string[]>
)
options.courses = Object.fromEntries(args.courses.map(course => [course.code, course]))
/* option.courses maybe frozen even when it should be used only within the scope of createFilter factory. */ {
delete options.courses
options.courses = Object.fromEntries(args.courses.map(course => [course.code, course]))
}
options.substitutedBy = substitutedBy
},
@@ -114,13 +117,14 @@ export const courseFilter = createFilter({
render: CourseFilterCard,
selectors: {
isCourseSelected: ({ courseFilters }, course) => !!courseFilters[course],
// NOTE: Remember FilterType.ALL === 0 when checking if courseFilters[course] exists
isCourseSelected: ({ courseFilters }, course) => Object.values(FilterType).includes(courseFilters[course]),
selectedCourseName: ({ courses }, courseCodes) => courses[courseCodes[0]]?.name,
},
actions: {
toggleCourseSelection: (options, code) => {
if (!options.courseFilters[code]) {
if (!Object.values(FilterType).includes(options.courseFilters[code])) {
options.courseFilters[code] = FilterType.ALL
} else {
delete options.courseFilters[code]
@@ -0,0 +1,60 @@
import { filterToolTips } from '@/common/InfoToolTips'
import { FilterTrayProps } from '../FilterTray'
import { FilterRadio } from './common/FilterRadio'
import { createFilter } from './createFilter'
const DEFAULT_STATE = 0
const HetuFilterCard = ({ onOptionsChange }: FilterTrayProps) => {
const modeOptions = [
{ key: 'All', text: 'All', value: 0 },
{ key: 'Has hetu', text: 'Has hetu', value: 1 },
{ key: 'Does not have hetu', text: 'Does not have hetu', value: 2 },
]
return (
<FilterRadio
defaultValue={DEFAULT_STATE}
filterKey={hetuFilter.key}
onChange={({ target }) => onOptionsChange({ mode: target.value })}
options={modeOptions}
/>
)
}
/**
* @options
* 0 - All
* 1 - Has hetu
* 2 - No hetu
*/
export const hetuFilter = createFilter({
key: 'hetuFilter',
title: 'Personal identity code',
info: filterToolTips.hetu,
defaultOptions: {
mode: 0,
},
isActive: ({ mode }) => mode !== 0,
filter(student, { options: { mode } }) {
const hetu = student.hasPersonalIdentityCode
switch (Number(mode)) {
case 0:
return true
case 1:
return hetu
case 2:
return !hetu
default:
// never reached
return true
}
},
render: HetuFilterCard,
})
@@ -18,3 +18,4 @@ export { studyRightTypeFilter } from './studyRightType'
export { studyTrackFilter } from './studyTrack'
export { tagsFilter } from './tags'
export { transferredToProgrammeFilter } from './transferredToProgramme'
export { hetuFilter } from './hetu'
@@ -71,7 +71,8 @@ export const studentNumberFilter = createFilter({
filter(student, { options }) {
const { allowlist, blocklist } = options
return allowlist.includes(student.studentNumber) && !blocklist.includes(student.studentNumber)
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
},
render: StudentNumberFilterCard,
@@ -1,5 +1,5 @@
import Stack from '@mui/material/Stack'
import { FC, useMemo } from 'react'
import { useMemo, type FC, type ReactNode } from 'react'
import { selectViewFilters } from '@/redux/filters'
import { useAppSelector } from '@/redux/hooks'
@@ -15,13 +15,13 @@ import type { Filter } from './filters/createFilter'
import { FilterTray } from './FilterTray'
export const FilterView: FC<{
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => React.ReactNode
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
name: string
filters: Filter[]
students: Student[]
coursestatistics: ExpandedCourseStats | undefined
displayTray: boolean
initialOptions: Record<Filter['key'], any>
initialOptions: Record<Filter['key'], Filter['defaultOptions']>
}> = ({ children, name, filters, students, coursestatistics, displayTray, initialOptions }) => {
const storedOptions = useAppSelector(state => selectViewFilters(state, name))
@@ -37,16 +37,14 @@ export const FilterView: FC<{
const precomputed = useMemo(
() =>
Object.fromEntries(
filters
.filter(({ precompute }) => precompute !== undefined)
.map(({ precompute, key }) => [
key,
precompute!({
students: students.slice(), // Copy instead of pass
options: filterOptions[key],
args: filterArgs[key],
}),
])
filters.map(({ key, precompute }) => [
key,
precompute?.({
students: students.slice(), // Copy instead of pass
options: filterOptions[key],
args: filterArgs[key],
}),
])
),
[filters]
)
@@ -3,7 +3,6 @@ import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { isDefaultServiceProvider } from '@/common'
import { ExternalLink } from '@/components/common/ExternalLink'
import { ToskaLogo } from '@/components/Footer/ToskaLogo'
import { builtAt, dataProtectionUrl, licenseUrl, sentryRelease, sourceCodeUrl } from '@/conf'
@@ -36,7 +35,7 @@ export const Footer = () => {
<Stack direction="row" gap={3}>
<Stack>
<Typography variant="subtitle1">Oodikone</Typography>
{isDefaultServiceProvider() && <ExternalLink href={dataProtectionUrl} text="Data protection" />}
<ExternalLink href={dataProtectionUrl} text="Data protection" />
<ExternalLink href={sourceCodeUrl} text="Source code" />
<ExternalLink href={licenseUrl} text="License" />
</Stack>
@@ -120,11 +120,11 @@ export const MedianBarChart = ({
yearLabel === 'Start year'
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} months</b><br />`
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
if (!facultyGraph) {
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} months **</b></p>` : ''
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
}
return `${timeText}${statisticsText}`
@@ -201,7 +201,7 @@ export const MedianBarChart = ({
color: theme.palette.graduationTimes.onTime,
},
{
value: goal + 12.1,
value: goal + 2.1,
color: theme.palette.graduationTimes.yearOver,
},
{
@@ -230,7 +230,7 @@ export const MedianBarChart = ({
yAxis: {
min: 0,
max: maxValue,
title: { text: 'Graduation time (months)' },
title: { text: 'Graduation time (semesters)' },
labels: {
overflow: 'justify',
},
@@ -246,7 +246,7 @@ export const MedianBarChart = ({
{
color: theme.palette.graduationTimes.yearOver,
width: 2,
value: goal + 12,
value: goal + 2,
dashStyle: 'ShortDash',
},
],
@@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography'
import { useState } from 'react'
import { useLocation } from 'react-router'
import { isDefaultServiceProvider } from '@/common'
import { Link } from '@/components/common/Link'
import { languageCenterViewEnabled } from '@/conf'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
@@ -26,11 +25,7 @@ export const NavigationButton = ({ item }: { item: NavigationItem }) => {
return true
}
if (
checkUserAccess(['admin', 'openUniSearch'], roles) &&
subItemKey === 'openUniSearch' &&
isDefaultServiceProvider()
) {
if (checkUserAccess(['admin', 'openUniSearch'], roles) && subItemKey === 'openUniSearch') {
return true
}
@@ -6,7 +6,6 @@ import Toolbar from '@mui/material/Toolbar'
import { useState, useEffect, useMemo } from 'react'
import { useLocation } from 'react-router'
import { isDefaultServiceProvider } from '@/common'
import { Link } from '@/components/common/Link'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { checkUserAccess, getFullStudyProgrammeRights, hasFullAccessToTeacherData } from '@/util/access'
@@ -48,8 +47,6 @@ export const NavigationBar = () => {
if (key === 'faculty' && !checkUserAccess(['admin', 'fullSisuAccess', 'facultyStatistics'], roles))
return false
if (key === 'feedback' && !isDefaultServiceProvider()) return false
if (key === 'admin' && !isAdmin) return false
if (
@@ -66,6 +66,9 @@ export const OodiTableContainer = <OTData,>({
backgroundColor: 'grey.100',
}
: {},
'& tr:hover > td': {
backgroundColor: 'grey.200',
},
}}
>
{table.getAggregationRowModel().rows.map(row => OodiTableDataRow(row, true))}
@@ -10,13 +10,14 @@ import { flexRender } from './util'
export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => {
const { maxSize } = cell.column.columnDef
const { size, maxSize } = cell.column.columnDef
return (
<TableCell
className="ot-data-cell"
key={cell.id}
sx={{
...getCommonPinningStyles(cell.column),
width: size ?? undefined,
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
}}
>
@@ -1,19 +1,34 @@
import TablePagination from '@mui/material/TablePagination'
import type { Table } from '@tanstack/react-table'
import { useEffect, useState } from 'react'
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
const tableRows = table.getRowCount()
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, tableRows),
})
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
useEffect(
() =>
setPagination({
pageIndex: 0,
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
}),
[table.getCoreRowModel().rows.length]
)
return (
<TablePagination
component="div"
count={table.getFilteredRowModel().rows.length}
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
onRowsPerPageChange={event => {
table.setPageSize(Number(event.target.value))
table.setPageIndex(0)
}}
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
page={table.getState().pagination.pageIndex}
rowsPerPage={table.getState().pagination.pageSize}
rowsPerPageOptions={pageRowOptions}
@@ -93,6 +93,7 @@ export const OodiTableExcelExport = <TData extends object>({
}),
columnHelper.accessor('sample', {
header: 'Sample values',
enableSorting: false,
cell: cell => {
const value = cell.getValue<React.ReactNode>()
return (
@@ -112,15 +113,14 @@ export const OodiTableExcelExport = <TData extends object>({
[data]
)
const prepData = exportData.slice(0, 10)
const pivotedData = useMemo(
() =>
exportColumnKeys.map(accessorKey => ({
header: accessorKey,
sample: prepData
sample: data
.map(row => row[accessorKey])
.filter(value => value !== undefined)
.filter(Boolean)
.slice(0, 8)
.map(churnSampleData),
})),
[exportColumnKeys, exportData]
@@ -1,6 +1,6 @@
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
import { ReactNode, useState } from 'react'
import type { ReactNode } from 'react'
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
import { OodiTableContainer } from './OodiTable'
@@ -28,21 +28,14 @@ export const OodiTable = <TData,>({
cy,
}: {
data: TData[]
columns: ColumnDef<TData>[]
columns: ColumnDef<TData, any>[]
isExportView?: true
options?: Partial<TableOptions<TData>>
toolbarContent?: ReactNode
cy?: string
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: Math.min(200, data.length),
})
// should maybe use a deep merging tool or write own
const config: Partial<TableOptions<TData>> = {
...options,
}
const { ...config }: Partial<TableOptions<TData>> = options
const table = useReactTable<TData>({
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onPaginationChange: setPagination,
...config,
state: { pagination, ...config.state },
})
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
}
@@ -11,6 +11,7 @@ import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
import { CourseFilterToggle } from '../CourseFilterToggle'
import { ModuleCourseToggle } from '../ModuleCourseToggle'
const mapCourseData = course =>
course.courses
@@ -45,7 +46,13 @@ const mapCourseData = course =>
const columnHelper = createColumnHelper()
export const GradeDistribution = ({ onlyIamRights, courseStatistics }) => {
export const GradeDistribution = ({
onlyIamRights,
courseStatistics,
courseTableMode,
showModules,
setShowModules,
}) => {
const { getTextIn } = useLanguage()
const [expanded, setExpanded] = useState({})
@@ -165,10 +172,15 @@ export const GradeDistribution = ({ onlyIamRights, courseStatistics }) => {
data={data}
options={tableOptions}
toolbarContent={
<OodiTableExcelExport
data={excelData}
exportColumnKeys={columns.flatMap(column => column.columns ?? [column]).map(({ header }) => header)}
/>
<>
<OodiTableExcelExport
data={excelData}
exportColumnKeys={columns.flatMap(column => column.columns ?? [column]).map(({ header }) => header)}
/>
{courseTableMode === 'all' && (
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
)}
</>
}
/>
)
@@ -0,0 +1,21 @@
import { Toggle } from '@/components/common/toggle/Toggle'
export const ModuleCourseToggle = ({
showModules,
setShowModules,
}: {
showModules: boolean
setShowModules: (input: boolean) => void
}) => {
return (
<Toggle
cypress="module-course-toggle"
disabled={false}
firstLabel="Courses"
infoBoxContent={'Show only courses or modules'}
secondLabel="Modules"
setValue={setShowModules}
value={showModules}
/>
)
}
@@ -10,31 +10,48 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
import { CourseModule, UnionOfFilteredModuleCourse } from '@/util/coursesOfPopulation'
import { Unarray } from '@oodikone/shared/types'
import { CourseFilterToggle } from '../CourseFilterToggle'
import { ModuleCourseToggle } from '../ModuleCourseToggle'
const columnHelper = createColumnHelper()
const columnHelper = createColumnHelper<Unarray<UnionOfFilteredModuleCourse>>()
export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
export const PassFailEnrollments = ({
courseStatistics,
onlyIamRights = false,
courseTableMode,
showModules,
setShowModules,
}: {
courseStatistics: UnionOfFilteredModuleCourse
onlyIamRights?: boolean
courseTableMode?: 'all' | 'curriculum'
showModules?: boolean
setShowModules?: (input: boolean) => void
}) => {
const { getTextIn } = useLanguage()
const [expanded, setExpanded] = useState({})
const [data, excelData] = useMemo(() => {
const excelData = courseStatistics
// Export only the courses, not the modules
.flatMap(row => row?.courses ?? [row])
.flatMap(row => {
const isModule = (x: object): x is CourseModule => !!Object.hasOwn(x, 'courses')
return isModule(row) ? row?.courses : [row]
})
.map(({ name, code, stats }) => ({
Name: getTextIn(name),
Code: code,
'Total students': stats.totalStudents,
Passed: stats.passed,
Failed: stats.failed,
'Enrolled, no grade': stats.totalEnrolledNoGrade,
'Pass rate': calculatePercentage(stats.passed, stats.totalStudents),
'Attempts total': stats.attempts,
'Attempts per student': stats.perStudent,
'Passed%': calculatePercentage(stats.passedOfPopulation, 100),
'Attempted%': calculatePercentage(stats.triedOfPopulation, 100),
'Total students': stats?.totalStudents,
Passed: stats?.passed,
Failed: stats?.failed,
'Enrolled, no grade': stats?.totalEnrolledNoGrade,
'Pass rate': calculatePercentage(stats?.passed ?? 0, stats?.totalStudents ?? 1),
'Attempts total': stats?.attempts,
'Attempts per student': stats?.perStudent,
'Passed%': calculatePercentage(stats?.passedOfPopulation ?? 0, 100),
'Attempted%': calculatePercentage(stats?.triedOfPopulation ?? 0, 100),
}))
return [courseStatistics, excelData]
@@ -60,7 +77,7 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
const columns = useMemo(
() => [
columnHelper.accessor('name', {
maxSize: '32em', // Hand picked magic number
maxSize: 400, // quick and dirty
header: 'Name',
cell: ({ row }) => {
const name = getTextIn(row.original.name)
@@ -83,7 +100,7 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
const linkComponent =
row.originalSubRows === undefined ? (
<Stack flexDirection="row" sx={{ m: 'auto', mr: '0' }}>
<CourseFilterToggle courseCode={code} courseName={name} />
<CourseFilterToggle courseCode={code} courseName={name ?? ''} />
{!onlyIamRights ? (
<Link
to={`/coursestatistics?courseCodes=["${encodeURIComponent(code)}"]&separate=false&unifyOpenUniCourses=false`}
@@ -112,7 +129,8 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
</Stack>
)
},
sortingFn: (rowA, rowB) => getTextIn(rowA.original.name)?.localeCompare(getTextIn(rowB.original.name)) ?? 0,
sortingFn: (rowA, rowB) =>
getTextIn(rowA.original.name)?.localeCompare(getTextIn(rowB.original.name) ?? '') ?? 0,
}),
columnHelper.accessor('code', { header: 'Code' }),
columnHelper.group({
@@ -144,12 +162,18 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
columnHelper.accessor(() => undefined, {
header: 'Pass rate',
cell: ({ row }) =>
row.original.stats
? calculatePercentage(row.original.stats?.passed, row.original.stats?.totalStudents)
row.original.stats?.totalStudents
? calculatePercentage(row.original.stats?.passed ?? 0, row.original.stats?.totalStudents)
: null,
sortingFn: (rowA, rowB) => {
const a = rowA.original.stats ? rowA.original.stats.passed / rowA.original.stats.totalStudents : 0
const b = rowB.original.stats ? rowB.original.stats.passed / rowB.original.stats.totalStudents : 0
const a =
rowA.original.stats?.passed && rowA.original.stats?.totalStudents
? rowA.original.stats.passed / rowA.original.stats.totalStudents
: 0
const b =
rowB.original.stats?.passed && rowB.original.stats?.totalStudents
? rowB.original.stats.passed / rowB.original.stats.totalStudents
: 0
return a - b
},
@@ -179,14 +203,18 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
id: 'passedPercentage',
header: 'Passed',
cell: ({ row }) =>
row.original.stats ? calculatePercentage(row.original.stats?.passedOfPopulation, 100) : null,
row.original.stats?.passedOfPopulation
? calculatePercentage(row.original.stats?.passedOfPopulation, 100)
: null,
sortingFn: (rowA, rowB) =>
(rowA.original.stats?.passedOfPopulation ?? 0) - (rowB.original.stats?.passedOfPopulation ?? 0),
}),
columnHelper.accessor(() => undefined, {
header: 'Attempted',
cell: ({ row }) =>
row.original.stats ? calculatePercentage(row.original.stats?.triedOfPopulation, 100) : null,
row.original.stats?.triedOfPopulation
? calculatePercentage(row.original.stats?.triedOfPopulation, 100)
: null,
sortingFn: (rowA, rowB) =>
(rowA.original.stats?.triedOfPopulation ?? 0) - (rowB.original.stats?.triedOfPopulation ?? 0),
}),
@@ -206,14 +234,26 @@ export const PassFailEnrollments = ({ onlyIamRights, courseStatistics }) => {
state: { expanded },
}
if (!data.length) return null
// HACK: Courses of class "curriculum" view doesn't render otherwise
// Also if this is removed and "Show all ..." returns no fields,
// the toggle Course <-> Module get's removed.
if (courseTableMode === 'curriculum' && !data.length) return null
return (
<OodiTable
columns={columns}
data={data}
options={tableOptions}
toolbarContent={<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />}
toolbarContent={
<>
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
{courseTableMode === 'all' &&
showModules !== undefined &&
setShowModules !== undefined && ( // Check if optional fields are set
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
)}
</>
}
/>
)
}

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