Compare commits

...
97 Commits
Author SHA1 Message Date
joonas-a f8c665ca5e [Date filter] fix needing to click twice 2025-09-26 14:45:37 +03:00
joonas-a 5abac85e81 [Filters] Make setting filter programmatically open the relevant card 2025-09-26 14:21:32 +03:00
joonas-a cf15dfab72 [Updater] Use most recent iteration of each study track, instead of the oldest 2025-09-25 17:27:53 +03:00
joonas-a 9ca60cacc5 [Class statistics] Add button to copy all student numbers to clipboard 2025-09-25 14:16:47 +03:00
joonas-a 8747161f0c [Filters/student number] Enable user input and allow pasting many at once 2025-09-24 15:36:51 +03:00
joonas-a 03c13cedd2 [Filters] Finishing touches fix padding etc 2025-09-23 17:48:53 +03:00
joonas-a 086d1f89f4 [E2E] Fix course filter tests 2025-09-23 17:23:49 +03:00
joonas-a 3335891b5a [Cypress] Add command to select from FilterSearchableSelect (mui autocomplete) 2025-09-23 17:17:44 +03:00
joonas-a 1b36ac9dfe [Filters] Show substituted courses in course filter cards 2025-09-23 16:23:37 +03:00
joonas-a 60d08ed9eb [Filters] Make course filter searchable 2025-09-23 15:21:30 +03:00
joonas-a dc51480e27 [Eslint] fix error 2025-09-19 13:22:56 +03:00
joonas-a aa30506df2 [CI] Remove invalid container declaration 2025-09-19 13:16:39 +03:00
joonas-a c929cd2325 [Meta] delete node-actionlint as abandoned and outdated 2025-09-19 12:01:11 +03:00
joonas-a 0fef278dd8 [CI] retry fix e2e 2025-09-19 11:59:45 +03:00
joonas-a 1e9cab4b46 [CI] require tests for staging and fix reusable workflow call 2025-09-19 11:42:28 +03:00
joonas-a 2beea33a4c [CI/e2e] Try to up node version 2025-09-19 11:32:54 +03:00
joonas-a 6d0acb4091 [Chore] Silence eslint for types with potential use cases 2025-09-18 15:51:34 +03:00
joonas-a 6d8d41dbe1 [Chore] Cleanup 2025-09-18 15:40:01 +03:00
joonas-a e3f6c4a244 [Backend] explicitly declare model fields to prevent shadowing 2025-09-18 15:31:18 +03:00
joonas-a 818b9e4e1c [Infra] Revert RHEL 10 -> 9 for compatibility 2025-09-18 11:20:42 +03:00
joonas-a b8bbe7634e [CI] Deploy to staging with failing tests 2025-09-18 10:48:17 +03:00
joonas-a 6e8e63b38d [Fix] Add safeguard for missing user 2025-09-17 17:28:54 +03:00
joonas-aandSanteri Rajamäki 04deef0c2b [Course filter] Show course code in addition to its name in card 2025-09-16 15:36:02 +03:00
joonas-aandSanteri Rajamäki a8e75754cd [Class statistics] Re-introduce summary rows in modules tab 2025-09-16 15:36:02 +03:00
joonas-aandSanteri Rajamäki c350adac64 [OodiTable] Implement aggregation/summary rows 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 59b79d26d2 [META] Update gelf-pro override on sis-updater-scheduler 2025-09-16 15:36:02 +03:00
Santeri Rajamäki cd8f5b11b1 [META] Run npm update on sis-updater-scheduler 2025-09-16 15:36:02 +03:00
Santeri Rajamäki b8e110e39a [META] Run npm update on sis-updater-worker 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 95b60ed378 [META] Run npm update on backend 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 9dac180b95 [META] Update typescript to version 5.9.2 2025-09-16 15:36:02 +03:00
Santeri Rajamäki 908985410b [META] Update unfuck-spa-shibboleth-session to version 5.0.0 2025-09-16 15:34:33 +03:00
Santeri Rajamäki 9f686fafd5 [META] Run npm update on frontend 2025-09-12 11:20:50 +03:00
Santeri Rajamäki 59963e4961 [META] Update xlsx blob to version 0.20.3 2025-09-12 11:12:39 +03:00
Santeri Rajamäki 1f1dc624e4 [META] Update unfuck-spa-shibboleth-session to version 4.1.1 2025-09-12 11:08:21 +03:00
Santeri Rajamäki 3e374f29d0 [META] Run npm update on root package 2025-09-12 10:29:58 +03:00
santeri0200andGitHub 9c060874eb Merge pull request #4884 from UniversityOfHelsinkiCS/renovate/npm-vite-vulnerability
Update dependency vite to v7.0.7 [SECURITY]
2025-09-12 10:23:26 +03:00
renovate[bot]andGitHub 3a04397e5d Update dependency vite to v7.0.7 [SECURITY] 2025-09-09 21:34:40 +00:00
Santeri Rajamäki 6ae174a1c1 [Filters] Add substitution information to course filter 2025-09-04 16:03:52 +03:00
santeri0200andGitHub 738ff03d2c Merge pull request #4877 from UniversityOfHelsinkiCS/renovate/actions-setup-node-5.x
Update actions/setup-node action to v5
2025-09-04 11:14:31 +03:00
Santeri Rajamäki b11d45da60 [Filters] Reshaped date range selector 2025-09-04 11:13:38 +03:00
renovate[bot]andGitHub e2bc22fdab Update actions/setup-node action to v5 2025-09-04 05:24:55 +00:00
Santeri Rajamäki 1433e949bc [Filters] Animate collapse margin transition 2025-09-03 13:36:04 +03:00
Santeri Rajamäki 7040d78de8 [Filters] Outline filter contents 2025-09-03 13:31:17 +03:00
Santeri Rajamäki 79ff2887fb Upgrade Redhat UBI9 to UBI10 2025-09-02 11:35:40 +03:00
Santeri Rajamäki eac2789951 Added engine versions 2025-09-02 11:35:12 +03:00
santeri0200andGitHub 9688747773 Merge pull request #4876 from UniversityOfHelsinkiCS/OOD-94-redis-port
[FD configuration] Added REDIS_PORT to updater and scheduler in fd docker compose file
2025-09-02 11:34:18 +03:00
Santeri Rajamäki 27616be349 [Frontend] Convert sisu url parsing to a hook 2025-09-02 11:33:44 +03:00
santeri0200andGitHub d330b84d1c Merge pull request #4875 from UniversityOfHelsinkiCS/OOD-90-config-json
[FRONTEND] OOD-90: fetching frontend config from json
2025-09-02 11:33:03 +03:00
Matti Räsänen 9275058e31 [FD configuration] Added REDIS_PORT to updater and scheduler in fd docker compose file 2025-09-02 07:24:46 +03:00
santeri0200andGitHub de8a090898 Merge pull request #4874 from UniversityOfHelsinkiCS/OOD-92
[FRONTEND] Hide show population button in course stats page
2025-09-01 17:25:08 +03:00
Jan Rundt ae393fa857 OOD-90: fetching frontend config from json 2025-09-01 15:32:41 +03:00
Matti Räsänen e46faa0904 OOD-92: Added service provider check to SHOW POPULATION button 2025-09-01 15:04:20 +03:00
Santeri Rajamäki ffa0120bd7 [E2E] Credit statistics logic has always been faulty 2025-09-01 14:49:08 +03:00
Santeri Rajamäki ed8f282cf5 [Frontend] Add alert element to the frontpage 2025-09-01 13:49:12 +03:00
Santeri Rajamäki f0e9a66a7e [Backend] Decouple tags and credits from statisticsOf mankeli 2025-09-01 13:47:04 +03:00
Santeri Rajamäki 0e8c3696eb [GeneralTab] return null if TVEX or transferSource is undefined 2025-09-01 13:40:14 +03:00
Santeri Rajamäki db986ee0fb Extract student option name from the single attribute object 2025-09-01 13:37:15 +03:00
Santeri Rajamäki a35f9cc6d3 [ProgressTab] Move criteria fetching to progress tab 2025-09-01 10:57:33 +03:00
Santeri Rajamäki d60232b954 [Setup] Reword config missing warning message 2025-09-01 10:57:01 +03:00
Santeri Rajamäki 5ef73f0094 Upgade cypress to version 15
Closes #4870
2025-09-01 10:29:19 +03:00
Santeri Rajamäki 433570f124 [ProgressTab] Readd ablity to read progress data based on programme start year 2025-08-21 19:03:55 +03:00
Santeri Rajamäki 7bf8c54efd [GeneralTab] Fix credits since programme start 2025-08-21 17:02:28 +03:00
Santeri Rajamäki 81da962991 [Ooditable] Enable usage of maxSize attribute in columns 2025-08-21 15:44:51 +03:00
Santeri Rajamäki d3d2c2aaf2 Rework StudentInfoItem 2025-08-21 15:21:38 +03:00
Santeri Rajamäki 8fc2019358 [Ooditable] Simplify cell function 2025-08-21 15:08:15 +03:00
Santeri Rajamäki 7f253257b1 [Filters] Use defined key for graduatedFromProgramme default value 2025-08-21 14:48:27 +03:00
Santeri Rajamäki 43c866fa74 [Filters] Tags and study track use the same alert notification when empty 2025-08-21 14:44:31 +03:00
Santeri Rajamäki a5c842222f [Filters] Add default values to options list for rest of the FilterRadio users 2025-08-21 14:41:28 +03:00
Santeri Rajamäki 2e948c802c [Filters] Add possibility of controlled value to FilterRadio
Fix Transferred to programme filter state
2025-08-21 14:34:48 +03:00
Santeri Rajamäki da64f63d48 [Filters] Add disabled tooltip to FilterSelect 2025-08-21 13:20:19 +03:00
Santeri Rajamäki 800012ef05 [GeneralTab] Add columns to excel columns 2025-08-21 12:47:43 +03:00
Santeri Rajamäki b29d478c86 [Ooditable] Use header names instead of id to match columns 2025-08-20 13:39:26 +03:00
tfhuhtal d4506fe70f fix: add jami to db list 2025-08-19 17:39:22 +03:00
tfhuhtal 80159e46f0 feat: use selection only when choosing one dump to reset 2025-08-19 13:39:20 +03:00
tfhuhtal 3a834eb5f4 feat: update setup scripts to use s3 2025-08-19 11:59:24 +03:00
Santeri Rajamäki e56e553f25 Fix study track overflow 2025-08-18 18:13:30 +03:00
Santeri Rajamäki d6e32d2b19 [Filters] programmes move component definitions out of the filter card 2025-08-18 18:06:25 +03:00
Santeri Rajamäki 1413745a3a Fix react render warnings from filter components 2025-08-18 17:53:58 +03:00
Santeri Rajamäki e174dc5403 [E2E][Filters] Fix studyTrack dropdown type 2025-08-18 17:44:40 +03:00
Santeri Rajamäki 45cd8d82d9 Refactor dayjs imports to abide by x-import-modules rules 2025-08-18 17:30:06 +03:00
Santeri Rajamäki a10e3dbaf8 [Filters] Add multiple attribute to studyTracks filter
Suppress MUI warning about invalid empty strings when only empty string is available...
2025-08-18 17:15:53 +03:00
Santeri Rajamäki afa652db30 Upgrade stylelint-config-standard to version 39
Closes #4849
2025-08-18 16:35:12 +03:00
Santeri Rajamäki a1165c76dc Upgrade vite plugin-react-swc to version 4
Closes #4865
2025-08-18 16:32:52 +03:00
Santeri Rajamäki affea21c5a Upgrade download-artifact to action version 5
Closes #4853
2025-08-18 16:30:13 +03:00
santeri0200andGitHub 3e68dcd494 Merge pull request #4864 from UniversityOfHelsinkiCS/renovate/actions-checkout-5.x
Update actions/checkout action to v5
2025-08-18 16:18:44 +03:00
Santeri Rajamäki eb6837a079 [ModuleTab] Mankel some export data 2025-08-18 16:16:58 +03:00
renovate[bot]andGitHub a29e01b6d9 Update actions/checkout action to v5 2025-08-18 13:01:07 +00:00
Santeri Rajamäki 6916824d26 Upgrade sentry to version 10 2025-08-18 16:00:01 +03:00
Santeri Rajamäki 21127a7d6c [ModuleTab] Add value type (again) 2025-08-18 15:49:28 +03:00
Santeri Rajamäki af9f7c06e6 Fix lint 2025-08-18 15:47:56 +03:00
Santeri Rajamäki f882a22263 [ModuleTab] Replace MRT with Ooditable 2025-08-18 15:43:35 +03:00
Santeri Rajamäki 89ae035cf3 [Ooditable] Add VerticalHeader feature 2025-08-18 15:43:35 +03:00
joonas-a 1767790adb [Filters] Revisit Admission type 2025-08-18 14:34:23 +03:00
joonas-a a46ff9b570 [Population search form] reverse order of years in picker 2025-08-18 12:41:37 +03:00
joonas-a 25b6de49c0 Delete dead code 2025-08-15 18:37:54 +03:00
joonas-a c6903ebe25 [Faculties][By starting year] Fix excel export not connecting data with correct year 2025-08-15 18:23:37 +03:00
joonas-a 2d2c25b763 [Backend][Faculties] minor readability improvement 2025-08-15 18:21:50 +03:00
144 changed files with 6398 additions and 4460 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: 'Runs ESLint and Stylelint for frontend and backend'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 22
- name: 'Install dependencies'
+6 -6
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Get current time'
id: get_current_time
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -85,7 +85,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -119,7 +119,7 @@ jobs:
- release_backend
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Extract release version
id: extract_version
@@ -157,7 +157,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -178,7 +178,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -59,7 +59,7 @@ jobs:
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
+5 -5
View File
@@ -18,7 +18,7 @@ on:
jobs:
test:
uses: ./.github/workflows/test.yaml
uses: UniversityOfHelsinkiCS/oodikone/.github/workflows/test.yaml@master
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
@@ -29,7 +29,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
@@ -64,7 +64,7 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build backend image
id: build-image
@@ -99,7 +99,7 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
@@ -118,7 +118,7 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
with:
+15 -12
View File
@@ -16,7 +16,7 @@ jobs:
name: 'Lint packages'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
@@ -25,7 +25,7 @@ jobs:
name: 'Build backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build image and export it'
uses: docker/build-push-action@v6
@@ -50,7 +50,7 @@ jobs:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
@@ -76,7 +76,7 @@ jobs:
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -120,9 +120,9 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: backend-image
- name: 'Load built backend image'
@@ -148,7 +148,7 @@ jobs:
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: check_migrations
with:
@@ -157,7 +157,7 @@ jobs:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download backend image'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: backend-image
- name: 'Load built backend image'
@@ -176,7 +176,6 @@ jobs:
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
@@ -215,9 +214,13 @@ jobs:
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v5
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
@@ -236,7 +239,7 @@ jobs:
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
+8 -8
View File
@@ -20,8 +20,8 @@ jobs:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- name: 'Install dependencies'
@@ -33,7 +33,7 @@ jobs:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -54,7 +54,7 @@ jobs:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 'Build the image'
uses: docker/build-push-action@v6
@@ -79,7 +79,7 @@ jobs:
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 'Login to toska docker registry'
uses: docker/login-action@v3
with:
@@ -87,7 +87,7 @@ jobs:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
@@ -124,7 +124,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build worker image
id: build-image
@@ -154,7 +154,7 @@ jobs:
needs: test_updater
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build scheduler image
id: build-image
-1
View File
@@ -14,7 +14,6 @@ export default {
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
'docker-compose*': files => {
const composeFiles = file => {
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
+1 -1
View File
@@ -60,7 +60,7 @@ set_custom_select_prompt() {
# Run scripts and set the prompt
show_welcome
init_dirs
get_username
get_s3_config
set_custom_select_prompt
while true; do
+5 -5
View File
@@ -19,7 +19,7 @@ describe("Population statistics with a master's programme", { testIsolation: fal
it('Study track filter works', () => {
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 0)
cy.selectFromDropdown('studyTrackFilter', 0, true)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
@@ -179,11 +179,11 @@ describe.skip('Population Statistics', { testIsolation: false }, () => {
]
cy.cs('courseFilter-header').click()
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(9)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(7)
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
checkFilteredStudentCount(4)
@@ -449,11 +449,11 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
]
cy.cs('courseFilter-header').click()
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(7)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(6)
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(2)
+4 -4
View File
@@ -208,22 +208,22 @@ describe('Population statistics tests', () => {
const categories = [
{
selector: 'All students of the population',
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
size: 27,
},
{
selector: 'Muu',
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
size: 6,
},
]
+13
View File
@@ -122,6 +122,19 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
})
/**
* Select item from FilterSearchableSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
cy.cs(`${label}-selector`).click()
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
})
/**
* Set range selector values FilterRange.
* If the reset flag is set, the function tries to reset the original values.
+2
View File
@@ -120,6 +120,7 @@ services:
- redis
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
SECRET_TOKEN: dev
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
@@ -148,6 +149,7 @@ services:
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
REDIS_PORT: 6379
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
SIS_IMPORTER_DATABASE: importer-db
+444 -664
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,6 +1,9 @@
{
"name": "oodikone",
"description": "An application for analyzing university data.",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"cypress": "cypress",
"cypress:open": "cypress open",
@@ -9,7 +12,6 @@
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"stylelint": "stylelint",
"actionlint": "node-actionlint",
"prettier": "prettier",
"cli": "./cli.sh",
"oodikone": "./run.sh oodikone anon up --detach",
@@ -44,11 +46,11 @@
"@cypress/code-coverage": "^3.14.4",
"@eslint/js": "^9.28.0",
"@istanbuljs/schema": "^0.1.3",
"cypress": "^14.4.1",
"cypress": "^15.0.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^5.1.0",
"eslint-plugin-cypress": "^5.1.1",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
@@ -56,11 +58,10 @@
"globals": "^16.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"node-actionlint": "^1.2.2",
"nyc": "^17.1.0",
"prettier": "3.5.3",
"stylelint": "^16.20.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-standard": "^39.0.0",
"typescript-eslint": "^8.34.0"
},
"dependencies": {
+69 -30
View File
@@ -9,7 +9,8 @@
## Folders
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
USER_DATA_FILE="$DUMP_DIR/hyuserdata"
S3_CONFIG_FILE=~/.s3cfg
S3_BUCKET="s3://psyduck"
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
## Following the naming convention in docker-compose, these are names for database
@@ -18,14 +19,16 @@ KONE_DB_NAME="kone-db"
SIS_DB_NAME="sis-db"
SIS_IMPORTER_DB_NAME="sis-importer-db"
USER_DB_NAME="user-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME")
JAMI_DB_NAME="jami-db"
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME" "$JAMI_DB_NAME")
## Urls should be in same order as databases as both are iterated through by indexes.
KONE_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-kone-db.sql.gz"
SIS_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-sis-db.sql.gz"
SIS_IMPORTER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/importer.sql.gz"
USER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-user-db.sql.gz"
REAL_DUMP_URLS=("$KONE_DB_REAL_DUMP_URL" "$SIS_DB_REAL_DUMP_URL" "$SIS_IMPORTER_DB_REAL_DUMP_URL" "$USER_DB_REAL_DUMP_URL")
KONE_DB_S3_PATH="oodikone_kone"
SIS_DB_S3_PATH="oodikone_updater"
SIS_IMPORTER_DB_S3_PATH="sis_importer"
USER_DB_S3_PATH="oodikone_user"
JAMI_DB_S3_PATH="jami"
S3_PATHS=("$KONE_DB_S3_PATH" "$SIS_DB_S3_PATH" "$SIS_IMPORTER_DB_S3_PATH" "$USER_DB_S3_PATH" "$JAMI_DB_S3_PATH")
# Source utility functions
source "$PROJECT_ROOT"/scripts/utils.sh
@@ -55,9 +58,46 @@ retry() {
download_real_dump() {
local database=$1
local pannu_url=$2
local dump_destination="$DUMP_DIR/$database.sql.gz"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
local s3_path=$2
local selection_flag=$3
rm -f "$dump_destination"
if [ "$selection_flag" = "true" ]; then
local backup_files
mapfile -t backup_files < <(s3cmd -c "$S3_CONFIG_FILE" ls "$S3_BUCKET/$s3_path/" | sort -rk1,2 | awk '{print $4}')
if [ ${#backup_files[@]} -eq 0 ]; then
die "No backup files found in $S3_BUCKET bucket $s3_path path!"
fi
infomsg "Available backups:"
PS3="Select backup (or 0 to go back): "
select chosen_backup in "${backup_files[@]}"; do
if [ "$REPLY" = "0" ] || [ "$chosen_backup" = "Go back" ]; then
infomsg "Going back to previous menu."
break
elif [ -n "$chosen_backup" ]; then
infomsg "You selected: $chosen_backup"
local FILE_NAME
FILE_NAME=$(basename "$chosen_backup")
infomsg "Fetching the selected dump: $FILE_NAME"
s3cmd -c "$S3_CONFIG_FILE" get "$chosen_backup" "$dump_destination"
if [ ! -f "$dump_destination" ]; then
die "Download failed or file not found: $dump_destination"
fi
echo "$dump_destination"
break
else
warningmsg "Invalid selection. Please select a valid backup number."
fi
done
else
local current_date
current_date=$(date +"%Y%m%d")
s3cmd -c "$S3_CONFIG_FILE" get "s3://psyduck/${s3_path}/${s3_path}_${current_date}.sql.gz" "$dump_destination"
fi
}
check_if_postgres_is_ready() {
@@ -101,11 +141,12 @@ reset_databases() {
reset_jami_data() {
local database="jami-db"
local pannu_url="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/jami.sql.gz"
local dump_destination="$DUMP_DIR/$database.sql.gz"
local s3_path="jami"
infomsg "Removing old data"
infomsg "Downloading Jami data"
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
infomsg "Removing database and related volume"
docker volume rm oodikone_jami-data || warningmsg "This is okay, continuing"
@@ -122,13 +163,12 @@ reset_jami_data() {
}
reset_all_real_data() {
infomsg "Downloading real data dumps, asking for pannu password when needed"
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
local url="${REAL_DUMP_URLS[$i]}"
download_real_dump "$database" "$url"
local url="${S3_PATHS[$i]}"
download_real_dump "$database" "$url" false
done
reset_jami_data
reset_databases "${DATABASES[@]}"
}
@@ -152,28 +192,30 @@ reset_single_database() {
local database=""
local url=""
local selection_flag=true
while true; do
select opt in "${options[@]}"; do
case $opt in
"kone-db")
local database=$KONE_DB_NAME
local url=$KONE_DB_REAL_DUMP_URL
local url=$KONE_DB_S3_PATH
break 2;;
"sis-db")
local database=$SIS_DB_NAME
local url=$SIS_DB_REAL_DUMP_URL
local url=$SIS_DB_S3_PATH
break 2;;
"sis-importer-db")
local database=$SIS_IMPORTER_DB_NAME
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
local url=$SIS_IMPORTER_DB_S3_PATH
break 2;;
"user-db")
local database=$USER_DB_NAME
local url=$USER_DB_REAL_DUMP_URL
local url=$USER_DB_S3_PATH
break 2;;
"jami-db")
reset_jami_data
local database=$JAMI_DB_NAME
local url=$JAMI_DB_S3_PATH
break 2;;
"Return to main menu.")
break 2;;
@@ -186,7 +228,7 @@ reset_single_database() {
if [ -n "$database" ]; then
infomsg "Downloading $database dump"
download_real_dump $database $url
download_real_dump $database $url $selection_flag
reset_databases $database
fi
}
@@ -242,14 +284,11 @@ init_dirs() {
# If username is not set, get username from data file.
# Ask user to provide username, if username was not found from data file.
get_username() {
if [ ! -f "$USER_DATA_FILE" ]; then
warningmsg "University username is needed to get database dumps from toska servers, please enter it now:"
read -r username
echo "$username" > "$USER_DATA_FILE"
successmsg "Succesfully saved username for later usage."
get_s3_config() {
if [ ! -f "$S3_CONFIG_FILE" ]; then
warningmsg "No username provided, config file is required. The \".s3cfg\" config file can be found on toska/dokumentaatio repo."
return 0
fi
username=$(head -n 1 < "$USER_DATA_FILE")
infomsg "Using your university username - $username - for getting database dumps"
infomsg "Using your config $S3_CONFIG_FILE - for getting database dumps"
}
+1719 -1232
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -6,8 +6,8 @@
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^9.28.1",
"@sentry/profiling-node": "^9.28.1",
"@sentry/node": "^10.5.0",
"@sentry/profiling-node": "^10.5.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
@@ -40,7 +40,7 @@
"@types/node": "^22.15.31",
"@types/validator": "^13.15.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
},
"overrides": {
"winston-gelf-transporter": {
+22 -31
View File
@@ -11,16 +11,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type {
Credit,
Enrollment,
Organization,
SISStudyRight,
SISStudyRightElement,
Student,
Studyplan,
} from '@oodikone/shared/models'
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
import type { SISStudyRight } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
@@ -34,69 +25,69 @@ import { StudyrightExtentModel } from './studyrightExtent'
underscored: true,
tableName: 'sis_study_rights',
})
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
export class SISStudyRightModel extends Model implements SISStudyRight {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: SISStudyRight['id']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
facultyCode!: string
declare facultyCode: SISStudyRight['facultyCode']
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
organization!: Organization
declare organization: SISStudyRight['organization']
@HasMany(() => StudyplanModel)
studyPlans!: Studyplan[]
declare studyPlans: SISStudyRight['studyPlans']
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
studyRightElements!: SISStudyRightElement[]
declare studyRightElements: SISStudyRight['studyRightElements']
@Column(DataType.DATE)
startDate!: Date
declare startDate: SISStudyRight['startDate']
@Column(DataType.DATE)
endDate!: Date
declare endDate: SISStudyRight['endDate']
@Column(DataType.DATE)
studyStartDate!: Date
declare studyStartDate: SISStudyRight['studyStartDate']
@Column(DataType.BOOLEAN)
cancelled!: boolean
declare cancelled: SISStudyRight['cancelled']
@Column(DataType.STRING)
studentNumber!: string
declare studentNumber: SISStudyRight['studentNumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
student!: Student
declare student: SISStudyRight['student']
@ForeignKey(() => StudyrightExtentModel)
@Column(DataType.INTEGER)
extentCode!: ExtentCode
declare extentCode: SISStudyRight['extentCode']
@Column(DataType.STRING)
admissionType!: string
declare admissionType: SISStudyRight['admissionType']
@Column(DataType.JSONB)
semesterEnrollments!: SemesterEnrollment[] | null
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
credits!: Credit[]
declare credits: SISStudyRight['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
enrollments!: Enrollment[]
declare enrollments: SISStudyRight['enrollments']
@Column(DataType.BOOLEAN)
tvex!: boolean
declare tvex: SISStudyRight['tvex']
@Column(DataType.ARRAY(DataType.STRING))
expirationRuleUrns!: string[]
declare expirationRuleUrns: SISStudyRight['expirationRuleUrns']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: SISStudyRight['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: SISStudyRight['updatedAt']
}
@@ -10,57 +10,54 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
import type { Name, StudyTrack } from '@oodikone/shared/types'
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
import type { SISStudyRightElement } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
@Table({
underscored: true,
tableName: 'sis_study_right_elements',
})
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
export class SISStudyRightElementModel extends Model implements SISStudyRightElement {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: SISStudyRightElement['id']
@Column(DataType.DATE)
startDate!: Date
declare startDate: SISStudyRightElement['startDate']
@Column(DataType.DATE)
endDate!: Date
declare endDate: SISStudyRightElement['endDate']
@Column(DataType.BOOLEAN)
graduated!: boolean
declare graduated: SISStudyRightElement['graduated']
@Column(DataType.INTEGER)
phase!: Phase
declare phase: SISStudyRightElement['phase']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyRightId!: string
declare studyRightId: SISStudyRightElement['studyRightId']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
studyRight!: SISStudyRight
declare studyRight: SISStudyRightElement['studyRight']
@Column(DataType.STRING)
code!: string
declare code: SISStudyRightElement['code']
@Column(DataType.JSONB)
name!: Name
declare name: SISStudyRightElement['name']
@Column(DataType.JSONB)
studyTrack!: StudyTrack | null
declare studyTrack: SISStudyRightElement['studyTrack']
@Column(DataType.STRING)
degreeProgrammeType!: DegreeProgrammeType
declare degreeProgrammeType: SISStudyRightElement['degreeProgrammeType']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: SISStudyRightElement['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: SISStudyRightElement['updatedAt']
}
+18 -19
View File
@@ -12,8 +12,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import type { Course } from '@oodikone/shared/models'
import { CourseProviderModel } from './courseProvider'
import { CourseTypeModel } from './courseType'
@@ -26,57 +25,57 @@ import { OrganizationModel } from './organization'
modelName: 'course',
tableName: 'course',
})
export class CourseModel extends Model<Course> implements Course {
export class CourseModel extends Model implements Course {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Course['id']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
code!: string
declare code: Course['code']
@ForeignKey(() => CourseTypeModel)
@Column(DataType.STRING)
coursetypecode!: string
declare coursetypecode: Course['coursetypecode']
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
courseType!: CourseType
declare courseType: Course['courseType']
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
credits!: Credit[]
declare credits: Course['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
enrollments!: Enrollment[]
declare enrollments: Course['enrollments']
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
organizations!: Organization[]
declare organizations: Course['organizations']
@Column(DataType.BOOLEAN)
is_study_module!: boolean
declare is_study_module: Course['is_study_module']
@Column(DataType.JSONB)
name!: Name
declare name: Course['name']
@Column(DataType.DATE)
max_attainment_date!: Date
declare max_attainment_date: Course['max_attainment_date']
@Column(DataType.DATE)
min_attainment_date!: Date
declare min_attainment_date: Course['min_attainment_date']
@CreatedAt
@Column({ field: 'created_at', type: DataType.DATE })
createdAt!: Date
declare createdAt: Course['createdAt']
@UpdatedAt
@Column({ field: 'updated_at', type: DataType.DATE })
updatedAt!: Date
declare updatedAt: Course['updatedAt']
@Column(DataType.JSONB)
substitutions!: string[]
declare substitutions: Course['substitutions']
@Column(DataType.STRING)
course_unit_type!: string
declare course_unit_type: Course['course_unit_type']
@Column({ field: 'main_course_code', type: DataType.STRING })
mainCourseCode!: string
declare mainCourseCode: Course['mainCourseCode']
}
@@ -10,23 +10,23 @@ import { OrganizationModel } from './organization'
modelName: 'course_provider',
tableName: 'course_providers',
})
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
export class CourseProviderModel extends Model implements CourseProvider {
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
coursecode!: string
declare coursecode: CourseProvider['coursecode']
@Column(DataType.JSONB)
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
declare shares: CourseProvider['shares']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organizationcode!: string
declare organizationcode: CourseProvider['organizationcode']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CourseProvider['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CourseProvider['updatedAt']
}
+7 -8
View File
@@ -1,8 +1,7 @@
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Course, CourseType } from '@oodikone/shared/models'
import type { CourseType } from '@oodikone/shared/models'
import { Name } from '@oodikone/shared/types'
import { CourseModel } from './course'
@Table({
@@ -10,22 +9,22 @@ import { CourseModel } from './course'
modelName: 'course_type',
tableName: 'course_types',
})
export class CourseTypeModel extends Model<CourseType> implements CourseType {
export class CourseTypeModel extends Model implements CourseType {
@PrimaryKey
@Column(DataType.STRING)
coursetypecode!: string
declare coursetypecode: CourseType['coursetypecode']
@HasMany(() => CourseModel)
courses!: Course[]
declare courses: CourseType['courses']
@Column(DataType.JSONB)
name!: Required<Name>
declare name: CourseType['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CourseType['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CourseType['updatedAt']
}
+26 -26
View File
@@ -11,7 +11,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
import type { Credit } from '@oodikone/shared/models'
import { CreditTypeCode } from '@oodikone/shared/types'
import { CourseModel } from './course'
@@ -27,85 +27,85 @@ import { TeacherModel } from './teacher'
modelName: 'credit',
tableName: 'credit',
})
export class CreditModel extends Model<Credit> implements Credit {
export class CreditModel extends Model implements Credit {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Credit['id']
@Column(DataType.STRING)
grade!: string
declare grade: Credit['grade']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
student_studentnumber!: string
declare student_studentnumber: Credit['student_studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Credit['student']
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
semester!: Semester
declare semester: Credit['semester']
@Column(DataType.DOUBLE)
credits!: number
declare credits: Credit['credits']
@ForeignKey(() => CreditTypeModel)
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
declare credittypecode: Credit['credittypecode']
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
creditType!: CreditType
declare creditType: Credit['creditType']
@Column(DataType.DATE)
createdate!: Date
declare createdate: Credit['createdate']
@Column(DataType.DATE)
attainment_date!: Date
declare attainment_date: Credit['attainment_date']
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
teachers!: Teacher[]
declare teachers: Credit['teachers']
@Column(DataType.STRING)
course_code!: string
declare course_code: Credit['course_code']
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
course!: Course
declare course: Credit['course']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
declare course_id: Credit['course_id']
@Column(DataType.STRING)
semester_composite!: string
declare semester_composite: Credit['semester_composite']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Credit['semestercode']
@Column(DataType.BOOLEAN)
isStudyModule!: boolean
declare isStudyModule: Credit['isStudyModule']
@Column(DataType.STRING)
org!: string
declare org: Credit['org']
@Column(DataType.STRING)
language!: string
declare language: Credit['language']
@Column(DataType.BOOLEAN)
is_open!: boolean
declare is_open: Credit['is_open']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
studyright_id!: string
declare studyright_id: Credit['studyright_id']
@BelongsTo(() => SISStudyRightModel)
studyright!: SISStudyRight
declare studyright: Credit['studyright']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Credit['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Credit['updatedAt']
static passed: ({ credittypecode }: { credittypecode: CreditTypeCode }) => boolean
+5 -5
View File
@@ -10,20 +10,20 @@ import { TeacherModel } from './teacher'
modelName: 'credit_teacher',
tableName: 'credit_teachers',
})
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
export class CreditTeacherModel extends Model implements CreditTeacher {
@ForeignKey(() => CreditModel)
@Column(DataType.STRING)
credit_id!: string
declare credit_id: CreditTeacher['credit_id']
@ForeignKey(() => TeacherModel)
@Column(DataType.STRING)
teacher_id!: string
declare teacher_id: CreditTeacher['teacher_id']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CreditTeacher['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CreditTeacher['updatedAt']
}
+4 -5
View File
@@ -1,7 +1,6 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { CreditType } from '@oodikone/shared/models'
import { CreditTypeCode, Name } from '@oodikone/shared/types'
@Table({
underscored: true,
@@ -11,16 +10,16 @@ import { CreditTypeCode, Name } from '@oodikone/shared/types'
export class CreditTypeModel extends Model<CreditType> implements CreditType {
@PrimaryKey
@Column(DataType.INTEGER)
credittypecode!: CreditTypeCode
declare credittypecode: CreditType['credittypecode']
@Column(DataType.JSONB)
name!: Required<Name>
declare name: CreditType['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: CreditType['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: CreditType['updatedAt']
}
@@ -1,34 +1,33 @@
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
import type { CurriculumPeriod } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'curriculum_period',
tableName: 'curriculum_periods',
})
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
export class CurriculumPeriodModel extends Model implements CurriculumPeriod {
@Column({ type: DataType.STRING, primaryKey: true })
id!: string
declare id: CurriculumPeriod['id']
@Column({ type: DataType.JSONB, allowNull: false })
name!: Name
declare name: CurriculumPeriod['name']
@Column({ type: DataType.STRING, allowNull: false })
universityOrgId!: string
declare universityOrgId: CurriculumPeriod['universityOrgId']
@Column({ type: DataType.DATE, allowNull: false })
startDate!: Date
declare startDate: CurriculumPeriod['startDate']
@Column({ type: DataType.DATE, allowNull: false })
endDate!: Date
declare endDate: CurriculumPeriod['endDate']
@CreatedAt
@Column({ type: DataType.DATE, allowNull: false })
createdAt!: Date
declare createdAt: CurriculumPeriod['createdAt']
@UpdatedAt
@Column({ type: DataType.DATE, allowNull: false })
updatedAt!: Date
declare updatedAt: CurriculumPeriod['updatedAt']
}
+18 -19
View File
@@ -10,8 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
import { EnrollmentState } from '@oodikone/shared/types'
import type { Enrollment } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { SemesterModel } from './semester'
@@ -23,59 +22,59 @@ import { StudentModel } from './student'
modelName: 'enrollment',
tableName: 'enrollment',
})
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
export class EnrollmentModel extends Model implements Enrollment {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Enrollment['id']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Enrollment['studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Enrollment['student']
@Column(DataType.STRING)
course_code!: string
declare course_code: Enrollment['course_code']
@Column(DataType.STRING)
state!: EnrollmentState
declare state: Enrollment['state']
@Column(DataType.DATE)
enrollment_date_time!: Date
declare enrollment_date_time: Enrollment['enrollment_date_time']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
course_id!: string
declare course_id: Enrollment['course_id']
@ForeignKey(() => SemesterModel)
@Column(DataType.STRING)
semester_composite!: string
declare semester_composite: Enrollment['semester_composite']
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
semester!: Semester
declare semester: Enrollment['semester']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Enrollment['semestercode']
@Column(DataType.BOOLEAN)
is_open!: boolean
declare is_open: Enrollment['is_open']
@ForeignKey(() => SISStudyRightModel)
@Column({ type: DataType.STRING, allowNull: true })
studyright_id!: string
declare studyright_id: Enrollment['studyright_id']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
studyright!: SISStudyRight
declare studyright: Enrollment['studyright']
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
course!: CourseModel
declare course: Enrollment['course']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Enrollment['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Enrollment['updatedAt']
}
@@ -1,28 +1,24 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
import type { CustomPopulationSearch } from '@oodikone/shared/models/kone'
@Table({
underscored: false,
modelName: 'custom_population_search',
tableName: 'custom_population_searches',
})
export class CustomPopulationSearchModel
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
implements CustomPopulationSearch
{
export class CustomPopulationSearchModel extends Model implements CustomPopulationSearch {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: CustomPopulationSearch['id']
@Column(DataType.BIGINT)
userId!: string
declare userId: CustomPopulationSearch['userId']
@Column(DataType.STRING)
name!: string
declare name: CustomPopulationSearch['name']
@Column(DataType.ARRAY(DataType.STRING))
students!: string[]
declare students: CustomPopulationSearch['students']
}
@@ -1,33 +1,32 @@
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
import type { ExcludedCourse } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'excluded_course',
tableName: 'excluded_courses',
})
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
export class ExcludedCourseModel extends Model implements ExcludedCourse {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
id!: CreationOptional<number>
declare id: ExcludedCourse['id']
@Column(DataType.STRING)
programme_code!: string
declare programme_code: ExcludedCourse['programme_code']
@Column(DataType.STRING)
course_code!: string
declare course_code: ExcludedCourse['course_code']
@Column(DataType.STRING)
curriculum_version!: string
declare curriculum_version: ExcludedCourse['curriculum_version']
@Default(DataType.NOW)
@Column(DataType.DATE)
createdAt!: CreationOptional<Date>
declare createdAt: ExcludedCourse['createdAt']
@Default(DataType.NOW)
@Column(DataType.DATE)
updatedAt!: CreationOptional<Date>
declare updatedAt: ExcludedCourse['updatedAt']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
import type { OpenUniPopulationSearch } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,21 +8,18 @@ import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@
modelName: 'open_uni_population_search',
tableName: 'open_uni_population_searches',
})
export class OpenUniPopulationSearchModel
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
implements OpenUniPopulationSearch
{
export class OpenUniPopulationSearchModel extends Model implements OpenUniPopulationSearch {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: OpenUniPopulationSearch['id']
@Column(DataType.BIGINT)
userId!: string
declare userId: OpenUniPopulationSearch['userId']
@Column(DataType.STRING)
name!: string
declare name: OpenUniPopulationSearch['name']
@Column(DataType.ARRAY(DataType.STRING))
courseCodes!: string[]
declare courseCodes: OpenUniPopulationSearch['courseCodes']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
import type { ProgressCriteria } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,50 +8,47 @@ import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/share
modelName: 'progress_criteria',
tableName: 'progress_criteria',
})
export class ProgressCriteriaModel
extends Model<ProgressCriteria, ProgressCriteriaCreation>
implements ProgressCriteria
{
export class ProgressCriteriaModel extends Model implements ProgressCriteria {
@PrimaryKey
@Column(DataType.STRING)
code!: string
declare code: ProgressCriteria['code']
@Column(DataType.STRING)
curriculumVersion!: CreationOptional<string>
declare curriculumVersion: ProgressCriteria['curriculumVersion']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearOne!: string[]
declare coursesYearOne: ProgressCriteria['coursesYearOne']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearTwo!: string[]
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearThree!: string[]
declare coursesYearThree: ProgressCriteria['coursesYearThree']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearFour!: string[]
declare coursesYearFour: ProgressCriteria['coursesYearFour']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearFive!: string[]
declare coursesYearFive: ProgressCriteria['coursesYearFive']
@Column(DataType.ARRAY(DataType.STRING))
coursesYearSix!: string[]
declare coursesYearSix: ProgressCriteria['coursesYearSix']
@Column(DataType.INTEGER)
creditsYearOne!: number
declare creditsYearOne: ProgressCriteria['creditsYearOne']
@Column(DataType.INTEGER)
creditsYearTwo!: number
declare creditsYearTwo: ProgressCriteria['creditsYearTwo']
@Column(DataType.INTEGER)
creditsYearThree!: number
declare creditsYearThree: ProgressCriteria['creditsYearThree']
@Column(DataType.INTEGER)
creditsYearFour!: number
declare creditsYearFour: ProgressCriteria['creditsYearFour']
@Column(DataType.INTEGER)
creditsYearFive!: number
declare creditsYearFive: ProgressCriteria['creditsYearFive']
@Column(DataType.INTEGER)
creditsYearSix!: number
declare creditsYearSix: ProgressCriteria['creditsYearSix']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
import type { StudyGuidanceGroupTag } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
@@ -9,22 +8,19 @@ import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodi
modelName: 'study_guidance_group_tag',
tableName: 'study_guidance_group_tags',
})
export class StudyGuidanceGroupTagModel
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
implements StudyGuidanceGroupTag
{
export class StudyGuidanceGroupTagModel extends Model implements StudyGuidanceGroupTag {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id?: CreationOptional<string>
declare id: StudyGuidanceGroupTag['id']
@Unique
@Column(DataType.STRING)
studyGuidanceGroupId!: string
declare studyGuidanceGroupId: StudyGuidanceGroupTag['studyGuidanceGroupId']
@Column(DataType.STRING)
studyProgramme!: string | null
declare studyProgramme: StudyGuidanceGroupTag['studyProgramme']
@Column(DataType.STRING)
year!: string | null
declare year: StudyGuidanceGroupTag['year']
}
@@ -8,11 +8,11 @@ import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
modelName: 'study_programme_pin',
tableName: 'study_programme_pins',
})
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
export class StudyProgrammePinModel extends Model implements StudyProgrammePin {
@PrimaryKey
@Column(DataType.INTEGER)
userId!: number
declare userId: StudyProgrammePin['userId']
@Column(DataType.ARRAY(DataType.STRING))
studyProgrammes!: string[]
declare studyProgrammes: StudyProgrammePin['studyProgrammes']
}
+8 -9
View File
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
import type { Tag } from '@oodikone/shared/models/kone'
import { TagStudentModel } from './tagStudent'
@@ -9,25 +8,25 @@ import { TagStudentModel } from './tagStudent'
modelName: 'tag',
tableName: 'tag',
})
export class TagModel extends Model<Tag, TagCreation> implements Tag {
export class TagModel extends Model implements Tag {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagStudentModel)
@Column(DataType.BIGINT)
tag_id!: CreationOptional<string>
declare tag_id: Tag['tag_id']
@BelongsTo(() => TagStudentModel)
tagStudent!: CreationOptional<TagStudent>
declare tagStudent: Tag['tagStudent']
@Column(DataType.STRING)
tagname!: string
declare tagname: Tag['tagname']
@Column(DataType.STRING)
studytrack!: string
declare studytrack: Tag['studytrack']
@Column(DataType.STRING)
year!: string | null
declare year: Tag['year']
@Column(DataType.BIGINT)
personal_user_id!: string | null
declare personal_user_id: Tag['personal_user_id']
}
@@ -1,7 +1,6 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
import type { TagStudent } from '@oodikone/shared/models/kone'
import { StudentModel } from '../student'
import { TagModel } from './tag'
@@ -10,18 +9,18 @@ import { TagModel } from './tag'
modelName: 'tag_student',
tableName: 'tag_student',
})
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
export class TagStudentModel extends Model implements TagStudent {
@PrimaryKey
@AutoIncrement
@ForeignKey(() => TagModel)
@Column(DataType.BIGINT)
tag_id!: string
declare tag_id: TagStudent['tag_id']
@PrimaryKey
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: TagStudent['studentnumber']
@BelongsTo(() => TagModel)
tag!: CreationOptional<Tag>
declare tag: TagStudent['tag']
}
+12 -13
View File
@@ -10,8 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import type { Organization } from '@oodikone/shared/models'
import { CourseModel } from './course'
import { CourseProviderModel } from './courseProvider'
@@ -23,37 +22,37 @@ import { SISStudyRightModel } from './SISStudyRight'
modelName: 'organization',
tableName: 'organization',
})
export class OrganizationModel extends Model<Organization> implements Organization {
export class OrganizationModel extends Model implements Organization {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Organization['id']
@Column(DataType.STRING)
code!: string
declare code: Organization['code']
@Column(DataType.JSONB)
name!: Name
declare name: Organization['name']
@Column(DataType.STRING)
parent_id!: string
declare parent_id: Organization['parent_id']
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
children!: Organization[]
declare children: Organization['children']
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
programmeModules!: ProgrammeModule[]
declare programmeModules: Organization['programmeModules']
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
SISStudyRights!: SISStudyRight[]
declare SISStudyRights: Organization['SISStudyRights']
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
courses!: CourseModel[]
declare courses: Organization['courses']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Organization['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Organization['updatedAt']
}
+21 -22
View File
@@ -12,7 +12,6 @@ import {
} from 'sequelize-typescript'
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
import { OrganizationModel } from './organization'
import { ProgrammeModuleChildModel } from './programmeModuleChild'
@@ -22,62 +21,62 @@ import { ProgrammeModuleChildModel } from './programmeModuleChild'
modelName: 'programme_module',
tableName: 'programme_modules',
})
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
export class ProgrammeModuleModel extends Model implements ProgrammeModule {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: ProgrammeModule['id']
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
parents!: ProgrammeModule[]
declare parents: ProgrammeModule[]
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
children!: ProgrammeModule[]
declare children: ProgrammeModule[]
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
declare organization: Organization
@Column(DataType.STRING)
group_id!: string
declare group_id: ProgrammeModule['group_id']
@Column(DataType.STRING)
code!: string
declare code: ProgrammeModule['code']
@Column(DataType.JSONB)
name!: Name
declare name: ProgrammeModule['name']
@Column(DataType.STRING)
type!: 'course' | 'module'
declare type: ProgrammeModule['type']
@Column(DataType.INTEGER)
order!: number
declare order: ProgrammeModule['order']
@Column(DataType.STRING)
studyLevel!: string
declare studyLevel: ProgrammeModule['studyLevel']
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
organization_id!: string
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
organization!: Organization
declare organization_id: ProgrammeModule['organization_id']
@Column(DataType.DATE)
valid_from!: Date
declare valid_from: ProgrammeModule['valid_from']
@Column(DataType.DATE)
valid_to!: Date
declare valid_to: ProgrammeModule['valid_to']
@Column(DataType.ARRAY(DataType.STRING))
curriculum_period_ids!: string[]
declare curriculum_period_ids: ProgrammeModule['curriculum_period_ids']
@Column(DataType.STRING)
degreeProgrammeType!: DegreeProgrammeType | null
declare degreeProgrammeType: ProgrammeModule['degreeProgrammeType']
@Column(DataType.INTEGER)
minimumCredits!: number | null
declare minimumCredits: ProgrammeModule['minimumCredits']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: ProgrammeModule['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: ProgrammeModule['updatedAt']
}
@@ -9,23 +9,23 @@ import { ProgrammeModuleModel } from './programmeModule'
modelName: 'programme_module_child',
tableName: 'programme_module_children',
})
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
export class ProgrammeModuleChildModel extends Model implements ProgrammeModuleChild {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
declare composite: ProgrammeModuleChild['composite']
@ForeignKey(() => ProgrammeModuleModel)
@Column(DataType.STRING)
parentId!: string
declare parentId: ProgrammeModuleChild['parentId']
@Column(DataType.STRING)
childId!: string
declare childId: ProgrammeModuleChild['childId']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: ProgrammeModuleChild['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: ProgrammeModuleChild['updatedAt']
}
+13 -14
View File
@@ -1,50 +1,49 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Semester } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'semester',
tableName: 'semesters',
})
export class SemesterModel extends Model<Semester> implements Semester {
export class SemesterModel extends Model implements Semester {
@PrimaryKey
@Column(DataType.STRING)
composite!: string
declare composite: Semester['composite']
@Column(DataType.INTEGER)
semestercode!: number
declare semestercode: Semester['semestercode']
@Column(DataType.JSONB)
name!: Name
declare name: Semester['name']
@Column(DataType.INTEGER)
startYear!: number
declare startYear: Semester['startYear']
@Column(DataType.DATE)
startdate!: Date
declare startdate: Semester['startdate']
@Column(DataType.DATE)
enddate!: Date
declare enddate: Semester['enddate']
@Column(DataType.INTEGER)
yearcode!: number
declare yearcode: Semester['yearcode']
@Column(DataType.STRING)
yearname!: string
declare yearname: Semester['yearname']
@Column(DataType.STRING)
org!: string
declare org: Semester['org']
@Column(DataType.INTEGER)
termIndex!: number
declare termIndex: Semester['termIndex']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Semester['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Semester['updatedAt']
}
+24 -26
View File
@@ -1,8 +1,6 @@
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
import type { Name } from '@oodikone/shared/types'
import { GenderCode } from '@oodikone/shared/types'
import type { Student } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { EnrollmentModel } from './enrollment'
@@ -14,73 +12,73 @@ import { StudyplanModel } from './studyplan'
modelName: 'student',
tableName: 'student',
})
export class StudentModel extends Model<Student> implements Student {
export class StudentModel extends Model implements Student {
@PrimaryKey
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Student['studentnumber']
@Column(DataType.STRING)
lastname!: string
declare lastname: Student['lastname']
@Column(DataType.STRING)
firstnames!: string
declare firstnames: Student['firstnames']
@Column(DataType.STRING)
abbreviatedname!: string
declare abbreviatedname: Student['abbreviatedname']
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
enrollments!: Enrollment[]
declare enrollments: Student['enrollments']
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
studyRights!: SISStudyRight[]
declare studyRights: Student['studyRights']
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
studyplans!: Studyplan[]
declare studyplans: Student['studyplans']
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
credits!: Credit[]
declare credits: Student['credits']
@Column(DataType.DATE)
birthdate!: Date
declare birthdate: Student['birthdate']
@Column(DataType.INTEGER)
creditcount!: number
declare creditcount: Student['creditcount']
@Column(DataType.DATE)
dateofuniversityenrollment!: Date
declare dateofuniversityenrollment: Student['dateofuniversityenrollment']
@Column(DataType.STRING)
email!: string
declare email: Student['email']
@Column(DataType.STRING)
secondary_email!: string
declare secondary_email: Student['secondary_email']
@Column(DataType.STRING)
national_student_number!: string
declare national_student_number: Student['national_student_number']
@Column(DataType.STRING)
phone_number!: string
declare phone_number: Student['phone_number']
@Column(DataType.JSONB)
citizenships!: Name[]
declare citizenships: Student['citizenships']
@Column(DataType.STRING)
gender_code!: GenderCode
declare gender_code: Student['gender_code']
@Column(DataType.STRING)
sis_person_id!: string
declare sis_person_id: Student['sis_person_id']
@Column(DataType.BOOLEAN)
hasPersonalIdentityCode!: boolean
declare hasPersonalIdentityCode: Student['hasPersonalIdentityCode']
@Column(DataType.STRING)
preferredLanguage!: string
declare preferredLanguage: Student['preferredLanguage']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Student['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Student['updatedAt']
}
+15 -15
View File
@@ -10,7 +10,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript'
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
import type { Studyplan } from '@oodikone/shared/models'
import { SISStudyRightModel } from './SISStudyRight'
import { StudentModel } from './student'
@@ -20,48 +20,48 @@ import { StudentModel } from './student'
modelName: 'studyplan',
tableName: 'studyplan',
})
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
export class StudyplanModel extends Model implements Studyplan {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Studyplan['id']
@ForeignKey(() => StudentModel)
@Column(DataType.STRING)
studentnumber!: string
declare studentnumber: Studyplan['studentnumber']
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
student!: Student
declare student: Studyplan['student']
@ForeignKey(() => SISStudyRightModel)
@Column(DataType.STRING)
sis_study_right_id!: string
declare sis_study_right_id: Studyplan['sis_study_right_id']
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
studyRight!: SISStudyRight
declare studyRight: Studyplan['studyRight']
@Column(DataType.STRING)
programme_code!: string
declare programme_code: Studyplan['programme_code']
@Column(DataType.ARRAY(DataType.STRING))
included_courses!: string[]
declare included_courses: Studyplan['included_courses']
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
includedModules!: string[]
declare includedModules: Studyplan['includedModules']
@Column(DataType.STRING)
sisu_id!: string
declare sisu_id: Studyplan['sisu_id']
@Column(DataType.DOUBLE)
completed_credits!: number
declare completed_credits: Studyplan['completed_credits']
@Column(DataType.STRING)
curriculum_period_id!: string
declare curriculum_period_id: Studyplan['curriculum_period_id']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Studyplan['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Studyplan['updatedAt']
}
@@ -1,26 +1,25 @@
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { StudyrightExtent } from '@oodikone/shared/models'
import { ExtentCode } from '@oodikone/shared/types'
@Table({
underscored: true,
modelName: 'studyright_extent',
tableName: 'studyright_extents',
})
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
export class StudyrightExtentModel extends Model implements StudyrightExtent {
@PrimaryKey
@Column(DataType.INTEGER)
extentcode!: ExtentCode
declare extentcode: StudyrightExtent['extentcode']
@Column(DataType.STRING)
name!: string
declare name: StudyrightExtent['name']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: StudyrightExtent['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: StudyrightExtent['updatedAt']
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
import type { Teacher, Credit } from '@oodikone/shared/models'
import type { Teacher } from '@oodikone/shared/models'
import { CreditModel } from './credit'
import { CreditTeacherModel } from './creditTeacher'
@@ -10,22 +10,22 @@ import { CreditTeacherModel } from './creditTeacher'
modelName: 'teacher',
tableName: 'teacher',
})
export class TeacherModel extends Model<Teacher> implements Teacher {
export class TeacherModel extends Model implements Teacher {
@PrimaryKey
@Column(DataType.STRING)
id!: string
declare id: Teacher['id']
@Column(DataType.STRING)
name!: string
declare name: Teacher['name']
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
credits!: Credit[]
declare credits: Teacher['credits']
@CreatedAt
@Column(DataType.DATE)
createdAt!: Date
declare createdAt: Teacher['createdAt']
@UpdatedAt
@Column(DataType.DATE)
updatedAt!: Date
declare updatedAt: Teacher['updatedAt']
}
+9 -13
View File
@@ -1,11 +1,7 @@
import { CreationOptional } from 'sequelize'
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import type { Language } from '@oodikone/shared/language'
import type { User, UserCreation } from '@oodikone/shared/models/user'
import type { Role } from '@oodikone/shared/types'
@Table({
underscored: true,
timestamps: false,
@@ -16,33 +12,33 @@ export class UserModel extends Model<User, UserCreation> implements User {
@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
id!: CreationOptional<string>
declare id: User['id']
@Column(DataType.STRING)
fullName!: string
declare fullName: User['fullName']
@Unique
@Column(DataType.STRING)
username!: string
declare username: User['username']
@Column(DataType.STRING)
email!: string
declare email: User['email']
@Default('fi')
@Column(DataType.STRING)
language!: CreationOptional<Language>
declare language: User['language']
@Column(DataType.STRING)
sisuPersonId!: string
declare sisuPersonId: User['sisuPersonId']
@Column(DataType.DATE)
lastLogin!: Date
declare lastLogin: User['lastLogin']
@Default([])
@Column(DataType.ARRAY(DataType.STRING))
roles!: CreationOptional<Role[]>
declare roles: User['roles']
@Default([])
@Column(DataType.ARRAY(DataType.STRING))
programmeRights!: CreationOptional<string[]>
declare programmeRights: User['programmeRights']
}
+1 -2
View File
@@ -207,8 +207,7 @@ router.get(
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
if (updatedStats) {
const redisResponse = await setFacultyStudentStats(updatedStats, specialGroups, graduated)
if (!redisResponse) return res.json(updatedStats)
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
}
return res.json(updatedStats)
}
@@ -129,7 +129,7 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
return formattedEnrollments
}
const getStudents = async (studentNumbers: string[]) => {
const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses[]> => {
const students = await StudentModel.findAll({
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
where: {
@@ -147,9 +147,9 @@ const getStudents = async (studentNumbers: string[]) => {
})
return students.map(student => {
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
const { studyplans, ...rest }: StudentWithStudyplanNested = student.toJSON()
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
return { ...rest, coursesInStudyPlan } as StudentWithCourses
return { ...rest, coursesInStudyPlan }
})
}
@@ -8,14 +8,18 @@ import type { ProgrammesOfOrganization } from './faculty'
type StudyTrackStats = Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>
const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats[]) => {
const facultyTableStats: Record<string, Array<string | number>> = {}
const facultyTableStats: Record<string, (string | number)[]> = {}
const facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>> = {}
const programmeTableStats: Record<string, Record<string, Array<string | number>>> = {}
const programmeTableStats: Record<string, Record<string, (string | number)[]>> = {}
for (const programmeCode of programmeCodes) {
const statsForProgramme = stats.find(programmeStats => programmeStats.id === programmeCode)?.mainStatsByTrack?.[
programmeCode
]
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === programmeCode)
?.otherCountriesCount?.[programmeCode]
for (const prog of programmeCodes) {
const statsForProgramme = stats.find(programmeStats => programmeStats.id === prog)?.mainStatsByTrack?.[prog]
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === prog)
?.otherCountriesCount?.[prog]
if (!statsForProgramme) continue
for (const yearStats of statsForProgramme) {
@@ -34,11 +38,11 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
}
}
if (!(prog in programmeTableStats)) {
programmeTableStats[prog] = {}
if (!(programmeCode in programmeTableStats)) {
programmeTableStats[programmeCode] = {}
}
if (!(yearStats[0] in programmeTableStats[prog])) {
programmeTableStats[prog][yearStats[0]] = yearStats.slice(1)
if (!(yearStats[0] in programmeTableStats[programmeCode])) {
programmeTableStats[programmeCode][yearStats[0]] = yearStats.slice(1)
}
}
@@ -47,7 +51,7 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
if (!(year in facultyTableStatsExtra)) {
facultyTableStatsExtra[year] = {}
}
facultyTableStatsExtra[year][prog] = countryStats
facultyTableStatsExtra[year][programmeCode] = countryStats
}
}
@@ -1,9 +1,10 @@
import { SISStudyRight } from '@oodikone/shared/models'
import { FormattedStudent, CriteriaYear, Name, ProgressCriteria } from '@oodikone/shared/types'
import { StudentData, StudentTags } from '@oodikone/shared/types/studentData'
import { dateYearsFromNow, dateDaysFromNow, dayInMilliseconds } from '@oodikone/shared/util/datetime'
import { CreditModel } from '../../models'
import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers'
import type { StudentStudyPlan, StudentStudyRight, TaggetStudentData } from './getStudentData'
import type { StudentStudyPlan, StudentStudyRight } from './getStudentData'
import { getCurriculumVersion } from './shared'
import type { AnonymousCredit, AnonymousEnrollment } from './statisticsOf'
@@ -131,9 +132,12 @@ const getProgressCriteria = (
})
yearMap.forEach(([yearToAdd, criteriaYear]) => {
criteriaChecked[yearToAdd].totalSatisfied +=
Object.values(criteriaChecked[yearToAdd].coursesSatisfied).filter(course => !!course).length ?? 0
// UPDATE CREDIT CRITERIA
if (!!criteria.credits[criteriaYear] && criteria.credits[criteriaYear] < academicYears[yearToAdd]) {
criteriaChecked[yearToAdd].credits = true
criteriaChecked[yearToAdd].totalSatisfied += 1
}
})
@@ -143,7 +147,8 @@ const getProgressCriteria = (
export const formatStudentForAPI = (
code: string,
startDate: string,
student: TaggetStudentData,
student: StudentData,
tags: StudentTags[],
credits: AnonymousCredit[],
enrollments: AnonymousEnrollment[],
optionData: Name | undefined,
@@ -187,15 +192,15 @@ export const formatStudentForAPI = (
secondaryEmail: student.secondary_email,
phoneNumber: student.phone_number,
updatedAt: student.updatedAt,
tags: student.tags,
studyrightStart: startDate,
option: optionData ? { name: optionData } : null,
option: optionData ?? null,
birthdate: student.birthdate,
sis_person_id: student.sis_person_id,
citizenships: student.citizenships,
criteriaProgress: getProgressCriteria(startDate, criteria, credits, hops),
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
tags,
transferredStudyright,
transferSource,
studyRights,
@@ -3,7 +3,6 @@ import { Op } from 'sequelize'
import { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
import { EnrollmentState } from '@oodikone/shared/types'
import { serviceProvider } from '../../config'
import {
CreditModel,
EnrollmentModel,
@@ -18,10 +17,6 @@ export type StudentTags = TagStudent & {
tag: Pick<Tag, 'tag_id' | 'tagname' | 'personal_user_id'>
}
export type TaggetStudentData = StudentData & {
tags: StudentTags[]
}
export const getStudentTags = async (studyRights: string[], studentNumbers: string[], userId: string) => {
const studentTags = await TagStudentModel.findAll({
attributes: ['tag_id', 'studentnumber'],
@@ -190,28 +185,8 @@ export type StudentCredit = Pick<
| 'studyright_id'
>
export const getCredits = async (
studentNumbers: string[],
studyRights: string[],
attainmentDateFrom: string
): Promise<Array<StudentCredit>> => {
const includedCoursesInStudyPlans: Array<Pick<Studyplan, 'included_courses'>> = await StudyplanModel.findAll({
attributes: ['included_courses'],
where: {
studentnumber: { [Op.in]: studentNumbers },
},
raw: true,
})
const uniqueCourses = new Set(includedCoursesInStudyPlans.flatMap(plan => plan.included_courses))
// takes into account possible progress tests taken earlier than the start date
const courseCodes =
['320001', 'MH30_001'].includes(studyRights[0]) && serviceProvider !== 'fd'
? [...uniqueCourses, '375063', '339101']
: [...uniqueCourses]
return CreditModel.findAll({
export const getCredits = async (studentNumbers: string[]): Promise<Array<StudentCredit>> =>
CreditModel.findAll({
attributes: [
'grade',
'credits',
@@ -225,8 +200,6 @@ export const getCredits = async (
],
where: {
student_studentnumber: { [Op.in]: studentNumbers },
[Op.or]: [{ attainment_date: { [Op.gte]: attainmentDateFrom } }, { course_code: courseCodes }],
},
raw: true,
})
}
@@ -98,9 +98,9 @@ export const getOptionsForStudents = async (
)
}
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name'>>> =>
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>> =>
CourseModel.findAll({
attributes: ['code', 'name'],
attributes: ['code', 'name', 'substitutions'],
where: {
code: { [Op.in]: courses },
},
@@ -267,13 +267,14 @@ export const parseCourseData = async (
const courses = await getCourses(Array.from(coursestats.keys()))
const courseMap = new Map(courses.map(({ code, name }) => [code, name]))
const substitutionMap = new Map(courses.map(({ code, substitutions }) => [code, substitutions]))
return Array.from(coursestats.entries()).map(([code, { attempts, enrollments, grades, students, stats }]) => {
return {
course: {
code,
name: courseMap.get(code) ?? { en: code },
substitutions: [],
substitutions: substitutionMap.get(code) ?? [],
},
attempts,
enrollments: {
@@ -5,10 +5,10 @@ import { formatStudentForAPI } from './formatStatisticsForApi'
import {
type StudentEnrollment,
type StudentCredit,
type StudentTags,
getStudents,
getEnrollments,
getCredits,
StudentTags,
} from './getStudentData'
import { getOptionsForStudents, parseCourseData } from './shared'
@@ -37,7 +37,7 @@ export const statisticsOf = async (
const [enrollments, credits, students] = await Promise.all([
getEnrollments(studentNumbers, mockedStartDate),
getCredits(studentNumbers, studyRights, mockedStartDate),
getCredits(studentNumbers),
getStudents(studentNumbers),
])
@@ -74,10 +74,11 @@ export const statisticsOf = async (
formatStudentForAPI(
code,
mockedStartDate,
{ ...student, tags: tagList[student.studentnumber] },
creditsByStudent.get(student.studentnumber) ?? [],
enrollmentsByStudent.get(student.studentnumber) ?? [],
optionData?.[student.studentnumber],
student,
tagList[student.studentnumber],
creditsByStudent.get(student.studentnumber)!,
enrollmentsByStudent.get(student.studentnumber)!,
optionData[student.studentnumber],
criteria
)
),
@@ -116,12 +116,9 @@ const combineStats = (
for (const year of Object.keys(yearlyStats)) {
for (const programmeOrStudyTrack of Object.keys(yearlyStats[year])) {
const yearStats = yearlyStats[year][programmeOrStudyTrack]
if (!mainStatsByYear[year]) {
mainStatsByYear[year] = []
}
if (!mainStatsByTrack[programmeOrStudyTrack]) {
mainStatsByTrack[programmeOrStudyTrack] = []
}
mainStatsByYear[year] ??= []
mainStatsByTrack[programmeOrStudyTrack] ??= []
const yearArray = [
yearStats.all,
@@ -197,13 +194,11 @@ const getMainStatsByTrackAndYear = async (
startedInProgramme: Date,
studyRightElement: SISStudyRightElement
) => {
if (!yearlyStats[year]) {
yearlyStats[year] = {}
}
if (!yearlyStats[year][programmeOrStudyTrack]) {
yearlyStats[year][programmeOrStudyTrack] = getEmptyYear()
}
yearlyStats[year] ??= {}
yearlyStats[year][programmeOrStudyTrack] ??= getEmptyYear()
yearlyStats[year][programmeOrStudyTrack].all += 1
/* Started studying col */
if (!hasTransferredToProgramme) {
const firstPresentAt = getDateOfFirstSemesterPresent(
studyRight.semesterEnrollments,
@@ -218,6 +213,8 @@ const getMainStatsByTrackAndYear = async (
}
}
}
/* Gender cols */
if (studyRight.student.gender_code === GenderCode.MALE) {
yearlyStats[year][programmeOrStudyTrack].male += 1
} else if (studyRight.student.gender_code === GenderCode.FEMALE) {
@@ -226,6 +223,7 @@ const getMainStatsByTrackAndYear = async (
yearlyStats[year][programmeOrStudyTrack].otherUnknown += 1
}
/* Current status cols */
const hasGraduated = studyRightElement.graduated
const hasGraduatedFromCombinedProgramme = combinedProgramme
? (studyRight.studyRightElements.find(element => element.code === combinedProgramme)?.graduated ?? false)
@@ -234,6 +232,7 @@ const getMainStatsByTrackAndYear = async (
if (hasGraduated) {
yearlyStats[year][programmeOrStudyTrack].graduated += 1
}
if (hasGraduatedFromCombinedProgramme) {
yearlyStats[year][programmeOrStudyTrack].graduatedCombinedProgramme += 1
} else if (combinedProgramme || !hasGraduated) {
@@ -249,6 +248,7 @@ const getMainStatsByTrackAndYear = async (
}
}
/* Citizenship cols */
const studentCitizenships = studyRight.student.citizenships
for (const citizenship of studentCitizenships) {
@@ -265,6 +265,7 @@ const getMainStatsByTrackAndYear = async (
}
}
/* Graduation statistics */
if (!hasGraduated) return
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
const [firstStudyRightElementWithSamePhase] = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
@@ -364,7 +365,7 @@ const getMainStatsByTrackAndYear = async (
studyRightElement
)
const studyTrack = studyRightElement.studyTrack?.code ?? null
const studyTrack = studyRightElement.studyTrack?.code
if (studyTrack) {
updateCounts(
startYear,
@@ -375,6 +376,7 @@ const getMainStatsByTrackAndYear = async (
studyRightElement
)
}
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
if (countAsBachelorMaster) {
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
@@ -391,12 +393,8 @@ const getMainStatsByTrackAndYear = async (
for (const year of Object.keys(yearlyStats)) {
if (year === 'Total') continue
for (const track of Object.keys(yearlyStats[year])) {
if (!yearlyStats.Total) {
yearlyStats.Total = {}
}
if (!yearlyStats.Total[track]) {
yearlyStats.Total[track] = getEmptyYear()
}
yearlyStats.Total ??= {}
yearlyStats.Total[track] ??= getEmptyYear()
for (const field of keysOf(yearlyStats[year][track])) {
if (field !== 'otherCountriesCounts') {
@@ -404,9 +402,7 @@ const getMainStatsByTrackAndYear = async (
continue
}
for (const country of Object.keys(yearlyStats[year][track].otherCountriesCounts)) {
if (!yearlyStats.Total[track].otherCountriesCounts[country]) {
yearlyStats.Total[track].otherCountriesCounts[country] = 0
}
yearlyStats.Total[track].otherCountriesCounts[country] ??= 0
yearlyStats.Total[track].otherCountriesCounts[country] +=
yearlyStats[year][track].otherCountriesCounts[country]
}
@@ -458,7 +454,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
studyProgramme: string
combinedProgramme?: string
settings: { graduated: boolean; specialGroups: boolean }
studyRightsOfProgramme: Array<SISStudyRight>
studyRightsOfProgramme: SISStudyRight[]
}) => {
const isAcademicYear = true
const includeYearsCombined = true
+1 -1
View File
@@ -155,7 +155,7 @@ const updateAccessGroups = async (
if (!userFromDb) {
throw new Error(`User ${username} not found`)
}
const currentAccessGroups = userFromDb.roles
const currentAccessGroups = userFromDb?.roles ?? []
const newAccessGroups = [
...(currentAccessGroups.includes('studyGuidanceGroups') || (await checkStudyGuidanceGroupsAccess(sisId))
+1152 -598
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -16,7 +16,7 @@
"@mui/x-date-pickers": "^7.29.4",
"@oodikone/shared": "file:../shared",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/browser": "^9.28.1",
"@sentry/browser": "^10.5.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
@@ -36,9 +36,9 @@
"scheduler": "^0.26.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^3.0.0-beta.2",
"unfuck-spa-shibboleth-session": "^4.1.0",
"unfuck-spa-shibboleth-session": "^5.0.0",
"use-context-selector": "^2.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/lodash": "^4.17.17",
@@ -46,8 +46,8 @@
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-highcharts": "^16.0.7",
"@vitejs/plugin-react-swc": "^3.10.2",
"typescript": "^5.8.3",
"@vitejs/plugin-react-swc": "^4.0.0",
"typescript": "^5.9.2",
"vite": "^7.0.0"
},
"overrides": {
@@ -0,0 +1 @@
{ "sisUrl": "https://sis-qa.funidata.fi" }
@@ -10,7 +10,7 @@ export const filterToolTips = {
startingYear:
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
studentNumber:
'Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.',
'Rajaa yksittäisiä opiskelijoita pois tilastoista (excluded student numbers) tai näytä **vain** määritellyt opiskelijat (allowed student numbers). Vain täsmälliset tarkasteltavan populaation joukosta löytyvät opiskelijanumerot toimivat. Suodatin tukee useamman opiskelijanumeron syöttämistä kerrallaan.',
studyRightStatus: `
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
+2 -2
View File
@@ -1,4 +1,4 @@
import dayjs from 'dayjs'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import { maxBy, orderBy, range } from 'lodash'
@@ -17,7 +17,7 @@ import {
} from '@oodikone/shared/types'
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
dayjs.extend(isBetween)
dayjsExtend(isBetween)
export const textAndDescriptionSearch = (dropDownOptions: any[], param: string) =>
dropDownOptions.filter(option =>
@@ -4,7 +4,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/LocalizationProvider'
import * as Sentry from '@sentry/browser'
import dayjs from 'dayjs'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import updateLocale from 'dayjs/plugin/updateLocale'
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
import { useEffect } from 'react'
@@ -37,7 +37,7 @@ HighCharts.setOptions({
},
})
dayjs.extend(updateLocale)
dayjsExtend(updateLocale)
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
@@ -1,10 +1,10 @@
import dayjs from 'dayjs'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import { Table, Progress } from 'semantic-ui-react'
import { languageAbbreviations } from '@/common'
dayjs.extend(isBetween)
dayjsExtend(isBetween)
export const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
let total = 0
@@ -9,7 +9,8 @@ export const useColumns = (): [string[], string[]] => {
const adminColumns = isAdmin ? ['extent', 'updatedAt'] : []
const excelOnlyColumns = ['email', 'phoneNumber']
/* REQUIRED TO ALWAYS BE VISIBLE */
const excelRequired = ['studentNumber', 'lastName', 'firstNames', 'email', 'phoneNumber']
return [
[
@@ -26,6 +27,6 @@ export const useColumns = (): [string[], string[]] => {
...nameColumns,
...adminColumns,
],
excelOnlyColumns,
excelRequired,
]
}
@@ -1,4 +1,4 @@
import dayjs from 'dayjs'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
@@ -12,9 +12,9 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { useGetSemestersQuery } from '@/redux/semesters'
import { SearchResultTable } from './SearchResultTable'
dayjs.extend(isBetween)
dayjs.extend(isSameOrBefore)
dayjs.extend(isSameOrAfter)
dayjsExtend(isBetween)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
export const findCorrectProgramme = (student, coursecodes, semesters, startDate, endDate, currentSemester) => {
let programme
@@ -13,7 +13,8 @@ export const useColumns = ({ programme }): [string[], string[]] => {
const adminColumns = isAdmin ? ['extent', 'updatedAt'] : []
const excelOnlyColumns = ['email', 'phoneNumber']
/* REQUIRED TO ALWAYS BE VISIBLE */
const excelRequired = ['studentNumber', 'lastName', 'firstNames', 'email', 'phoneNumber']
return [
[
@@ -38,6 +39,6 @@ export const useColumns = ({ programme }): [string[], string[]] => {
...populationWithProgrammeColumns,
...adminColumns,
],
excelOnlyColumns,
excelRequired,
]
}
@@ -2,6 +2,7 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { DatePicker } from '@mui/x-date-pickers/DatePicker/DatePicker'
@@ -30,56 +31,58 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
}
}
const createDateButton = (date, label) => (
<Button
key={label}
onClick={() => triggerOnChange(date)}
size="small"
sx={{
background: 'primary.light',
color: value?.isSame(date, 'day') ? 'primary.light' : 'text.primary',
}}
variant="outlined"
>
{label}
</Button>
const DateButton = ({ date, label }) => (
<span>
<Button
key={label}
// HACK: Use onMouseDown as it fires faster than onClick, bypassing the onBlur of whatever would be focused (the calendar) and prevents having to double click
onMouseDown={() => triggerOnChange(date)}
size="small"
sx={{
background: 'primary.light',
color: value?.isSame(date, 'day') ? 'primary.light' : 'text.primary',
}}
variant="outlined"
>
{label}
</Button>
</span>
)
const CustomPicker = _ => {
return (
<Box sx={{ padding: '0 0.5em' }}>
<Typography sx={{ paddingLeft: '0.3em', fontWeight: 'bold' }}>Semesters:</Typography>
<List
sx={{
overflowY: 'auto',
margin: 0,
padding: '0.5em 0',
maxHeight: '10em',
borderTop: '1px solid #f9f9f9',
}}
>
{Object.values(allSemesters)
.filter(({ startdate, enddate }) => {
const start = dayjs(startdate)
const end = dayjs(enddate)
const CustomPicker = _ => (
<Box sx={{ padding: '0 0.5em' }}>
<Typography sx={{ paddingLeft: '0.3em', fontWeight: 'bold' }}>Semesters:</Typography>
<List
sx={{
overflowY: 'auto',
padding: '0.5em 0',
maxHeight: '10em',
borderTop: '1px solid #f9f9f9',
}}
>
{Object.values(allSemesters)
.filter(({ startdate, enddate }) => {
const start = dayjs(startdate)
const end = dayjs(enddate)
return start.isBefore() && (!before || start.isBefore(before)) && (!after || end.isAfter(after))
})
.sort(({ startdate: a }, { startdate: b }) => new Date(b).getTime() - new Date(a).getTime())
.map(({ name, startdate, enddate }) => (
<ListItem
key={`${getTextIn(name)}-${Math.random()}`}
sx={{ padding: '0.2em 0.3em 0.2em', display: 'flex', alignItems: 'center' }}
>
<Typography sx={{ flexGrow: 1 }}>{getTextIn(name)}</Typography>
<Typography sx={{ margin: '0 0.25em' }}>{createDateButton(dayjs(startdate), 'Start')}</Typography>
<Typography>{createDateButton(dayjs(enddate).subtract(1, 'days'), 'End')}</Typography>
</ListItem>
))}
</List>
</Box>
)
}
return start.isBefore() && (!before || start.isBefore(before)) && (!after || end.isAfter(after))
})
.sort(({ startdate: a }, { startdate: b }) => new Date(b).getTime() - new Date(a).getTime())
.map(({ name, startdate, enddate }) => (
<ListItem
key={`${getTextIn(name)}-${Math.random()}`}
sx={{ padding: '0.2em 0.3em', display: 'flex', alignItems: 'center' }}
>
<Typography sx={{ flexGrow: 1 }}>{getTextIn(name)}</Typography>
<Stack direction="row" spacing={1}>
<DateButton date={dayjs(startdate)} label="Start" />
<DateButton date={dayjs(enddate).subtract(1, 'days')} label="End" />
</Stack>
</ListItem>
))}
</List>
</Box>
)
return (
<DatePicker
@@ -90,10 +93,10 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
maxDate={dayjs(before ?? today)}
minDate={after ? dayjs(after) : undefined}
onChange={onChange}
reduceAnimations
showDaysOutsideCurrentMonth
slotProps={{ popper: { disablePortal: true } }}
// @ts-expect-error HACK: there should be a custom component that eats the picker
slots={{ tabs: showSemesters && CustomPicker }}
slots={{ tabs: showSemesters && CustomPicker, desktopTrapFocus: undefined }}
value={value}
/>
)
@@ -1,13 +1,14 @@
import { ADMISSION_TYPES } from '@/common'
import { FormattedStudent } from '@oodikone/shared/types/studentData'
import { FilterTrayProps } from '../FilterTray'
import { FilterSelect } from './common/FilterSelect'
import { createFilter } from './createFilter'
const findAllStudyRightsForProgramme = (student, programme) =>
const findAllStudyRightsForProgramme = (student: FormattedStudent, programme: string) =>
student.studyRights.filter(studyRight => studyRight.studyRightElements.some(el => el.code === programme))
export const filter = code => value => student => {
const programmeStudyRights = findAllStudyRightsForProgramme(student, code)
export const filter = (programme: string, value: string | null) => (student: FormattedStudent) => {
const programmeStudyRights = findAllStudyRightsForProgramme(student, programme)
const fixedValue = value !== 'Valintakoe' ? value : 'Koepisteet'
return programmeStudyRights.some(
@@ -22,7 +23,7 @@ export const filter = code => value => student => {
const AdmissionTypeFilterCard = ({ args, options, onOptionsChange, students }: FilterTrayProps) => {
const code = args.programme
const { selected } = options
const count = (admissionType: string | null): number => students.filter(filter(code)(admissionType)).length
const count = (admissionType: string | null): number => students.filter(filter(code, admissionType)).length
const selectOptions = Object.entries(ADMISSION_TYPES)
.filter(([_, admissionType]) => !!admissionType)
@@ -57,15 +58,15 @@ export const admissionTypeFilter = createFilter({
title: 'Admission type',
defaultOptions: {
selected: null,
selected: '',
},
isActive: ({ selected }) => !!selected,
isActive: ({ selected }) => !!selected.length,
filter(student, { args, options }) {
const { selected } = options
return filter(args.programme)(selected)(student)
return filter(args.programme, selected)(student)
},
render: AdmissionTypeFilterCard,
@@ -1,42 +1,64 @@
import ClearIcon from '@mui/icons-material/Clear'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'
import Box from '@mui/material/Box'
import Collapse from '@mui/material/Collapse'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { FC, useState } from 'react'
import { useEffect, useState } from 'react'
import { InfoBox } from '@/components/material/InfoBox'
import type { Filter } from '../createFilter'
export const FilterCard: FC<{
const ConditionalInfoBox = ({ info }) => {
return info ? <InfoBox content={info} mini /> : null
}
export const FilterCard = ({
active,
filter,
children,
onClear,
}: {
active: boolean
filter: Filter
children: React.ReactNode
onClear: () => void
}> = ({ active, filter, children, onClear }) => {
}) => {
const [opened, setOpened] = useState<boolean>(active)
useEffect(() => setOpened(active), [active])
const { info, key, title } = filter
const handleOnClick = () => setOpened(state => !state)
const iconStyle = {
transition: 'transform 300ms',
...(opened ? { transform: 'rotate(90deg)' } : {}),
}
return (
<Stack data-active={active} data-cy={`${key}-filter-card`} spacing={1.2} sx={{ width: '100%' }}>
<Stack
data-active={active}
data-cy={`${key}-filter-card`}
padding={0.5}
spacing={Number(opened) * 1.2}
sx={{ width: '100%' }}
>
<Box
data-cy={`${key}-header`}
sx={{ alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'inline-flex' }}
>
<IconButton onClick={handleOnClick} size="small">
{opened ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
<IconButton onClick={handleOnClick} size="small" sx={iconStyle}>
<KeyboardArrowRightIcon />
</IconButton>
<Typography
component="span"
fontWeight={500}
fontWeight="medium"
onClick={handleOnClick}
sx={{ mr: 1, flex: 1, whiteSpace: 'wrap' }}
sx={{ flex: 1, whiteSpace: 'wrap' }}
variant="subtitle1"
>
{title}
@@ -47,17 +69,19 @@ export const FilterCard: FC<{
onClick={() => onClear()}
sx={{
color: theme => theme.palette.error.dark,
mr: info ? 0.5 : 0,
m: '0 0.5',
'&:hover': {
color: theme => theme.palette.error.light,
},
}}
/>
) : null}
{info ? <InfoBox content={info} mini /> : null}
<ConditionalInfoBox info={info} />
</Box>
<Collapse in={opened}>
<Box sx={{ px: 1, mb: 1.5 }}>{children}</Box>
<Collapse in={opened} sx={{ transition: '300ms' }}>
<Paper sx={{ p: 1.5 }} variant="outlined">
{children}
</Paper>
</Collapse>
</Stack>
)
@@ -3,40 +3,46 @@ import FormControlLabel from '@mui/material/FormControlLabel'
import Radio from '@mui/material/Radio'
import RadioGroup, { type RadioGroupProps, useRadioGroup } from '@mui/material/RadioGroup'
type RadioOption = {
type RadioOption<T> = {
key: string | undefined
text: string
value: string
value: T
}
type FilterRadioProps = {
defaultOption: RadioOption
options: RadioOption[]
type FilterRadioProps<T> = {
defaultValue: RadioOption<T>['value']
controlledValue?: RadioOption<T>['value']
options: RadioOption<T>[]
filterKey: string
onChange: RadioGroupProps['onChange']
}
export const FilterRadio = ({ defaultOption, options, onChange, filterKey }: FilterRadioProps) => {
const RadioButton = ({ option }: { option: RadioOption }) => {
const radioGroup = useRadioGroup()
return (
<FormControlLabel
checked={radioGroup?.value === String(option.value)}
control={<Radio />}
data-cy={`${filterKey}-radio-${option.key}`}
label={option.text}
value={option.value}
/>
)
}
const RadioButton = <T,>({ filterKey, option }: { filterKey: string; option: RadioOption<T> }) => {
const radioGroup = useRadioGroup()
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<RadioGroup defaultValue={defaultOption.value} name={filterKey} onChange={onChange}>
<RadioButton option={defaultOption} />
<FormControlLabel
checked={radioGroup?.value === String(option.value)}
control={<Radio />}
data-cy={`${filterKey}-radio-${option.key}`}
label={option.text}
value={option.value}
/>
)
}
export const FilterRadio = <T,>({
defaultValue,
controlledValue,
options,
onChange,
filterKey,
}: FilterRadioProps<T>) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'left', px: 1 }}>
<RadioGroup defaultValue={defaultValue} name={filterKey} onChange={onChange} value={controlledValue}>
{options.map(option => (
<RadioButton key={option.key} option={option} />
<RadioButton filterKey={filterKey} key={option.key} option={option} />
))}
</RadioGroup>
</Box>
@@ -0,0 +1,91 @@
import Autocomplete from '@mui/material/Autocomplete'
import Box from '@mui/material/Box'
import Popper from '@mui/material/Popper'
import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
type ValidValueType = string
type SelectOption<T extends ValidValueType> = {
key?: string
text: string
value: T
disabled?: boolean
}
type FilterSearchableSelectProps<T extends ValidValueType> = {
label: string
options: SelectOption<T>[]
value: SelectOption<T> | SelectOption<T>[] | null
onChange: (target: any) => void // target varies per implementation
filterKey: string
multiple?: boolean
}
/**
* Matches arbitrary user input against values in options.
* Multiple values can be entered, accepted separators: [',', ';', '\n' ' ']
*/
const matchUserInput = (options: SelectOption<string>[], target: any[]) => {
const parsedInput = target.flatMap(item => item.split(/[\s,;]+/).filter(Boolean))
return options.filter(opt => parsedInput.includes(opt.value)).map(opt => opt.value)
}
export const FilterSearchableSelect = <T extends ValidValueType = string>({
label,
value,
options,
onChange,
filterKey,
multiple,
}: FilterSearchableSelectProps<T>) => {
const disabled = !options.length
const CustomWidthPopper = props => (
<Popper
{...props}
id={`${filterKey}-popper`}
placement="bottom-start"
style={{ minWidth: '14em', maxWidth: '40em' }}
/>
)
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Tooltip title={disabled ? 'No valid values found' : ''}>
<Autocomplete
autoHighlight
blurOnSelect
clearOnEscape
data-cy={`${filterKey}-selector`}
disabled={disabled}
filterSelectedOptions
freeSolo={multiple}
fullWidth
getOptionLabel={opt => (typeof opt === 'string' ? opt : opt.text)}
limitTags={5}
multiple={multiple}
onChange={(_, target, reason) => {
// Target is always an array if multiple is enabled
if (multiple && Array.isArray(target)) {
if (reason === 'createOption') {
onChange(matchUserInput(options, target))
} else {
onChange(target.map(item => (typeof item === 'string' ? item : item.value)))
}
} else if (target !== null && typeof target !== 'string' && !Array.isArray(target)) {
// When not in multiple mode (=freesolo is off) target is always SelectOption<T>
onChange(target)
}
}}
options={options}
renderInput={params => <TextField {...params} id={`${filterKey}-label`} label={label} size="small" />}
slots={{
popper: CustomWidthPopper,
}}
value={value}
/>
</Tooltip>
</Box>
)
}
@@ -3,6 +3,7 @@ import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select, { SelectChangeEvent } from '@mui/material/Select'
import Tooltip from '@mui/material/Tooltip'
// HACK: This is ripped off of MUI MenuItem error.
// TODO: Find an actual type to replace this.
@@ -41,24 +42,31 @@ export const FilterSelect = <T extends ValidValueType = string>({
multiple,
InputItem,
MenuItem,
}: FilterSelectProps<T>) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<FormControl sx={{ width: '95%' }} variant="outlined">
<InputLabel id={`${filterKey}-label`} size="small">
{label}
</InputLabel>
<Select
data-cy={`${filterKey}-selector`}
label={label}
labelId={filterKey}
multiple={multiple}
onChange={onChange}
renderValue={InputItem}
size="small"
value={value}
>
{options.map(MenuItem ?? DefaultMenuItem)}
</Select>
</FormControl>
</Box>
)
}: FilterSelectProps<T>) => {
const disabled = !options.length
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<FormControl fullWidth variant="outlined">
<InputLabel id={`${filterKey}-label`} size="small" sx={{ textOverflow: 'ellipsis', pr: 2.5 }}>
{label}
</InputLabel>
<Tooltip title={disabled ? 'No valid values found' : null}>
<Select
data-cy={`${filterKey}-selector`}
disabled={disabled}
label={label}
labelId={filterKey}
multiple={multiple}
onChange={onChange}
renderValue={InputItem}
size="small"
value={value}
>
{options.map(MenuItem ?? DefaultMenuItem)}
</Select>
</Tooltip>
</FormControl>
</Box>
)
}
@@ -11,26 +11,24 @@ type SwitchOption = {
type FilterSwitchProps = {
options: SwitchOption[]
filterKey?: string
filterKey: string
}
export const FilterSwitch = ({ options, filterKey }: FilterSwitchProps) => {
const SwitchButton = ({ option }: { option: SwitchOption }) => {
return (
<FormControlLabel
checked={option.checked}
control={<Switch />}
data-cy={`${filterKey}-radio-${option.key}`}
label={option.text}
onClick={option.onClick}
/>
)
}
const SwitchButton = ({ filterKey, option }: { filterKey: string; option: SwitchOption }) => (
<FormControlLabel
checked={!!option.checked}
control={<Switch />}
data-cy={`${filterKey}-radio-${option.key}`}
label={option.text}
onClick={option.onClick}
/>
)
export const FilterSwitch = ({ options, filterKey }: FilterSwitchProps) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{options.map(option => (
<SwitchButton key={option.key} option={option} />
<SwitchButton filterKey={filterKey} key={option.key} option={option} />
))}
</Box>
)
@@ -1,6 +1,7 @@
import ClearIcon from '@mui/icons-material/Clear'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
@@ -25,7 +26,12 @@ const filterTexts = {
label: 'Enrolled, No Grade',
},
}
const getSubstitutionTooltip = (substitutions: string[]) => (
<Typography fontSize="0.9rem" whiteSpace="pre-line">
Included course substitutions:
{substitutions.map(code => `\n${code}`)}
</Typography>
)
export const CourseCard = ({
course,
filterType,
@@ -56,7 +62,18 @@ export const CourseCard = ({
})}
>
<Stack direction="row" sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ my: 2 }}>{getTextIn(course.course?.name)}</Typography>
<Box sx={{ mb: 2 }}>
<Typography>{getTextIn(course.course?.name)}</Typography>
{course.course?.substitutions?.length ? (
<Tooltip title={getSubstitutionTooltip(course.course.substitutions)}>
<Typography sx={{ color: 'text.secondary' }}>
{course.course?.code}... +{course.course?.substitutions?.length}
</Typography>
</Tooltip>
) : (
<Typography sx={{ color: 'text.secondary' }}>{course.course?.code}</Typography>
)}
</Box>
<ClearIcon
data-cy={`courseFilter-${course.course?.code}-clear`}
onClick={() => onChange(null)}
@@ -2,7 +2,7 @@ import { keyBy } from 'lodash'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import type { FilterTrayProps } from '../../FilterTray'
import { FilterSelect } from '../common/FilterSelect'
import { FilterSearchableSelect } from '../common/FilterSearchableSelect'
import { createFilter } from '../createFilter'
import { CourseCard } from './CourseCard'
import { FilterType } from './filterType'
@@ -10,7 +10,7 @@ import { FilterType } from './filterType'
type CourseStats = Record<string, any>
const CourseFilterCard = ({ precomputed, options, onOptionsChange }: FilterTrayProps) => {
const courseStats: CourseStats = precomputed
const courseStats: CourseStats = precomputed.courses
const courseFilters: Record<string, keyof typeof FilterType> = options?.courseFilters
const { getTextIn } = useLanguage()
@@ -34,12 +34,12 @@ const CourseFilterCard = ({ precomputed, options, onOptionsChange }: FilterTrayP
return (
<>
<FilterSelect
<FilterSearchableSelect
filterKey="courseFilter"
label="Select course"
onChange={({ target }) => setCourseFilter(target.value, FilterType.ALL)}
label="Select a course"
onChange={target => setCourseFilter(target.value, FilterType.ALL)}
options={dropdownOptions}
value={''}
value={null}
/>
{Object.entries(courseFilters).map(([code, type]) => (
<CourseCard
@@ -62,7 +62,25 @@ export const courseFilter = createFilter({
courseFilters: {},
},
precompute: ({ args }) => keyBy(args.courses, 'course.code'),
precompute: ({ args }) => {
const substitutedBy = args.courses.reduce(
(acc, { course }) => {
const { code, substitutions } = course
for (const original of substitutions) {
acc[original] ??= []
acc[original].push(code)
}
return acc
},
{} as Record<string, string[]>
)
return {
courses: keyBy(args.courses, 'course.code'),
substitutedBy,
}
},
isActive: ({ courseFilters }) => Object.keys(courseFilters).length > 0,
@@ -75,10 +93,14 @@ export const courseFilter = createFilter({
}
for (const [code, filterType] of Object.entries(options.courseFilters)) {
const { students = {} } = precomputed[code]
const key = filterKeys[filterType as string]
const found = [code, ...(precomputed.substitutedBy[code] ?? [])].some(course => {
const students = precomputed.courses?.[course]?.students ?? {}
const key = filterKeys[filterType as string]
if (!students?.[key]?.includes(studentNumber)) return false
return students?.[key]?.includes(studentNumber)
})
if (!found) return false
}
return true
@@ -14,7 +14,7 @@ const CreditDateFilterCard = ({ options, onOptionsChange }: FilterTrayProps) =>
const { startDate, endDate } = options
return (
<div className="card-content" style={{ marginTop: '0.5rem' }}>
<div className="card-content">
<DateRangeSelector
onChange={([startDate, endDate]) =>
onOptionsChange({
@@ -24,6 +24,7 @@ const GraduatedFromProgrammeFilterCard = ({ args, onOptionsChange }: FilterTrayP
const typeOfCombined = isLicentiate ? 'Licentiate' : 'Master'
const modeOptions = isCombinedExtent
? [
{ key: 'All', text: 'All', value: DEFAULT_STATE },
{ key: 'graduated-bachelor', text: "Graduated with Bachelor's", value: GRADUATION_PHASE.GRADUATED_BACHELOR },
{
key: 'graduated-master',
@@ -42,13 +43,14 @@ const GraduatedFromProgrammeFilterCard = ({ args, onOptionsChange }: FilterTrayP
},
]
: [
{ key: 'All', text: 'All', value: DEFAULT_STATE },
{ key: 'graduated-true', text: 'Graduated', value: GRADUATION_PHASE.GRADUATED },
{ key: 'graduated-false', text: 'Not graduated', value: GRADUATION_PHASE.NOT_GRADUATED },
]
return (
<FilterRadio
defaultOption={{ key: undefined, text: 'All', value: DEFAULT_STATE }}
defaultValue={DEFAULT_STATE}
filterKey={graduatedFromProgrammeFilter.key}
onChange={({ target }) => onOptionsChange({ mode: target.value })}
options={modeOptions}
@@ -44,11 +44,11 @@ const CombinedProgramme = ({ options, onOptionsChange, combinedProgramme }) => {
value: key,
}))
const defaultOption = modeOptions.shift()!
const defaultOption = modeOptions.at(0)?.value ?? ''
return (
<FilterRadio
defaultOption={defaultOption}
defaultValue={defaultOption}
filterKey="hopsFilter"
onChange={({ target }) => modeObject[target.value]()}
options={modeOptions}
@@ -1,7 +1,7 @@
import MenuItem from '@mui/material/MenuItem'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import dayjs from 'dayjs'
import dayjs, { extend as dayjsExtend } from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
@@ -10,11 +10,26 @@ import { FilterTrayProps } from '../FilterTray'
import { FilterSelect } from './common/FilterSelect'
import { createFilter } from './createFilter'
dayjs.extend(isSameOrBefore)
dayjs.extend(isSameOrAfter)
dayjsExtend(isSameOrBefore)
dayjsExtend(isSameOrAfter)
const NO_PROGRAMME = { code: '00000', name: { en: 'No programme', fi: 'Ei ohjelmaa' } }
const SelectInputItem = ({ text }) => (
<Typography fontWeight={500} px={0.5}>
{text}
</Typography>
)
const CourseMenuStack = ({ disabled, text, value, description }) => (
<MenuItem disabled={disabled} value={value}>
<Stack>
<Typography fontWeight={500}>{text}</Typography>
<Typography sx={{ color: '#5b5b5b', fontSize: '0.9em' }}>{description}</Typography>
</Stack>
</MenuItem>
)
const ProgrammeFilterCard = ({
args,
onOptionsChange,
@@ -94,20 +109,15 @@ const ProgrammeFilterCard = ({
value={selectedProgrammes}
/>
<FilterSelect
InputItem={value => (
<Typography fontWeight={500} px={0.5}>
{modeOptions.find(mode => value === mode.value)!.text}
</Typography>
)}
InputItem={value => <SelectInputItem text={modeOptions.find(mode => value === mode.value)!.text} />}
MenuItem={option => (
<MenuItem disabled={option.disabled} key={option.key} value={option.value}>
<Stack>
<Typography fontWeight={500}>{option.text}</Typography>
<Typography sx={{ color: '#5b5b5b', fontSize: '0.9em' }}>
{modes.find(mode => option.key === mode.key)?.description}
</Typography>
</Stack>
</MenuItem>
<CourseMenuStack
description={modes.find(mode => option.key === mode.key)?.description}
disabled={option.disabled}
key={option.key}
text={option.text}
value={option.value}
/>
)}
filterKey="programmeFilter-mode"
label="Select Mode"
@@ -7,7 +7,7 @@ import { includes, isEqual, union } from 'lodash'
import { filterToolTips } from '@/common/InfoToolTips'
import { formatToArray } from '@oodikone/shared/util'
import { FilterTrayProps } from '../FilterTray'
import { FilterSelect } from './common/FilterSelect'
import { FilterSearchableSelect } from './common/FilterSearchableSelect'
import { createFilter } from './createFilter'
const StudentNumberFilterCard = ({ options, onOptionsChange, students }: FilterTrayProps) => {
@@ -19,10 +19,11 @@ const StudentNumberFilterCard = ({ options, onOptionsChange, students }: FilterT
return (
<Stack gap={1}>
<FilterSelect
<FilterSearchableSelect
filterKey="studentNumberFilter-allowlist"
label="Allowed student numbers"
multiple
onChange={({ target }) => onOptionsChange({ ...options, allowlist: target.value })}
onChange={studentNumbers => onOptionsChange({ ...options, allowlist: studentNumbers })}
options={dropdownOptions}
value={options.allowlist}
/>
@@ -41,10 +42,11 @@ const StudentNumberFilterCard = ({ options, onOptionsChange, students }: FilterT
<Typography>Swap lists</Typography>
</Stack>
</Button>
<FilterSelect
<FilterSearchableSelect
filterKey="studentNumberFilter-blocklist"
label="Excluded student numbers"
multiple
onChange={({ target }) => onOptionsChange({ ...options, blocklist: target.value })}
onChange={studentNumbers => onOptionsChange({ ...options, blocklist: studentNumbers })}
options={dropdownOptions}
value={options.blocklist}
/>
@@ -41,11 +41,11 @@ const StudyRightStatusFilterCard = ({ args, options, onOptionsChange }: FilterTr
value: key,
}))
const defaultOption = modeOptions.shift()!
const defaultOption = modeOptions.at(0)?.value ?? ''
return (
<FilterRadio
defaultOption={defaultOption}
defaultValue={defaultOption}
filterKey="studyRightStatusFilter"
onChange={({ target }) => modeObject[target.value]()}
options={modeOptions}
@@ -6,6 +6,7 @@ import { createFilter } from './createFilter'
const DEFAULT_STATE = '0' as const
const STUDYRIGHT_TYPES = [
{ text: 'All', value: DEFAULT_STATE },
{ text: 'Bachelor + master', value: '1' },
{ text: 'Master only', value: '2' },
]
@@ -19,7 +20,7 @@ const StudyRightTypeFilterCard = ({ onOptionsChange }: FilterTrayProps) => {
return (
<FilterRadio
defaultOption={{ key: undefined, text: 'All', value: DEFAULT_STATE }}
defaultValue={DEFAULT_STATE}
filterKey={studyRightTypeFilter.key}
onChange={({ target }) => onOptionsChange({ mode: target.value })}
options={modeOptions}
@@ -1,3 +1,4 @@
import Alert from '@mui/material/Alert'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import type { StudyTrack } from '@oodikone/shared/types'
import { FilterTrayProps } from '../FilterTray'
@@ -15,6 +16,9 @@ const StudyTrackFilterCard = ({ args, onOptionsChange, options, students }: Filt
.filter(element => element.code === code && element.studyTrack !== null)
.map(element => element.studyTrack) as StudyTrack[]
if (!Object.keys(validStudyTracks).length)
return <Alert severity="warning">No study tracks have been defined for the selected students.</Alert>
const dropdownOptions = [...new Map(validStudyTracks.map(({ code, name }) => [code, name]))].map(([code, name]) => ({
key: code,
value: code,
@@ -30,6 +34,7 @@ const StudyTrackFilterCard = ({ args, onOptionsChange, options, students }: Filt
<FilterSelect
filterKey={studyTrackFilter.key}
label="Choose study track"
multiple
onChange={({ target }) => onOptionsChange({ selected: target.value })}
options={dropdownOptions}
value={selected}
@@ -20,7 +20,7 @@ const TagsFilterCard = ({ options, onOptionsChange, students }: FilterTrayProps)
}
if (!Object.keys(tagCounts).length)
return <Alert severity="warning">No tags have been defined for any of the selected students.</Alert>
return <Alert severity="warning">No tags have been defined for the selected students.</Alert>
const dropdownOptions = Object.entries(tagCounts).map(([tagId, { count, name }]) => ({
key: `tag-${tagId}`,
@@ -1,3 +1,5 @@
import { useMemo } from 'react'
import { filterToolTips } from '@/common/InfoToolTips'
import { FilterTrayProps } from '../FilterTray'
import { FilterRadio } from './common/FilterRadio'
@@ -8,28 +10,24 @@ const TransferredToProgrammeFilterCard = ({ options, onOptionsChange }: FilterTr
const toggle = buttonValue =>
onOptionsChange({
transferred: transferred === buttonValue ? null : buttonValue,
transferred: String(transferred) === buttonValue ? null : buttonValue,
})
const modeObject = {
All: () => toggle(''),
Transferred: () => toggle(true),
'Not transferred': () => toggle(false),
All: '',
Transferred: 'true',
'Not transferred': 'false',
}
const modeOptions = Object.keys(modeObject).map(key => ({
key,
text: key,
value: key,
}))
const defaultOption = modeOptions.shift()!
const defaultOption = useMemo(() => transferred ?? modeOptions.at(0)?.value ?? '', [])
const modeOptions = Object.entries(modeObject).map(([key, value]) => ({ key, text: key, value }))
return (
<FilterRadio
defaultOption={defaultOption}
controlledValue={transferred}
defaultValue={defaultOption}
filterKey={transferredToProgrammeFilter.key}
onChange={({ target }) => modeObject[target.value]()}
onChange={({ target }) => toggle(target.value)}
options={modeOptions}
/>
)
@@ -51,25 +49,13 @@ export const transferredToProgrammeFilter = createFilter({
filter: (student, { options }) => {
const { transferred } = options
return student.transferredStudyright === transferred
return String(student.transferredStudyright) === transferred
},
actions: {
set: (options, value) => {
options.transferred = value
return options
},
set: (options, value) => Object.assign(options, { transferred: String(value) }),
// Toggle between 'null' -> 'All' and 'false' -> 'Not transferred'
toggle: options => {
if (options.transferred !== false) {
options.transferred = false
} else {
options.transferred = ''
}
return options
},
toggle: options => Object.assign(options, { transferred: options.transferred !== 'false' ? 'false' : '' }),
},
selectors: {
@@ -5,11 +5,14 @@ import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import { type Table as TableType } from '@tanstack/react-table'
import { OodiTableAggregationRow } from './components/AggregationRow'
import { OodiTableDataRow } from './components/Cell'
import { OodiTableHeaderGroup } from './components/Header'
import { OodiTablePagination } from './components/Pagination'
export const OodiTableContainer = <OTData,>({ table }: { table: TableType<OTData> }) => {
const verticalHeaders = table.getState().useVerticalHeaders ?? []
return (
<Paper sx={{ p: 2, borderRadius: 0 }} variant="outlined">
<TableContainer
@@ -35,7 +38,7 @@ export const OodiTableContainer = <OTData,>({ table }: { table: TableType<OTData
zIndex: 2,
}}
>
{table.getHeaderGroups().map(OodiTableHeaderGroup)}
{table.getHeaderGroups().map(group => OodiTableHeaderGroup(group, verticalHeaders))}
</TableHead>
<TableBody
sx={{
@@ -47,6 +50,7 @@ export const OodiTableContainer = <OTData,>({ table }: { table: TableType<OTData
},
}}
>
{table.getAggregationRowModel().rows.map(OodiTableAggregationRow)}
{table.getRowModel().rows.map(OodiTableDataRow)}
</TableBody>
</Table>
@@ -0,0 +1,57 @@
import TableCell, { type TableCellProps } from '@mui/material/TableCell'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import { flexRender } from '@tanstack/react-table'
import type { FC } from 'react'
import { AggregationRow } from '../features/aggregationRows'
import { getCommonPinningStyles } from '../styles'
const OodiTableAggregationCell: FC<TableCellProps> = ({ children, ...props }) => {
return (
<TableCell
{...props}
sx={{
borderWidth: '0 1px 1px 0',
borderStyle: 'solid',
borderColor: 'grey.300',
padding: '0 1em',
height: '3em',
...props?.sx,
}}
>
<Typography
sx={{
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
whiteSpace: 'nowrap',
}}
>
{children}
</Typography>
</TableCell>
)
}
export const OodiTableAggregationRow = <OTData,>(row: AggregationRow<OTData>) => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => {
const { maxSize } = cell.column.columnDef
return (
<OodiTableAggregationCell
key={cell.id}
sx={{
...getCommonPinningStyles(cell.column),
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
}}
>
{flexRender(cell.column.columnDef.aggregatedCell, cell.getContext())}
</OodiTableAggregationCell>
)
})}
</TableRow>
)
@@ -3,11 +3,11 @@ import TableCell, { type TableCellProps } from '@mui/material/TableCell'
import TableRow from '@mui/material/TableRow'
import { flexRender } from '@tanstack/react-table'
import type { Row } from '@tanstack/react-table'
import type { FC, ReactNode } from 'react'
import type { FC } from 'react'
import { getCommonPinningStyles } from '../styles'
const OodiTableCell: FC<TableCellProps & { children?: ReactNode }> = ({ children, ...props }) => {
const OodiTableCell: FC<TableCellProps> = ({ children, ...props }) => {
return (
<TableCell
{...props}
@@ -15,19 +15,17 @@ const OodiTableCell: FC<TableCellProps & { children?: ReactNode }> = ({ children
borderWidth: '0 1px 1px 0',
borderStyle: 'solid',
borderColor: 'grey.300',
paddingTop: 0,
paddingBottom: 0,
padding: '0 1em',
height: '3em',
minHeight: '3em',
...props?.sx,
}}
>
<Box
sx={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
maxWidth: '20em',
whiteSpace: 'nowrap',
}}
>
@@ -39,10 +37,19 @@ const OodiTableCell: FC<TableCellProps & { children?: ReactNode }> = ({ children
export const OodiTableDataRow = <OTData,>(row: Row<OTData>) => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<OodiTableCell key={cell.id} sx={{ ...getCommonPinningStyles(cell.column) }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</OodiTableCell>
))}
{row.getVisibleCells().map(cell => {
const { maxSize } = cell.column.columnDef
return (
<OodiTableCell
key={cell.id}
sx={{
...getCommonPinningStyles(cell.column),
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</OodiTableCell>
)
})}
</TableRow>
)
@@ -1,10 +1,12 @@
import Box from '@mui/material/Box'
import { SxProps, Theme } from '@mui/material/styles'
import TableCell, { type TableCellProps } from '@mui/material/TableCell'
import TableRow from '@mui/material/TableRow'
import { flexRender } from '@tanstack/react-table'
import type { HeaderGroup } from '@tanstack/react-table'
import type { FC, ReactNode } from 'react'
import { getCommonPinningStyles } from '../styles'
import { getCommonPinningStyles, verticalStyles } from '../styles'
import { OodiTableSortIcons } from './SortIcons'
const OodiTableHeader: FC<TableCellProps & { children?: ReactNode }> = ({ children, ...props }) => {
@@ -27,7 +29,7 @@ const OodiTableHeader: FC<TableCellProps & { children?: ReactNode }> = ({ childr
)
}
export const OodiTableHeaderGroup = <OTData,>(headerGroup: HeaderGroup<OTData>) => (
export const OodiTableHeaderGroup = <OTData,>(headerGroup: HeaderGroup<OTData>, verticalHeaders: string[]) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
if (header.depth - header.column.depth > 1) return null
@@ -38,18 +40,48 @@ export const OodiTableHeaderGroup = <OTData,>(headerGroup: HeaderGroup<OTData>)
rowSpan = leafs[leafs.length - 1].depth - header.depth
}
const isVertical = verticalHeaders.includes(header.id)
const sx = {
cursor: header.column.getCanSort() ? 'pointer' : 'inherit',
...(isVertical && verticalStyles),
...getCommonPinningStyles(header.column),
}
return (
<OodiTableHeader
colSpan={header.colSpan}
key={header.id}
onClick={header.column.getToggleSortingHandler()}
rowSpan={rowSpan}
sx={{
...getCommonPinningStyles(header.column),
cursor: header.column.getCanSort() ? 'pointer' : 'inherit',
}}
sx={sx as SxProps<Theme>}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{isVertical ? (
<Box
sx={{
display: 'inline-flex',
width: '100%',
}}
>
<Box
sx={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
whiteSpace: 'normal',
overflow: 'visible',
height: '100%',
maxWidth: '15em',
minHeight: '160px',
maxHeight: '240px',
margin: '0 auto',
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Box>
</Box>
) : (
<Box>{flexRender(header.column.columnDef.header, header.getContext())}</Box>
)}
<OodiTableSortIcons canSort={header.column.getCanSort()} isSorted={header.column.getIsSorted()} />
</OodiTableHeader>
)
@@ -136,11 +136,14 @@ export const OodiTableExcelExport = <TData extends object>({
const [dialogOpen, setDialogOpen] = useState(false)
const handleExport = () => {
const selectedColumns = Object.keys(rowSelection).map(key => Number(key))
const selectedRows = Object.entries(rowSelection)
.filter(([_, selected]) => selected)
.map(([key, _]) => Number(key))
const selectedCols = pivotedData.filter((_, index) => selectedRows.includes(index)).map(({ header }) => header)
const worksheet = utils.json_to_sheet(
exportData.map(row =>
Object.fromEntries(Object.entries(row).filter((_, index) => selectedColumns.includes(index)))
)
exportData.map(row => Object.fromEntries(Object.entries(row).filter(([key, _]) => selectedCols.includes(key))))
)
const workbook = utils.book_new()
utils.book_append_sheet(workbook, worksheet)
@@ -0,0 +1,160 @@
import { Cell, CellContext, Column, Row, Table, TableFeature } from '@tanstack/react-table'
/**
* This doesn't implement all the features of Row, so be aware of that
*/
export interface AggregationRow<TData> extends Row<TData> {
getIsAggregationRow: () => true
}
type AggregationRowValueFn<TData, TValue = unknown> = (ctx: {
table: Table<TData>
rowId: string
column: Column<TData, TValue>
}) => TValue
interface AggregationRowCellDef<TData, TValue = unknown> {
id: string
value: TValue | AggregationRowValueFn<TData, TValue>
}
export type AggregationRowsInput<TData, TValue = unknown> =
| AggregationRowCellDef<TData, TValue>[]
| ((ctx: { table: Table<TData>; column: Column<TData> }) => AggregationRowCellDef<TData, TValue>[])
export interface AggregationRowModel<TData> {
rows: AggregationRow<TData>[]
flatRows: AggregationRow<TData>[]
rowsById: Record<string, AggregationRow<TData>>
}
const resolveAggregationRowsArray = <TData, TValue>(
table: Table<TData>,
column: Column<TData, TValue>
): AggregationRowCellDef<TData, TValue>[] => {
const input = column.columnDef.aggregationRows
if (!input) return []
return typeof input === 'function' ? (input({ table, column }) ?? []) : input
}
const collectAggregationRowIds = <TData>(table: Table<TData>): string[] => {
const seen: string[] = []
const set = new Set<string>()
table.getAllLeafColumns().forEach(col => {
const defs = resolveAggregationRowsArray(table, col)
if (!defs) return
for (const def of defs) {
if (!set.has(def.id)) {
set.add(def.id)
seen.push(def.id)
}
}
})
return seen
}
const resolveAggregationCellValue = <TData, TValue>(
table: Table<TData>,
column: Column<TData, unknown>,
aggRowId: string
): TValue | undefined => {
const defs = resolveAggregationRowsArray(table, column)
const def = defs.find(def => def.id === aggRowId)
if (!def) return undefined
const { value } = def
if (typeof value === 'function') {
return (value as AggregationRowValueFn<TData>)({
table,
rowId: aggRowId,
column,
}) as TValue
}
return value as TValue
}
const buildAggregationRowModel = <TData>(table: Table<TData>): AggregationRowModel<TData> => {
const aggRowIds = collectAggregationRowIds(table)
if (!aggRowIds.length) {
return {
rows: [],
flatRows: [],
rowsById: {},
} as unknown as AggregationRowModel<TData>
}
const visibleLeafColumns =
table.getVisibleLeafColumns?.() ?? table.getAllLeafColumns().filter((col: any) => col.getIsVisible?.() ?? true)
const rowsById: Record<string, AggregationRow<TData>> = {}
const rows: AggregationRow<TData>[] = []
const flatRows: AggregationRow<TData>[] = []
aggRowIds.forEach((aggRowId, index) => {
const row: AggregationRow<TData> = {
id: aggRowId,
index,
depth: 0,
original: undefined as any,
parentId: undefined,
_valuesCache: {},
_uniqueValuesCache: {},
_groupingValuesCache: {},
getValue: <TValue>(columnId: string): TValue => {
const column = table.getColumn(columnId)
return column
? (resolveAggregationCellValue<TData, TValue>(table, column, aggRowId) as TValue)
: (undefined as TValue)
},
getVisibleCells: () => {
if (!(row as any)._cells) {
;(row as any)._cells = visibleLeafColumns.map(column => {
const cell: Cell<TData, unknown> = {
id: `${column.id}_${row.id}`,
row,
column,
getValue: () => row.getValue(column.id),
getContext: () =>
({
table,
column,
row,
cell,
getValue: cell.getValue,
renderValue: cell.getValue(),
}) as CellContext<TData, unknown>,
renderValue: () => cell.getValue(),
} as Cell<TData, unknown>
return cell
})
}
return (row as any)._cells as Cell<TData, unknown>[]
},
/** Unimplemented and redundant, use getVisibleCells instead */
getAllCells: () => undefined,
getLeafRows: () => [row],
getParentRow: () => undefined,
subRows: [],
getIsAggregationRow: () => true,
} as unknown as AggregationRow<TData>
rows.push(row)
flatRows.push(row)
rowsById[row.id] = row
})
return { rows, flatRows, rowsById }
}
export const AggregationRowFeature: TableFeature<any> = {
createTable: <TData>(table: Table<TData>) => {
table.getAllAggregationRowIds = () => collectAggregationRowIds(table)
table.hasAggregationRows = () => collectAggregationRowIds(table).length > 0
table.getAggregationRowModel = () => buildAggregationRowModel(table)
},
}
@@ -0,0 +1,38 @@
/* eslint-disable import-x/no-unused-modules */
export {}
import '@tanstack/react-table'
import { AggregationRowModel, AggregationRowsInput } from './aggregationRows'
import { VerticalHeaders } from './verticalHeader'
declare module '@tanstack/react-table' {
interface TableState extends VerticalHeaders {}
interface ColumnDefBase<TData, TValue = unknown> {
/**
* Accepts two kinds of inputs:
*
* 1. An array directly `{ id: String, value: T }[]`
* 2. A function `({ table, column }) => {id: String, value:T}[]`
*
* `id` is used for grouping
*
* Use same `id` on multiple rows to supply data to the same aggregation row,
* or different `id` to create multiple rows.
*
* Order matters and is based on the first occurence of id.
*/
aggregationRows?: AggregationRowsInput<TData, TValue>
}
interface Table<TData> {
getAggregationRowModel: () => AggregationRowModel<TData>
getAllAggregationRowIds: () => string[]
hasAggregationRows: () => boolean
}
interface Row {
getIsAggregationRow?: () => boolean
}
}
@@ -0,0 +1,2 @@
export { VerticalHeaderFeature } from './verticalHeader'
export { AggregationRowFeature } from './aggregationRows'
@@ -0,0 +1,14 @@
import { TableFeature } from '@tanstack/react-table'
export interface VerticalHeaders {
useVerticalHeaders: string[]
}
export const VerticalHeaderFeature: TableFeature<unknown> = {
getInitialState: (state): VerticalHeaders => {
return {
useVerticalHeaders: [],
...state,
}
},
}
@@ -1,9 +1,8 @@
// 03-07-2025 summer hackathon never forget
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
import { useState } from 'react'
import { AggregationRowFeature, VerticalHeaderFeature } from './features'
import { OodiTableContainer } from './OodiTable'
/**
@@ -37,6 +36,7 @@ export const OodiTable = <TData,>({
}
const table = useReactTable<TData>({
_features: [VerticalHeaderFeature, AggregationRowFeature],
data,
columns,
getCoreRowModel: getCoreRowModel(),
@@ -1,7 +1,7 @@
import type { SxProps } from '@mui/material/styles'
import type { SxProps, Theme } from '@mui/material/styles'
import type { Column } from '@tanstack/react-table'
export const getCommonPinningStyles = <TData>(column: Column<TData>): SxProps => {
export const getCommonPinningStyles = <TData>(column: Column<TData>): SxProps<Theme> => {
const isPinned = column.getIsPinned()
return isPinned
? {
@@ -19,3 +19,11 @@ export const getCommonPinningStyles = <TData>(column: Column<TData>): SxProps =>
}
: {}
}
export const verticalStyles: SxProps<Theme> = {
overflow: 'visible',
borderWidth: '1px 1px 1px 0',
borderStyle: 'solid',
borderColor: 'grey.300',
padding: '0.5em',
}
@@ -20,7 +20,7 @@ export const CreditsGainedTab = ({ filteredStudents, programmeGoalTime, programm
if (!filteredStudents?.length || !programme) return null
const admissionTypesAvailable =
filteredStudents.length !== filteredStudents.filter(admissionTypeFilter(programme)(null)).length
filteredStudents.length !== filteredStudents.filter(admissionTypeFilter(programme, null)).length
return (
<Stack direction="column" sx={{ p: 1 }}>
@@ -34,7 +34,7 @@ export const CreditsGainedTab = ({ filteredStudents, programmeGoalTime, programm
<Box>
<DividerWithText text="By admission type" />
{admissionTypes.map(type => {
const studentsByAdmissionType = filteredStudents.filter(admissionTypeFilter(programme)(type))
const studentsByAdmissionType = filteredStudents.filter(admissionTypeFilter(programme, type))
return studentsByAdmissionType.length ? (
<CreditsGainedTable
filteredStudents={studentsByAdmissionType}
@@ -8,7 +8,7 @@ import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import dayjs, { type Dayjs } from 'dayjs'
import dayjs, { type Dayjs, extend as dayjsExtend } from 'dayjs'
import isBetween from 'dayjs/plugin/isBetween'
import { getStudentTotalCredits } from '@/common'
@@ -19,7 +19,7 @@ import { formatDate } from '@/util/timeAndDate'
import { FormattedStudent } from '@oodikone/shared/types'
import { CollapsibleCreditRow } from './CollapsibleCreditRow'
dayjs.extend(isBetween)
dayjsExtend(isBetween)
type CreditsGainedTableProps = {
filteredStudents: FormattedStudent[]
@@ -16,7 +16,7 @@ type StatisticsTabProps = {
export const StatisticsTab = ({ filteredStudents, programme }: StatisticsTabProps) => {
const admissionTypesAvailable =
filteredStudents.length !== filteredStudents.filter(admissionTypeFilter(programme)(null)).length
filteredStudents.length !== filteredStudents.filter(admissionTypeFilter(programme, null)).length
return (
<Stack sx={{ alignItems: 'center', p: '2em' }}>
@@ -28,7 +28,7 @@ export const StatisticsTab = ({ filteredStudents, programme }: StatisticsTabProp
<DividerWithText text="By admission type" />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '2em', justifyContent: 'center', width: '100%' }}>
{admissionTypes.map(type => {
const studentsByAdmissionType = filteredStudents.filter(admissionTypeFilter(programme)(type))
const studentsByAdmissionType = filteredStudents.filter(admissionTypeFilter(programme, type))
return studentsByAdmissionType.length ? (
<StatisticsTable
filteredStudents={studentsByAdmissionType}
@@ -25,7 +25,6 @@ import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/S
import { useDebouncedState } from '@/hooks/debouncedState'
import { useCurriculumState } from '@/hooks/useCurriculums'
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
import { PopulationQuery } from '@/types/populationSearch'
import { getFullStudyProgrammeRights } from '@/util/access'
@@ -70,7 +69,6 @@ export const PopulationDetails = ({
const { isFetching: authLoading, programmeRights, fullAccessToStudentData } = useGetAuthorizedUserQuery()
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
useEffect(() => setStudentAmountLimit(Math.floor(filteredStudents.length * 0.3)), [filteredStudents.length])
@@ -146,7 +144,6 @@ export const PopulationDetails = ({
{/* @ts-expect-error leave for later :) TODO */}
<PopulationStudents
combinedProgramme={combinedProgramme}
criteria={criteria}
curriculum={curriculum}
filteredCourses={filteredCourses}
filteredStudents={filteredStudents}
@@ -221,7 +218,7 @@ export const PopulationDetails = ({
<FormControlLabel
control={
<Switch
checked={transferredSelected !== false}
checked={transferredSelected !== 'false'}
onChange={() => filterDispatch(transferredToProgrammeFilter.actions.toggle(undefined))}
/>
}
@@ -13,7 +13,8 @@ export const useColumns = ({ showCombinedProgrammeColumns }): [string[], string[
const adminColumns = isAdmin ? ['extent', 'updatedAt'] : []
const excelOnlyColumns = ['email', 'phoneNumber']
/* REQUIRED TO ALWAYS BE VISIBLE */
const excelRequired = ['studentNumber', 'lastName', 'firstNames', 'email', 'phoneNumber']
return [
[
@@ -42,6 +43,6 @@ export const useColumns = ({ showCombinedProgrammeColumns }): [string[], string[
...combinedProgrammeColumns,
...adminColumns,
],
excelOnlyColumns,
excelRequired,
]
}
@@ -13,7 +13,7 @@ type EnrollmentDateSelectorProps = {
export const EnrollmentDateSelector = ({ year, setYear }: EnrollmentDateSelectorProps) => {
const currentYear = new Date().getFullYear()
const lowestYear = 2017
const options = [...Array(currentYear + 1 - lowestYear).keys()].map(value => lowestYear + value)
const options = [...Array(currentYear + 1 - lowestYear).keys()].map(value => currentYear - value)
return (
<Stack direction="row" spacing={1} sx={{ mt: 0 }}>
<IconButton
@@ -172,7 +172,7 @@ export const PopulationStatistics = () => {
const initialOptions = {
[transferredToProgrammeFilter.key]: {
transferred: false,
transferred: 'false',
},
[hopsFilter.key]: {
studyStart: students.some(student => !!student?.studyrightStart),

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