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