mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03756dfce0 | ||
|
|
1c26912ac7 | ||
|
|
8a7b9b4894 | ||
|
|
b4103ca779 | ||
|
|
4a68fe19e6 | ||
|
|
69365473a4 | ||
|
|
2cfe0d1f6c | ||
|
|
26d33c1201 | ||
|
|
b1c8396098 | ||
|
|
9d608dbbcc | ||
|
|
ae52189689 | ||
|
|
ecfc8053a0 | ||
|
|
71bff0214e | ||
|
|
58bbe00171 | ||
|
|
75302bdc14 | ||
|
|
55135030e0 | ||
|
|
a8b87eed3c | ||
|
|
95f36ef7e8 | ||
|
|
f8107bc475 | ||
|
|
391ea1cbaf | ||
|
|
5f1d3a12b7 | ||
|
|
c533c25bfd | ||
|
|
d4e2d856e8 | ||
|
|
c5b3147f04 | ||
|
|
8977c52225 | ||
|
|
a8f48f24d1 | ||
|
|
714682c6c5 | ||
|
|
0659d162b5 | ||
|
|
7057e00ee2 | ||
|
|
adf64a3d1d | ||
|
|
36421cf5e7 | ||
|
|
a81b569ab9 | ||
|
|
7641ca3ba1 | ||
|
|
9515adb1be | ||
|
|
55668cc313 | ||
|
|
28b7cdfe6b | ||
|
|
4b6bba653a | ||
|
|
eea167df9b | ||
|
|
ce946bcb0d | ||
|
|
6580f42eab | ||
|
|
043b0d2cbd | ||
|
|
25fb4bad84 | ||
|
|
53f3955427 | ||
|
|
49390cf92f | ||
|
|
128ee465ff | ||
|
|
6ed84bee52 | ||
|
|
fe665cc8ad | ||
|
|
413f2b4fda | ||
|
|
8959204a37 | ||
|
|
f069d705cb | ||
|
|
adb8c9322b | ||
|
|
3e83b63324 | ||
|
|
4dcb6b4e37 | ||
|
|
7830a8abc3 | ||
|
|
34e006e12a | ||
|
|
a33fe22bac | ||
|
|
4ac467fa7a | ||
|
|
6aad36d06a | ||
|
|
8413369b89 | ||
|
|
4f2780bf00 | ||
|
|
59120fa69c | ||
|
|
5e502fa23a | ||
|
|
28eec2db0f | ||
|
|
9df131b326 | ||
|
|
b9bd916b91 | ||
|
|
c99034575f | ||
|
|
dc12b0fd04 | ||
|
|
3ec2b5e5d1 | ||
|
|
26461f5277 | ||
|
|
17b83a8574 | ||
|
|
ec2e203e6b | ||
|
|
6f6a732e7f | ||
|
|
fbbf3830b3 | ||
|
|
4cbccd29ba | ||
|
|
3d0fd3563f | ||
|
|
a929613e83 | ||
|
|
d64aca5285 | ||
|
|
9e1307595c | ||
|
|
0e9ba8490d | ||
|
|
50b4166a7f | ||
|
|
3843937429 | ||
|
|
b60b1dd88b | ||
|
|
4f4868a32b | ||
|
|
d45bddbeab | ||
|
|
2abda1de50 | ||
|
|
333944d377 | ||
|
|
50c301fe82 | ||
|
|
ed513c14bf | ||
|
|
8e2b8e5443 | ||
|
|
bb215bb785 | ||
|
|
35ea22e125 | ||
|
|
c3c7b5b479 | ||
|
|
635fb97b25 | ||
|
|
a5334c8e2f | ||
|
|
18b8daabff | ||
|
|
b6494f88d0 | ||
|
|
87bc8b24a9 | ||
|
|
b181fda7fc | ||
|
|
747c37697c | ||
|
|
76d8fef4eb | ||
|
|
e08d99c801 | ||
|
|
b5f7a8bd86 | ||
|
|
7d7d71b6be | ||
|
|
c1bacd11cc | ||
|
|
743ccb6797 | ||
|
|
dab81f994d | ||
|
|
b17fa2a11d | ||
|
|
ea8a84420f | ||
|
|
56d98e16cd | ||
|
|
110994ea46 | ||
|
|
11bde27811 | ||
|
|
0f4ef4a3cd | ||
|
|
a3bb84e169 | ||
|
|
b9b90876f1 | ||
|
|
777a19b291 | ||
|
|
362930c481 | ||
|
|
8ca1636517 | ||
|
|
d6beafa13a | ||
|
|
f92d72b77a | ||
|
|
dca144d58b | ||
|
|
915d5c097c | ||
|
|
9a9f723b46 | ||
|
|
523f6aee3e | ||
|
|
e874454e40 | ||
|
|
45289702d5 | ||
|
|
1ab6366fd1 | ||
|
|
a01b06b8bc | ||
|
|
11dfa4c6f1 | ||
|
|
b94e4fc25d | ||
|
|
c1bb44bd55 | ||
|
|
1d8f92c663 | ||
|
|
e6686c024a | ||
|
|
2aa6e9c549 | ||
|
|
898a2b1f1b | ||
|
|
6a9a49952b | ||
|
|
0f76e50e4b | ||
|
|
b5ee1f2190 | ||
|
|
c5649e9d39 | ||
|
|
2a979af974 | ||
|
|
06964526dc | ||
|
|
b2b471dab5 | ||
|
|
d9695901b2 | ||
|
|
b85edaf6f0 | ||
|
|
b165ece770 | ||
|
|
a90e82a31e | ||
|
|
651e37051d | ||
|
|
77b6d1cb16 | ||
|
|
15b9551f7a | ||
|
|
c6f4e6066b | ||
|
|
7ebb60e564 | ||
|
|
b641cf9ca6 | ||
|
|
686697f202 | ||
|
|
296299e635 | ||
|
|
afa6804d09 | ||
|
|
c8b8b033ed | ||
|
|
f64f34b971 | ||
|
|
9b4f689dd3 | ||
|
|
cd32860300 | ||
|
|
7ed58eb38a | ||
|
|
291082e6c3 | ||
|
|
54f363be6e | ||
|
|
539ed1e3b8 | ||
|
|
9d61b50db5 | ||
|
|
8ede9de315 | ||
|
|
1cb43cee6b | ||
|
|
b2617c8933 | ||
|
|
beb1130050 | ||
|
|
1c75204854 | ||
|
|
80bba6e0fc | ||
|
|
47b0dabe75 | ||
|
|
da45c8c150 | ||
|
|
a57f2f01ca | ||
|
|
8fd7383217 | ||
|
|
baa6d87c2c | ||
|
|
5ae4c8c7a0 | ||
|
|
cbb7e82d23 | ||
|
|
2768e95920 | ||
|
|
5b7d641ad1 | ||
|
|
37a71d7580 | ||
|
|
aaa7b5de27 | ||
|
|
40bcbc59dc | ||
|
|
5e46ce0cb0 | ||
|
|
517c08091e | ||
|
|
e25dfb2a36 | ||
|
|
aa4e0abb44 | ||
|
|
3e5dc9a9f0 | ||
|
|
afd9541151 | ||
|
|
a5ab3b972c | ||
|
|
4f3350e4f9 | ||
|
|
2b8e1ed98f | ||
|
|
1b4bdff37e |
@@ -47,4 +47,4 @@ runs:
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
- 'services/**'
|
||||
- '!services/backend/src/rapodiff/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'package*.json'
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
- 'services/**'
|
||||
- '!services/backend/src/rapodiff/**'
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'package*.json'
|
||||
|
||||
+172
-66
@@ -40,9 +40,11 @@ jobs:
|
||||
outputs: type=docker,dest=/tmp/backend.tar
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: backend-image
|
||||
path: /tmp/backend.tar
|
||||
|
||||
@@ -51,23 +53,69 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build frontend image
|
||||
id: build-image
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
image: oodikone-frontend
|
||||
tags: staging ${{ github.sha }}
|
||||
context: services
|
||||
containerfiles: |
|
||||
services/frontend/Dockerfile
|
||||
build-args: |
|
||||
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
|
||||
BASE_PATH=/oodikone
|
||||
STAGING=true
|
||||
context: services
|
||||
file: services/frontend/Dockerfile
|
||||
push: false
|
||||
tags: oodikone-frontend:staging
|
||||
outputs: type=docker,dest=/tmp/frontend.tar
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
# - name: 'Upload image for other jobs'
|
||||
# uses: actions/upload-artifact@v7
|
||||
# with:
|
||||
# if-no-files-found: error
|
||||
# name: frontend-image
|
||||
# path: /tmp/frontend.tar
|
||||
|
||||
pull_docker_images:
|
||||
name: 'Pull all needed docker images and cache them'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Pull required images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone --profile updater pull --ignore-buildable --quiet
|
||||
|
||||
- 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
|
||||
|
||||
# === Update databases ===
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ubuntu-latest
|
||||
@@ -94,7 +142,9 @@ jobs:
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
needs: check_migration_files
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
steps:
|
||||
@@ -105,15 +155,26 @@ jobs:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- 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: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach sis-updater-worker
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
|
||||
|
||||
build_and_push_anon_kone_db:
|
||||
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
@@ -121,18 +182,29 @@ jobs:
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: 'Download Docker images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach backend
|
||||
@@ -142,6 +214,7 @@ jobs:
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
@@ -156,82 +229,44 @@ jobs:
|
||||
migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
|
||||
- name: 'Download Docker images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach backend
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Run services, pull rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- name: 'Check that exit statuses are not errors'
|
||||
run: |
|
||||
docker ps -a
|
||||
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
|
||||
echo 'Some process had nonzero exit code';
|
||||
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
|
||||
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
needs:
|
||||
- test_service_exit_statuses
|
||||
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -239,7 +274,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24 for cypress
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -261,7 +296,7 @@ jobs:
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: Start oodikone (without frontend and backend)
|
||||
run: npm run oodikone:ci:setup
|
||||
run: npm run oodikone:ci:setup -- --quiet-pull
|
||||
|
||||
- name: Run api-tests
|
||||
run: npm run oodikone:ci:api
|
||||
@@ -269,9 +304,12 @@ jobs:
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
needs:
|
||||
- test_service_exit_statuses
|
||||
- test_api
|
||||
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -321,9 +359,21 @@ jobs:
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
# - name: 'Download frontend image'
|
||||
# uses: actions/download-artifact@v8
|
||||
# with:
|
||||
# name: frontend-image
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
# - name: 'Load built frontend image'
|
||||
# run: docker load --input frontend.tar
|
||||
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
@@ -332,3 +382,59 @@ jobs:
|
||||
password: ${{ secrets.password }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Download Docker images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
# - name: 'Download frontend image'
|
||||
# uses: actions/download-artifact@v8
|
||||
# with:
|
||||
# name: frontend-image
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
# - name: 'Load built frontend image'
|
||||
# run: docker load --input frontend.tar
|
||||
|
||||
- name: 'Run services, build rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- name: 'Check that exit statuses are not errors'
|
||||
run: |
|
||||
docker ps -a
|
||||
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
|
||||
echo 'Some process had nonzero exit code';
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
|
||||
|
||||
@@ -17,6 +17,10 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install safe-chain
|
||||
run: set -o pipefail && curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
|
||||
+4
-5
@@ -5,11 +5,10 @@ const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodi
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
export default {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': () => 'eslint --fix --report-unused-disable-directives',
|
||||
'services/backend/**/*.{js.jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{js,jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': () => 'prettier . --write',
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Oodikone
|
||||
|
||||
[](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
|
||||
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
|
||||
@@ -129,7 +130,6 @@ Some useful commands are defined in `package.json` and can be run with `npm run
|
||||
- `npm run docker:down`: stops the whole environment
|
||||
- `npm run testupdater`: runs tests for updater
|
||||
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
|
||||
- `npm run rapodiff`: runs [Rapodiff](./services/backend/src/rapodiff/)
|
||||
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
|
||||
- `npm run install:docker`: installs `node_modules` for each package inside containers
|
||||
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
|
||||
@@ -187,8 +187,6 @@ Continuous integration (CI) works with Github actions and is defined in workflow
|
||||
- Updater is deployed to production when pushed if tests pass
|
||||
- Test databases can be updated, instructions in [anonyymioodi README](anonyymioodi/README.md)
|
||||
|
||||
[Rapodiff](./services/backend/src/rapodiff/) can be used to check differences between Rapo and Oodikone data.
|
||||
|
||||
## ❓FAQ
|
||||
|
||||
### Modules are missing after updating package.json
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script will first make db dumps of the test environment and upload the dumps to staging
|
||||
|
||||
|
||||
. ../scripts/utils.sh
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
declare -r db_names="sis-db kone-db user-db"
|
||||
declare -A db_url_names
|
||||
db_url_names["sis-db"]="SIS_DB_URL"
|
||||
db_url_names["kone-db"]="DB_URL_KONE"
|
||||
db_url_names["user-db"]="DB_URL_USER"
|
||||
|
||||
declare -r dump_directory="dumps"
|
||||
|
||||
# Takes the first db-tools pod
|
||||
check_oc_logged_staging
|
||||
db_tools_name=$(oc get pods -l app=db-tools --output jsonpath='{.items[0].metadata.name}')
|
||||
readonly db_tools_name
|
||||
|
||||
|
||||
create_dumps
|
||||
upload_and_migrate_dumps
|
||||
}
|
||||
|
||||
|
||||
|
||||
function create_dumps() {
|
||||
echo "Starting test/anon containers..."
|
||||
(npm run docker:down && npm run both) || die "Failed to start containers"
|
||||
echo
|
||||
echo "Waiting for containers to start..."
|
||||
sleep 10 # wait for containers to start
|
||||
|
||||
# Create dumps for all dbs
|
||||
echo
|
||||
echo "Creating dumps for $db_names"
|
||||
echo
|
||||
|
||||
# Create dir if not exists
|
||||
if [ ! -d $dump_directory ]; then mkdir -p $dump_directory; fi
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
create_dump_from_db "$db"
|
||||
if [ ! -f "$dump_directory/$db.sqz" ]; then die "Db dump not created"; fi
|
||||
done
|
||||
|
||||
echo "Dumps can be found in $PWD/\*.sqz"
|
||||
}
|
||||
|
||||
|
||||
# $1 db-name
|
||||
function create_dump_from_db() {
|
||||
echo "Dumping db $1..."
|
||||
|
||||
docker exec "$1" pg_dump -Fc -U postgres "$1" > "$dump_directory/$1.sqz" || die "Failed to dump db $1"
|
||||
}
|
||||
|
||||
|
||||
function check_oc_logged_staging() {
|
||||
echo -n "Checking if oc is logged into toska staging..."
|
||||
[[ $(oc config current-context) == "toska/api-ocp-test-0-k8s-it-helsinki-fi"* ]] || die "\nNot logged in to toska staging environment: $(oc config current-context)"
|
||||
echo " OK"
|
||||
}
|
||||
|
||||
|
||||
function upload_and_migrate_dumps() {
|
||||
|
||||
local target_dump_directory="$db_tools_name:/tmp/oodikone-staging-dumps"
|
||||
echo -n "Dumping $dump_directory/ to $target_dump_directory..."
|
||||
oc rsync "$dump_directory/" "$target_dump_directory" 1> /dev/null # show errors
|
||||
echo " OK"
|
||||
echo
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
migrate_dumps "$db"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
function migrate_dumps() {
|
||||
local db_url_b64
|
||||
local db_url_raw
|
||||
local db_url
|
||||
|
||||
db_url_b64=$(oc get secrets -l app.kubernetes.io/instance=oodikone --output jsonpath="{.items[0].data.${db_url_names[$1]}}")
|
||||
db_url_raw=$(echo "$db_url_b64" | base64 --decode)
|
||||
db_url="${db_url_raw%\?*}"
|
||||
|
||||
[[ $db_url != *"oodikone_*_test"* ]] || die "Trying to use non-test database: $db_url"
|
||||
|
||||
echo "Migrating $1 to url: $db_url"
|
||||
|
||||
oc exec "$db_tools_name" -- pg_restore --clean --no-owner -d "$db_url" "/tmp/oodikone-staging-dumps/$1.sqz"
|
||||
}
|
||||
|
||||
|
||||
# Run script
|
||||
main
|
||||
+292
-273
@@ -92,17 +92,17 @@ const clickAway = () => {
|
||||
|
||||
const getCourseSearchHooksForSingleYear = code => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': code },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
}
|
||||
|
||||
@@ -185,13 +185,16 @@ describe('Basic user', () => {
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
|
||||
cy.contains('50131 • Pro gradu -tutkielma tietojenkäsittelytieteessä')
|
||||
cy.contains('50131') // Pro gradu -tutkielma tietojenkäsittelytieteessä
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
cy.contains('Show population').click()
|
||||
|
||||
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
cy.contains('Population of course Pro gradu -tutkielma tietojenkäsittelytieteessä 2007-2020 (open and normal)')
|
||||
cy.contains('Pro gradu -tutkielma tietojenkäsittelytieteessä')
|
||||
cy.contains('Class of 2007-2020, 10 students')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
cy.contains('Showing 10 out of 10 students')
|
||||
|
||||
const gradesTableContents = [
|
||||
@@ -214,17 +217,15 @@ describe('Basic user', () => {
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
|
||||
cy.contains(
|
||||
'ON-310 • Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot'
|
||||
)
|
||||
cy.contains('200012 • Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
|
||||
cy.contains('ON-310') // Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot
|
||||
cy.contains('200012') // Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
cy.contains('Show population').click()
|
||||
|
||||
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains(
|
||||
'Population of course Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
|
||||
)
|
||||
cy.contains('Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
|
||||
cy.contains('Class of 2011-2018, 4 students')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
cy.contains('Showing 4 out of 4 students')
|
||||
|
||||
const gradesTableContents = [
|
||||
@@ -263,17 +264,17 @@ describe('Basic user', () => {
|
||||
|
||||
it('Searching multiple courses having substitution mappings shows course statistics', () => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code: 'TKT' },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
@@ -309,17 +310,17 @@ describe('Basic user', () => {
|
||||
|
||||
it('On searches with multiple courses, has correct links on the Course tab', () => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code: 'BSCS' },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
@@ -338,7 +339,9 @@ describe('Basic user', () => {
|
||||
cy.url().should('contain', 'courseCodes=%5B%22BSCS1001%22%2C%22BSCS1003%22%5D')
|
||||
|
||||
cy.cs('course-population-for-2023-2024').click()
|
||||
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
|
||||
cy.contains('Introduction to Programming')
|
||||
cy.contains('Class of 2023-2024')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
// NOTE: These should also have `cy.intercept`s.
|
||||
// The calls should be either cached or cheap so this is only
|
||||
@@ -349,7 +352,9 @@ describe('Basic user', () => {
|
||||
// Check the link has updated correctly
|
||||
cy.cs('course-population-for-2022-2023').invoke('attr', 'href').should('include', 'BSCS1003')
|
||||
cy.cs('course-population-for-2022-2023').click()
|
||||
cy.contains('Population of course Data Structures and Algorithms 2022-2023 (open and normal)')
|
||||
cy.contains('Data Structures and Algorithms')
|
||||
cy.contains('Class of 2022-2023')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
})
|
||||
|
||||
it('Searching course by name displays right courses', () => {
|
||||
@@ -361,11 +366,11 @@ describe('Basic user', () => {
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT10004 • Tietokantojen perusteet')
|
||||
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 • Introduction to Databases')
|
||||
cy.contains('581328 • Tietokantojen perusteet')
|
||||
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('TKT10004') // Tietokantojen perusteet
|
||||
cy.contains('AYTKT10004') // Avoin yo: Tietokantojen perusteet
|
||||
cy.contains('BSCS2001') // Introduction to Databases
|
||||
cy.contains('581328') // Tietokantojen perusteet
|
||||
cy.contains('A581328') // Avoin yo: Tietokantojen perusteet
|
||||
clickNewQueryButton()
|
||||
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
|
||||
})
|
||||
@@ -377,20 +382,20 @@ describe('Basic user', () => {
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
|
||||
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
|
||||
cy.contains('BSCS1003') // Data Structures and Algorithms
|
||||
cy.contains('58131') // Tietorakenteet
|
||||
clickNewQueryButton()
|
||||
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
|
||||
searchByCourseName('tietorakenteet ja algoritmit')
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
|
||||
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
|
||||
cy.contains('BSCS1003') // Data Structures and Algorithms
|
||||
cy.contains('58131') // Tietorakenteet
|
||||
})
|
||||
|
||||
it('"Select all search results" button is not showing unless "Select multiple courses" toggle is on', () => {
|
||||
@@ -410,11 +415,11 @@ describe('Basic user', () => {
|
||||
cy.cs(`course-TKT10004`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT10004 • Tietokantojen perusteet')
|
||||
cy.contains('AYTKT10004 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('BSCS2001 • Introduction to Databases')
|
||||
cy.contains('581328 • Tietokantojen perusteet')
|
||||
cy.contains('A581328 • Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('TKT10004') // Tietokantojen perusteet
|
||||
cy.contains('AYTKT10004') // Avoin yo: Tietokantojen perusteet
|
||||
cy.contains('BSCS2001') // Introduction to Databases
|
||||
cy.contains('581328') // Tietokantojen perusteet
|
||||
cy.contains('A581328') // Avoin yo: Tietokantojen perusteet
|
||||
|
||||
cy.cs('ProviderOrganizationSelect').click()
|
||||
cy.cs('ProviderOrganizationSelectOptionBoth').should('have.class', 'Mui-selected')
|
||||
@@ -438,10 +443,14 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT20003')
|
||||
cy.contains('tr', 'TKT20003').click()
|
||||
cy.contains('TKT20003 • Käyttöjärjestelmät')
|
||||
cy.contains('582219 • Käyttöjärjestelmät')
|
||||
cy.contains('TKT20003') // Käyttöjärjestelmät
|
||||
cy.contains('582219') // Käyttöjärjestelmät
|
||||
cy.cs('course-population-for-2020-2021').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2020-2021 (open and normal')
|
||||
|
||||
cy.contains('Käyttöjärjestelmät')
|
||||
cy.contains('Class of 2020-2021, 19 students')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
cy.contains('TKT20003')
|
||||
|
||||
cy.contains('Students (19)').click()
|
||||
@@ -452,12 +461,16 @@ describe('Basic user', () => {
|
||||
it('Population of course shows grades for each student', () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
|
||||
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
|
||||
cy.contains('BSCS1003') // Data Structures and Algorithms
|
||||
cy.contains('58131') // Tietorakenteet
|
||||
cy.cs('course-population-for-2019-2020').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
|
||||
|
||||
cy.contains('Tietorakenteet ja algoritmit')
|
||||
cy.contains('Class of 2019-2020, 33 students')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
cy.contains('Students (33)').click()
|
||||
cy.contains('td', '394776').siblings().eq(2).contains('3')
|
||||
cy.contains('td', '497388').siblings().eq(2).contains('2')
|
||||
@@ -466,12 +479,16 @@ describe('Basic user', () => {
|
||||
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
cy.contains('58131 • Tietorakenteet')
|
||||
cy.contains('TKT20001') // Tietorakenteet ja algoritmit
|
||||
cy.contains('AYTKT20001') // Avoin yo: Tietorakenteet ja algoritmit
|
||||
cy.contains('BSCS1003') // Data Structures and Algorithms
|
||||
cy.contains('58131') // Tietorakenteet
|
||||
cy.cs('course-population-for-2019-2020').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
|
||||
|
||||
cy.contains('Tietorakenteet ja algoritmit')
|
||||
cy.contains('Class of 2019-2020, 33 students')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
cy.contains('Students (33)').click()
|
||||
cy.get('table tbody td').filter(':contains("Hidden")').should('have.length', 9)
|
||||
})
|
||||
@@ -480,10 +497,14 @@ describe('Basic user', () => {
|
||||
it('Language distribution is correct', () => {
|
||||
searchByCourseCode('TKT20003')
|
||||
cy.cs(`course-TKT20003`).click()
|
||||
cy.contains('TKT20003 • Käyttöjärjestelmät')
|
||||
cy.contains('582219 • Käyttöjärjestelmät')
|
||||
cy.contains('TKT20003') // Käyttöjärjestelmät
|
||||
cy.contains('582219') // Käyttöjärjestelmät
|
||||
cy.cs('course-population-for-2021-2022').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2021-2022 (open and normal)')
|
||||
|
||||
cy.contains('Käyttöjärjestelmät')
|
||||
cy.contains('Class of 2021-2022')
|
||||
cy.contains('Include substitutions, Open and normal')
|
||||
|
||||
cy.contains('Language distribution').click()
|
||||
cy.contains('td', 'finnish').siblings().eq(0).contains('5')
|
||||
cy.contains('td', 'english').siblings().eq(0).contains('2')
|
||||
@@ -499,7 +520,7 @@ describe('Basic user', () => {
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.cs(`course-TKT10002`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
cy.contains('TKT10002') // Ohjelmoinnin perusteet
|
||||
})
|
||||
|
||||
it('Time range', () => {
|
||||
@@ -538,14 +559,14 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 187, 140, 11, 36, '74.87 %', '25.13 %'],
|
||||
['Total', 183, 140, 6, 37, '76.50 %', '23.50 %'],
|
||||
['2023-2024', 7, 1, 0, 6, '14.29 %', '85.71 %'],
|
||||
['2022-2023', 33, 27, 0, 6, '81.82 %', '18.18 %'],
|
||||
['2021-2022', 39, 15, 0, 24, '38.46 %', '61.54 %'],
|
||||
['2020-2021', 23, 23, 0, null, '100.00 %', '0.00 %'],
|
||||
['2022-2023', 32, 26, 0, 6, '81.25 %', '18.75 %'],
|
||||
['2021-2022', 39, 14, 0, 25, '35.90 %', '64.10 %'],
|
||||
['2020-2021', 21, 21, 0, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 28, 28, 0, null, '100.00 %', '0.00 %'],
|
||||
['2018-2019', 30, 26, 4, null, '86.67 %', '13.33 %'],
|
||||
['2017-2018', 26, 19, 7, null, '73.08 %', '26.92 %'],
|
||||
['2018-2019', 29, 27, 2, null, '93.10 %', '6.90 %'],
|
||||
['2017-2018', 26, 22, 4, null, '84.62 %', '15.38 %'],
|
||||
['2016-2017', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
checkTableContents(tableContents)
|
||||
@@ -554,21 +575,21 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 191, 140, 13, 38, '73.30 %', '26.70 %'],
|
||||
['Total', 185, 140, 6, 39, '75.68 %', '24.32 %'],
|
||||
['Syksy 2023', 7, 1, 0, 6, '14.29 %', '85.71 %'],
|
||||
['Kevät 2023', 10, 9, 0, 1, '90.00 %', '10.00 %'],
|
||||
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
|
||||
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
|
||||
['Kevät 2022', 26, 9, 0, 17, '34.62 %', '65.38 %'],
|
||||
['Syksy 2021', 15, 6, 0, 9, '40.00 %', '60.00 %'],
|
||||
['Kevät 2021', 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 19, 19, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 25, 24, 1, null, '96.00 %', '4.00 %'],
|
||||
['Kevät 2019', 8, 8, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2018', 22, 18, 4, null, '81.82 %', '18.18 %'],
|
||||
['Kevät 2018', 19, 12, 7, null, '63.16 %', '36.84 %'],
|
||||
['Syksy 2017', 8, 7, 1, null, '87.50 %', '12.50 %'],
|
||||
['Kevät 2017', 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2021', 15, 5, 0, 10, '33.33 %', '66.67 %'],
|
||||
['Kevät 2021', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 18, 18, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 25, 25, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2019', 7, 7, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2018', 22, 20, 2, null, '90.91 %', '9.09 %'],
|
||||
['Kevät 2018', 18, 14, 4, null, '77.78 %', '22.22 %'],
|
||||
['Syksy 2017', 8, 8, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2017', 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2016', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -578,14 +599,14 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 187, 11, 3, 8, 5, 24, 99, 1, 36, '74.87 %', '25.13 %'],
|
||||
['Total', 183, 6, 3, 8, 5, 24, 99, 1, 37, '76.50 %', '23.50 %'],
|
||||
['2023-2024', 7, 0, 0, 0, 0, 0, 1, 0, 6, '14.29 %', '85.71 %'],
|
||||
['2022-2023', 33, 0, 0, 0, 0, 6, 21, 0, 6, '81.82 %', '18.18 %'],
|
||||
['2021-2022', 39, 0, 0, 0, 0, 2, 13, 0, 24, '38.46 %', '61.54 %'],
|
||||
['2020-2021', 23, 0, 0, 2, 0, 2, 18, 1, null, '100.00 %', '0.00 %'],
|
||||
['2022-2023', 32, 0, 0, 0, 0, 6, 20, 0, 6, '81.25 %', '18.75 %'],
|
||||
['2021-2022', 39, 0, 0, 0, 0, 2, 12, 0, 25, '35.90 %', '64.10 %'],
|
||||
['2020-2021', 21, 0, 0, 1, 0, 2, 17, 1, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 28, 0, 1, 4, 1, 5, 17, 0, null, '100.00 %', '0.00 %'],
|
||||
['2018-2019', 30, 4, 2, 1, 2, 3, 18, 0, null, '86.67 %', '13.33 %'],
|
||||
['2017-2018', 26, 7, 0, 1, 2, 5, 11, 0, null, '73.08 %', '26.92 %'],
|
||||
['2018-2019', 29, 2, 2, 1, 2, 3, 19, 0, null, '93.10 %', '6.90 %'],
|
||||
['2017-2018', 26, 4, 0, 2, 2, 5, 13, 0, null, '84.62 %', '15.38 %'],
|
||||
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -595,21 +616,21 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 191, 13, 3, 8, 5, 24, 99, 1, 38, '73.30 %', '26.70 %'],
|
||||
['Total', 185, 6, 3, 8, 5, 24, 99, 1, 39, '75.68 %', '24.32 %'],
|
||||
['Syksy 2023', 7, 0, 0, 0, 0, 0, 1, 0, 6, '14.29 %', '85.71 %'],
|
||||
['Kevät 2023', 10, 0, 0, 0, 0, 3, 6, 0, 1, '90.00 %', '10.00 %'],
|
||||
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
|
||||
['Syksy 2022', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
|
||||
['Kevät 2022', 26, 0, 0, 0, 0, 2, 7, 0, 17, '34.62 %', '65.38 %'],
|
||||
['Syksy 2021', 15, 0, 0, 0, 0, 0, 6, 0, 9, '40.00 %', '60.00 %'],
|
||||
['Kevät 2021', 4, 0, 0, 0, 0, 0, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 19, 0, 0, 2, 0, 2, 14, 1, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 4, 0, 1, 0, 1, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 25, 1, 0, 4, 0, 5, 15, 0, null, '96.00 %', '4.00 %'],
|
||||
['Kevät 2019', 8, 0, 0, 0, 1, 1, 6, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2018', 22, 4, 2, 1, 1, 2, 12, 0, null, '81.82 %', '18.18 %'],
|
||||
['Kevät 2018', 19, 7, 0, 1, 0, 3, 8, 0, null, '63.16 %', '36.84 %'],
|
||||
['Syksy 2017', 8, 1, 0, 0, 2, 2, 3, 0, null, '87.50 %', '12.50 %'],
|
||||
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2021', 15, 0, 0, 0, 0, 0, 5, 0, 10, '33.33 %', '66.67 %'],
|
||||
['Kevät 2021', 3, 0, 0, 0, 0, 0, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 18, 0, 0, 1, 0, 2, 14, 1, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 3, 0, 0, 0, 1, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 25, 0, 1, 4, 0, 5, 15, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2019', 7, 0, 0, 0, 1, 1, 5, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2018', 22, 2, 2, 1, 1, 2, 14, 0, null, '90.91 %', '9.09 %'],
|
||||
['Kevät 2018', 18, 4, 0, 2, 0, 3, 9, 0, null, '77.78 %', '22.22 %'],
|
||||
['Syksy 2017', 8, 0, 0, 0, 2, 2, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2016', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -644,15 +665,15 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
['Total', 186, 140, 16, '89.74 %', 73],
|
||||
['2023-2024', 6, 1, 0, '16.67 %', 6],
|
||||
['2022-2023', 26, 27, 0, '100.00 %', 26],
|
||||
['2021-2022', 41, 15, 0, '36.59 %', 41],
|
||||
['2020-2021', 23, 23, 0, '100.00 %', null],
|
||||
['2019-2020', 29, 28, 1, '96.55 %', null],
|
||||
['2018-2019', 32, 26, 6, '81.25 %', null],
|
||||
['2017-2018', 28, 19, 9, '67.86 %', null],
|
||||
['2016-2017', 1, 1, 0, '100.00 %', null],
|
||||
['Total', 178, 140, 6, '95.89 %', 73],
|
||||
['2023-2024', 6, 1, null, '16.67 %', 6],
|
||||
['2022-2023', 26, 26, null, '100.00 %', 26],
|
||||
['2021-2022', 41, 14, null, '34.15 %', 41],
|
||||
['2020-2021', 21, 21, null, '100.00 %', null],
|
||||
['2019-2020', 28, 28, null, '100.00 %', null],
|
||||
['2018-2019', 29, 27, 2, '93.10 %', null],
|
||||
['2017-2018', 26, 22, 4, '84.62 %', null],
|
||||
['2016-2017', 1, 1, null, '100.00 %', null],
|
||||
]
|
||||
checkTableContents(tableContents)
|
||||
})
|
||||
@@ -660,20 +681,20 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
['Total', 186, 140, 16, '89.74 %', 73],
|
||||
['Total', 178, 140, 6, '95.89 %', 73],
|
||||
['Syksy 2023', 6, 1, 0, '16.67 %', 6],
|
||||
['Kevät 2023', 11, 9, 0, '81.82 %', 11],
|
||||
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
|
||||
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
|
||||
['Kevät 2022', 29, 9, 0, '31.03 %', 29],
|
||||
['Syksy 2021', 12, 6, 0, '50.00 %', 12],
|
||||
['Kevät 2021', 4, 4, 0, '100.00 %', null],
|
||||
['Syksy 2020', 19, 19, 0, '100.00 %', null],
|
||||
['Kevät 2020', 4, 4, 0, '100.00 %', null],
|
||||
['Syksy 2019', 25, 24, 1, '96.00 %', null],
|
||||
['Kevät 2019', 8, 8, 0, '100.00 %', null],
|
||||
['Syksy 2018', 24, 18, 6, '75.00 %', null],
|
||||
['Kevät 2018', 20, 12, 8, '60.00 %', null],
|
||||
['Syksy 2017', 8, 7, 1, '87.50 %', null],
|
||||
['Syksy 2021', 12, 5, 0, '41.67 %', 12],
|
||||
['Kevät 2021', 3, 3, 0, '100.00 %', null],
|
||||
['Syksy 2020', 18, 18, 0, '100.00 %', null],
|
||||
['Kevät 2020', 3, 3, 0, '100.00 %', null],
|
||||
['Syksy 2019', 25, 25, 0, '100.00 %', null],
|
||||
['Kevät 2019', 7, 7, 0, '100.00 %', null],
|
||||
['Syksy 2018', 22, 20, 2, '90.91 %', null],
|
||||
['Kevät 2018', 18, 14, 4, '77.78 %', null],
|
||||
['Syksy 2017', 8, 8, 0, '100.00 %', null],
|
||||
['Kevät 2017', 0, 0, 0, '–', 0],
|
||||
['Syksy 2016', 1, 1, 0, '100.00 %', null],
|
||||
]
|
||||
@@ -684,14 +705,14 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
|
||||
['Total', 186, 16, 3, 8, 5, 24, 99, 1],
|
||||
['Total', 178, 6, 3, 8, 5, 24, 99, 1],
|
||||
['2023-2024', 6, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2022-2023', 26, 0, 0, 0, 0, 6, 21, 0],
|
||||
['2021-2022', 41, 0, 0, 0, 0, 2, 13, 0],
|
||||
['2020-2021', 23, 0, 0, 2, 0, 2, 18, 1],
|
||||
['2019-2020', 29, 1, 1, 4, 1, 5, 17, 0],
|
||||
['2018-2019', 32, 6, 2, 1, 2, 3, 18, 0],
|
||||
['2017-2018', 28, 9, 0, 1, 2, 5, 11, 0],
|
||||
['2022-2023', 26, 0, 0, 0, 0, 6, 20, 0],
|
||||
['2021-2022', 41, 0, 0, 0, 0, 2, 12, 0],
|
||||
['2020-2021', 21, 0, 0, 1, 0, 2, 17, 1],
|
||||
['2019-2020', 28, 0, 1, 4, 1, 5, 17, 0],
|
||||
['2018-2019', 29, 2, 2, 1, 2, 3, 19, 0],
|
||||
['2017-2018', 26, 4, 0, 2, 2, 5, 13, 0],
|
||||
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
]
|
||||
toggleShowGrades()
|
||||
@@ -701,20 +722,20 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
|
||||
['Total', 186, 16, 3, 8, 5, 24, 99, 1],
|
||||
['Total', 178, 6, 3, 8, 5, 24, 99, 1],
|
||||
['Syksy 2023', 6, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Kevät 2023', 11, 0, 0, 0, 0, 3, 6, 0],
|
||||
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
|
||||
['Syksy 2022', 15, 0, 0, 0, 0, 3, 14, 0],
|
||||
['Kevät 2022', 29, 0, 0, 0, 0, 2, 7, 0],
|
||||
['Syksy 2021', 12, 0, 0, 0, 0, 0, 6, 0],
|
||||
['Kevät 2021', 4, 0, 0, 0, 0, 0, 4, 0],
|
||||
['Syksy 2020', 19, 0, 0, 2, 0, 2, 14, 1],
|
||||
['Kevät 2020', 4, 0, 1, 0, 1, 0, 2, 0],
|
||||
['Syksy 2019', 25, 1, 0, 4, 0, 5, 15, 0],
|
||||
['Kevät 2019', 8, 0, 0, 0, 1, 1, 6, 0],
|
||||
['Syksy 2018', 24, 6, 2, 1, 1, 2, 12, 0],
|
||||
['Kevät 2018', 20, 8, 0, 1, 0, 3, 8, 0],
|
||||
['Syksy 2017', 8, 1, 0, 0, 2, 2, 3, 0],
|
||||
['Syksy 2021', 12, 0, 0, 0, 0, 0, 5, 0],
|
||||
['Kevät 2021', 3, 0, 0, 0, 0, 0, 3, 0],
|
||||
['Syksy 2020', 18, 0, 0, 1, 0, 2, 14, 1],
|
||||
['Kevät 2020', 3, 0, 0, 0, 1, 0, 2, 0],
|
||||
['Syksy 2019', 25, 0, 1, 4, 0, 5, 15, 0],
|
||||
['Kevät 2019', 7, 0, 0, 0, 1, 1, 5, 0],
|
||||
['Syksy 2018', 22, 2, 2, 1, 1, 2, 14, 0],
|
||||
['Kevät 2018', 18, 4, 0, 2, 0, 3, 9, 0],
|
||||
['Syksy 2017', 8, 0, 0, 0, 2, 2, 4, 0],
|
||||
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2016', 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
]
|
||||
@@ -732,11 +753,11 @@ describe('Basic user', () => {
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.cs(`course-TKT10002`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
cy.contains('AYTKT10002 • Avoin yo: Ohjelmoinnin perusteet')
|
||||
cy.contains('BSCS1001 • Introduction to Programming')
|
||||
cy.contains('581325 • Ohjelmoinnin perusteet')
|
||||
cy.contains('A581325 • Avoin yo: Ohjelmoinnin perusteet')
|
||||
cy.contains('TKT10002') // Ohjelmoinnin perusteet
|
||||
cy.contains('AYTKT10002') // Avoin yo: Ohjelmoinnin perusteet
|
||||
cy.contains('BSCS1001') // Introduction to Programming
|
||||
cy.contains('581325') // Ohjelmoinnin perusteet
|
||||
cy.contains('A581325') // Avoin yo: Ohjelmoinnin perusteet
|
||||
})
|
||||
|
||||
it('Time range', () => {
|
||||
@@ -757,18 +778,18 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 284, 237, 16, 31, '83.45 %', '16.55 %'],
|
||||
['Total', 277, 237, 8, 32, '85.56 %', '14.44 %'],
|
||||
['2023-2024', 8, 2, 0, 6, '25.00 %', '75.00 %'],
|
||||
['2022-2023', 35, 28, 0, 7, '80.00 %', '20.00 %'],
|
||||
['2021-2022', 45, 27, 0, 18, '60.00 %', '40.00 %'],
|
||||
['2020-2021', 33, 33, 0, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 57, 56, 1, null, '98.25 %', '1.75 %'],
|
||||
['2018-2019', 42, 38, 4, null, '90.48 %', '9.52 %'],
|
||||
['2017-2018', 32, 25, 7, null, '78.13 %', '21.88 %'],
|
||||
['2016-2017', 7, 6, 1, null, '85.71 %', '14.29 %'],
|
||||
['2015-2016', 4, 3, 1, null, '75.00 %', '25.00 %'],
|
||||
['2014-2015', 6, 6, 0, null, '100.00 %', '0.00 %'],
|
||||
['2013-2014', 2, 1, 1, null, '50.00 %', '50.00 %'],
|
||||
['2022-2023', 34, 27, 0, 7, '79.41 %', '20.59 %'],
|
||||
['2021-2022', 45, 26, 0, 19, '57.78 %', '42.22 %'],
|
||||
['2020-2021', 31, 31, 0, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 56, 56, 0, null, '100.00 %', '0.00 %'],
|
||||
['2018-2019', 41, 39, 2, null, '95.12 %', '4.88 %'],
|
||||
['2017-2018', 32, 28, 4, null, '87.50 %', '12.50 %'],
|
||||
['2016-2017', 6, 5, 1, null, '83.33 %', '16.67 %'],
|
||||
['2015-2016', 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['2014-2015', 5, 5, 0, null, '100.00 %', '0.00 %'],
|
||||
['2013-2014', 2, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['2012-2013', 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['2011-2012', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2010-2011', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
@@ -790,28 +811,28 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Passed, Failed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 293, 239, 19, 35, '81.57 %', '18.43 %'],
|
||||
['Total', 282, 237, 9, 36, '84.04 %', '15.96 %'],
|
||||
['Syksy 2023', 8, 2, 0, 6, '25.00 %', '75.00 %'],
|
||||
['Kevät 2023', 12, 10, 0, 2, '83.33 %', '16.67 %'],
|
||||
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
|
||||
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
|
||||
['Kevät 2022', 28, 11, 0, 17, '39.29 %', '60.71 %'],
|
||||
['Syksy 2021', 21, 16, 0, 5, '76.19 %', '23.81 %'],
|
||||
['Kevät 2021', 8, 8, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 25, 25, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 26, 26, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 33, 31, 2, null, '93.94 %', '6.06 %'],
|
||||
['Kevät 2019', 17, 16, 1, null, '94.12 %', '5.88 %'],
|
||||
['Syksy 2018', 27, 23, 4, null, '85.19 %', '14.81 %'],
|
||||
['Kevät 2018', 24, 17, 7, null, '70.83 %', '29.17 %'],
|
||||
['Syksy 2017', 9, 8, 1, null, '88.89 %', '11.11 %'],
|
||||
['Syksy 2021', 21, 15, 0, 6, '71.43 %', '28.57 %'],
|
||||
['Kevät 2021', 7, 7, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 24, 24, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 24, 24, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 32, 32, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2019', 15, 14, 1, null, '93.33 %', '6.67 %'],
|
||||
['Syksy 2018', 27, 25, 2, null, '92.59 %', '7.41 %'],
|
||||
['Kevät 2018', 23, 19, 4, null, '82.61 %', '17.39 %'],
|
||||
['Syksy 2017', 9, 9, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2017', 4, 3, 1, null, '75.00 %', '25.00 %'],
|
||||
['Syksy 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2016', 3, 2, 1, null, '66.67 %', '33.33 %'],
|
||||
['Syksy 2016', 2, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2015', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2015', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2014', 3, 3, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2014', 2, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2014', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2013', 1, 0, 1, null, '0.00 %', '100.00 %'],
|
||||
['Syksy 2013', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2013', 0, 0, 0, 0, '–', '–'],
|
||||
['Syksy 2012', 4, 4, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2012', 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
@@ -848,18 +869,18 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 284, 16, 4, 14, 9, 37, 168, 5, 31, '83.45 %', '16.55 %'],
|
||||
['Total', 277, 8, 4, 14, 9, 37, 168, 5, 32, '85.56 %', '14.44 %'],
|
||||
['2023-2024', 8, 0, 0, 0, 0, 0, 2, 0, 6, '25.00 %', '75.00 %'],
|
||||
['2022-2023', 35, 0, 0, 0, 0, 6, 22, 0, 7, '80.00 %', '20.00 %'],
|
||||
['2021-2022', 45, 0, 0, 1, 0, 2, 24, 0, 18, '60.00 %', '40.00 %'],
|
||||
['2020-2021', 33, 0, 0, 2, 1, 2, 27, 1, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 57, 1, 1, 5, 3, 9, 38, 0, null, '98.25 %', '1.75 %'],
|
||||
['2018-2019', 42, 4, 3, 2, 2, 6, 25, 0, null, '90.48 %', '9.52 %'],
|
||||
['2017-2018', 32, 7, 0, 2, 2, 6, 15, 0, null, '78.13 %', '21.88 %'],
|
||||
['2016-2017', 7, 1, 0, 0, 1, 1, 4, 0, null, '85.71 %', '14.29 %'],
|
||||
['2015-2016', 4, 1, 0, 1, 0, 0, 1, 1, null, '75.00 %', '25.00 %'],
|
||||
['2014-2015', 6, 0, 0, 1, 0, 0, 2, 3, null, '100.00 %', '0.00 %'],
|
||||
['2013-2014', 2, 1, 0, 0, 0, 0, 1, 0, null, '50.00 %', '50.00 %'],
|
||||
['2022-2023', 34, 0, 0, 0, 0, 6, 21, 0, 7, '79.41 %', '20.59 %'],
|
||||
['2021-2022', 45, 0, 0, 1, 0, 2, 23, 0, 19, '57.78 %', '42.22 %'],
|
||||
['2020-2021', 31, 0, 0, 1, 1, 2, 26, 1, null, '100.00 %', '0.00 %'],
|
||||
['2019-2020', 56, 0, 1, 5, 3, 9, 38, 0, null, '100.00 %', '0.00 %'],
|
||||
['2018-2019', 41, 2, 3, 2, 2, 6, 26, 0, null, '95.12 %', '4.88 %'],
|
||||
['2017-2018', 32, 4, 0, 3, 2, 6, 17, 0, null, '87.50 %', '12.50 %'],
|
||||
['2016-2017', 6, 1, 0, 0, 1, 1, 3, 0, null, '83.33 %', '16.67 %'],
|
||||
['2015-2016', 4, 0, 0, 1, 0, 0, 2, 1, null, '100.00 %', '0.00 %'],
|
||||
['2014-2015', 5, 0, 0, 1, 0, 0, 1, 3, null, '100.00 %', '0.00 %'],
|
||||
['2013-2014', 2, 0, 0, 0, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['2012-2013', 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2011-2012', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['2010-2011', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
|
||||
@@ -882,28 +903,28 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total students, Failed, 1, 2, 3, 4, 5, Other passed, Enrolled no grade, Pass rate, Fail rate]
|
||||
['Total', 293, 19, 5, 15, 9, 37, 168, 5, 35, '81.57 %', '18.43 %'],
|
||||
['Total', 282, 9, 4, 14, 9, 37, 168, 5, 36, '84.04 %', '15.96 %'],
|
||||
['Syksy 2023', 8, 0, 0, 0, 0, 0, 2, 0, 6, '25.00 %', '75.00 %'],
|
||||
['Kevät 2023', 12, 0, 0, 0, 0, 3, 7, 0, 2, '83.33 %', '16.67 %'],
|
||||
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
|
||||
['Syksy 2022', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
|
||||
['Kevät 2022', 28, 0, 0, 0, 0, 2, 9, 0, 17, '39.29 %', '60.71 %'],
|
||||
['Syksy 2021', 21, 0, 0, 1, 0, 0, 15, 0, 5, '76.19 %', '23.81 %'],
|
||||
['Kevät 2021', 8, 0, 0, 0, 0, 0, 8, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 25, 0, 0, 2, 1, 2, 19, 1, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 26, 0, 1, 1, 2, 4, 18, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 33, 2, 1, 4, 1, 5, 20, 0, null, '93.94 %', '6.06 %'],
|
||||
['Kevät 2019', 17, 1, 1, 1, 1, 2, 11, 0, null, '94.12 %', '5.88 %'],
|
||||
['Syksy 2018', 27, 4, 2, 2, 1, 4, 14, 0, null, '85.19 %', '14.81 %'],
|
||||
['Kevät 2018', 24, 7, 0, 2, 0, 4, 11, 0, null, '70.83 %', '29.17 %'],
|
||||
['Syksy 2017', 9, 1, 0, 0, 2, 2, 4, 0, null, '88.89 %', '11.11 %'],
|
||||
['Syksy 2021', 21, 0, 0, 1, 0, 0, 14, 0, 6, '71.43 %', '28.57 %'],
|
||||
['Kevät 2021', 7, 0, 0, 0, 0, 0, 7, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2020', 24, 0, 0, 1, 1, 2, 19, 1, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2020', 24, 0, 0, 1, 2, 4, 17, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2019', 32, 0, 1, 4, 1, 5, 21, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2019', 15, 1, 1, 1, 1, 2, 9, 0, null, '93.33 %', '6.67 %'],
|
||||
['Syksy 2018', 27, 2, 2, 1, 1, 4, 17, 0, null, '92.59 %', '7.41 %'],
|
||||
['Kevät 2018', 23, 4, 0, 3, 0, 4, 12, 0, null, '82.61 %', '17.39 %'],
|
||||
['Syksy 2017', 9, 0, 0, 0, 2, 2, 5, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0, null, '75.00 %', '25.00 %'],
|
||||
['Syksy 2016', 3, 0, 0, 0, 0, 1, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2016', 3, 1, 0, 1, 0, 0, 1, 0, null, '66.67 %', '33.33 %'],
|
||||
['Syksy 2016', 2, 0, 0, 0, 0, 1, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2016', 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2015', 1, 0, 0, 0, 0, 0, 0, 1, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2015', 3, 0, 0, 0, 0, 0, 0, 3, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2014', 2, 0, 0, 1, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2014', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Syksy 2013', 1, 1, 0, 0, 0, 0, 0, 0, null, '0.00 %', '100.00 %'],
|
||||
['Syksy 2013', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2013', 0, 0, 0, 0, 0, 0, 0, 0, null, '–', '–'],
|
||||
['Syksy 2012', 4, 0, 0, 0, 0, 3, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
['Kevät 2012', 1, 0, 0, 0, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
|
||||
@@ -947,18 +968,18 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
['Total', 288, 241, 25, '90.60 %', 79],
|
||||
['Total', 270, 237, 9, '96.34 %', 79],
|
||||
['2023-2024', 6, 2, 0, '33.33 %', 6],
|
||||
['2022-2023', 28, 28, 0, '100.00 %', 28],
|
||||
['2021-2022', 45, 27, 0, '60.00 %', 45],
|
||||
['2020-2021', 34, 34, 0, '100.00 %', null],
|
||||
['2019-2020', 60, 57, 3, '95.00 %', null],
|
||||
['2018-2019', 46, 39, 7, '84.78 %', null],
|
||||
['2017-2018', 35, 26, 9, '74.29 %', null],
|
||||
['2016-2017', 7, 6, 1, '85.71 %', null],
|
||||
['2015-2016', 5, 3, 2, '60.00 %', null],
|
||||
['2014-2015', 7, 6, 1, '85.71 %', null],
|
||||
['2013-2014', 2, 1, 1, '50.00 %', null],
|
||||
['2022-2023', 28, 27, 0, '96.43 %', 28],
|
||||
['2021-2022', 45, 26, 0, '57.78 %', 45],
|
||||
['2020-2021', 31, 31, 0, '100.00 %', null],
|
||||
['2019-2020', 56, 56, 0, '100.00 %', null],
|
||||
['2018-2019', 42, 39, 3, '92.86 %', null],
|
||||
['2017-2018', 32, 28, 4, '87.50 %', null],
|
||||
['2016-2017', 6, 5, 1, '83.33 %', null],
|
||||
['2015-2016', 4, 4, 0, '100.00 %', null],
|
||||
['2014-2015', 5, 5, 0, '100.00 %', null],
|
||||
['2013-2014', 2, 2, 0, '100.00 %', null],
|
||||
['2012-2013', 4, 4, 0, '100.00 %', null],
|
||||
['2011-2012', 1, 1, 0, '100.00 %', null],
|
||||
['2010-2011', 1, 1, 0, '100.00 %', null],
|
||||
@@ -980,55 +1001,55 @@ describe('Basic user', () => {
|
||||
it('Show grades off, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
['Total', 288, 241, 25, '90.60 %', 79],
|
||||
['Total', 270, 237, 9, '96.34 %', 79],
|
||||
['Syksy 2023', 6, 2, 0, '33.33 %', 6],
|
||||
['Kevät 2023', 13, 10, 0, '76.92 %', 13],
|
||||
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
|
||||
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
|
||||
['Kevät 2022', 30, 11, 0, '36.67 %', 30],
|
||||
['Syksy 2021', 15, 16, 0, '100.00 %', 15],
|
||||
['Kevät 2021', 8, 8, 0, '100.00 %', null],
|
||||
['Syksy 2020', 26, 26, 0, '100.00 %', null],
|
||||
['Kevät 2020', 26, 26, 0, '100.00 %', null],
|
||||
['Syksy 2019', 34, 31, 3, '91.18 %', null],
|
||||
['Kevät 2019', 17, 16, 1, '94.12 %', null],
|
||||
['Syksy 2018', 29, 23, 6, '79.31 %', null],
|
||||
['Kevät 2018', 26, 18, 8, '69.23 %', null],
|
||||
['Syksy 2017', 9, 8, 1, '88.89 %', null],
|
||||
['Syksy 2021', 15, 15, 0, '100.00 %', 15],
|
||||
['Kevät 2021', 7, 7, 0, '100.00 %', null],
|
||||
['Syksy 2020', 24, 24, 0, '100.00 %', null],
|
||||
['Kevät 2020', 24, 24, 0, '100.00 %', null],
|
||||
['Syksy 2019', 32, 32, 0, '100.00 %', null],
|
||||
['Kevät 2019', 15, 14, 1, '93.33 %', null],
|
||||
['Syksy 2018', 27, 25, 2, '92.59 %', null],
|
||||
['Kevät 2018', 23, 19, 4, '82.61 %', null],
|
||||
['Syksy 2017', 9, 9, 0, '100.00 %', null],
|
||||
['Kevät 2017', 4, 3, 1, '75.00 %', null],
|
||||
['Syksy 2016', 3, 3, 0, '100.00 %', null],
|
||||
['Kevät 2016', 4, 2, 2, '50.00 %', null],
|
||||
['Syksy 2016', 2, 2, 0, '100.00 %', null],
|
||||
['Kevät 2016', 3, 3, 0, '100.00 %', null],
|
||||
['Syksy 2015', 1, 1, 0, '100.00 %', null],
|
||||
['Kevät 2015', 4, 3, 1, '75.00 %', null],
|
||||
['Syksy 2014', 3, 3, 0, '100.00 %', null],
|
||||
['Kevät 2015', 3, 3, 0, '100.00 %', null],
|
||||
['Syksy 2014', 2, 2, 0, '100.00 %', null],
|
||||
['Kevät 2014', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2013', 1, 0, 1, '0.00 %', null],
|
||||
['Kevät 2013', 0, 0, 0, '–', 0],
|
||||
['Syksy 2013', 1, 1, 0, '100.00 %', null],
|
||||
['Kevät 2013', 0, 0, 0, '–', null],
|
||||
['Syksy 2012', 4, 4, 0, '100.00 %', null],
|
||||
['Kevät 2012', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2011', 0, 0, 0, '–', 0],
|
||||
['Syksy 2011', 0, 0, 0, '–', null],
|
||||
['Kevät 2011', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2010', 0, 0, 0, '–', 0],
|
||||
['Kevät 2010', 0, 0, 0, '–', 0],
|
||||
['Syksy 2009', 0, 0, 0, '–', 0],
|
||||
['Kevät 2009', 0, 0, 0, '–', 0],
|
||||
['Syksy 2010', 0, 0, 0, '–', null],
|
||||
['Kevät 2010', 0, 0, 0, '–', null],
|
||||
['Syksy 2009', 0, 0, 0, '–', null],
|
||||
['Kevät 2009', 0, 0, 0, '–', null],
|
||||
['Syksy 2008', 1, 0, 1, '0.00 %', null],
|
||||
['Kevät 2008', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2007', 0, 0, 0, '–', 0],
|
||||
['Syksy 2007', 0, 0, 0, '–', null],
|
||||
['Kevät 2007', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2006', 0, 0, 0, '–', 0],
|
||||
['Kevät 2006', 0, 0, 0, '–', 0],
|
||||
['Syksy 2006', 0, 0, 0, '–', null],
|
||||
['Kevät 2006', 0, 0, 0, '–', null],
|
||||
['Syksy 2005', 1, 1, 0, '100.00 %', null],
|
||||
['Kevät 2005', 0, 0, 0, '–', 0],
|
||||
['Syksy 2004', 0, 0, 0, '–', 0],
|
||||
['Kevät 2005', 0, 0, 0, '–', null],
|
||||
['Syksy 2004', 0, 0, 0, '–', null],
|
||||
['Kevät 2004', 1, 1, 0, '100.00 %', null],
|
||||
['Syksy 2003', 0, 0, 0, '–', 0],
|
||||
['Kevät 2003', 0, 0, 0, '–', 0],
|
||||
['Syksy 2002', 0, 0, 0, '–', 0],
|
||||
['Kevät 2002', 0, 0, 0, '–', 0],
|
||||
['Syksy 2001', 0, 0, 0, '–', 0],
|
||||
['Kevät 2001', 0, 0, 0, '–', 0],
|
||||
['Syksy 2000', 0, 0, 0, '–', 0],
|
||||
['Kevät 2000', 0, 0, 0, '–', 0],
|
||||
['Syksy 2003', 0, 0, 0, '–', null],
|
||||
['Kevät 2003', 0, 0, 0, '–', null],
|
||||
['Syksy 2002', 0, 0, 0, '–', null],
|
||||
['Kevät 2002', 0, 0, 0, '–', null],
|
||||
['Syksy 2001', 0, 0, 0, '–', null],
|
||||
['Kevät 2001', 0, 0, 0, '–', null],
|
||||
['Syksy 2000', 0, 0, 0, '–', null],
|
||||
['Kevät 2000', 0, 0, 0, '–', null],
|
||||
['Syksy 1999', 2, 2, 0, '100.00 %', null],
|
||||
]
|
||||
toggleSeparateBySemesters()
|
||||
@@ -1038,18 +1059,18 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters off', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
|
||||
['Total', 288, 25, 6, 15, 9, 37, 169, 5],
|
||||
['Total', 270, 9, 4, 14, 9, 37, 168, 5],
|
||||
['2023-2024', 6, 0, 0, 0, 0, 0, 2, 0],
|
||||
['2022-2023', 28, 0, 0, 0, 0, 6, 22, 0],
|
||||
['2021-2022', 45, 0, 0, 1, 0, 2, 24, 0],
|
||||
['2020-2021', 34, 0, 0, 2, 1, 2, 28, 1],
|
||||
['2019-2020', 60, 3, 2, 5, 3, 9, 38, 0],
|
||||
['2018-2019', 46, 7, 3, 3, 2, 6, 25, 0],
|
||||
['2017-2018', 35, 9, 1, 2, 2, 6, 15, 0],
|
||||
['2016-2017', 7, 1, 0, 0, 1, 1, 4, 0],
|
||||
['2015-2016', 5, 2, 0, 1, 0, 0, 1, 1],
|
||||
['2014-2015', 7, 1, 0, 1, 0, 0, 2, 3],
|
||||
['2013-2014', 2, 1, 0, 0, 0, 0, 1, 0],
|
||||
['2022-2023', 28, 0, 0, 0, 0, 6, 21, 0],
|
||||
['2021-2022', 45, 0, 0, 1, 0, 2, 23, 0],
|
||||
['2020-2021', 31, 0, 0, 1, 1, 2, 26, 1],
|
||||
['2019-2020', 56, 0, 1, 5, 3, 9, 38, 0],
|
||||
['2018-2019', 42, 3, 3, 2, 2, 6, 26, 0],
|
||||
['2017-2018', 32, 4, 0, 3, 2, 6, 17, 0],
|
||||
['2016-2017', 6, 1, 0, 0, 1, 1, 3, 0],
|
||||
['2015-2016', 4, 0, 0, 1, 0, 0, 2, 1],
|
||||
['2014-2015', 5, 0, 0, 1, 0, 0, 1, 3],
|
||||
['2013-2014', 2, 0, 0, 0, 0, 0, 2, 0],
|
||||
['2012-2013', 4, 0, 0, 0, 0, 3, 1, 0],
|
||||
['2011-2012', 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2010-2011', 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
@@ -1072,28 +1093,28 @@ describe('Basic user', () => {
|
||||
it('Show grades on, Separate by semesters on', () => {
|
||||
const tableContents = [
|
||||
// [Time, Total attempts, 0, 1, 2, 3, 4, 5, Other passed]
|
||||
['Total', 288, 25, 6, 15, 9, 37, 169, 5],
|
||||
['Total', 270, 9, 4, 14, 9, 37, 168, 5],
|
||||
['Syksy 2023', 6, 0, 0, 0, 0, 0, 2, 0],
|
||||
['Kevät 2023', 13, 0, 0, 0, 0, 3, 7, 0],
|
||||
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
|
||||
['Syksy 2022', 15, 0, 0, 0, 0, 3, 14, 0],
|
||||
['Kevät 2022', 30, 0, 0, 0, 0, 2, 9, 0],
|
||||
['Syksy 2021', 15, 0, 0, 1, 0, 0, 15, 0],
|
||||
['Kevät 2021', 8, 0, 0, 0, 0, 0, 8, 0],
|
||||
['Syksy 2020', 26, 0, 0, 2, 1, 2, 20, 1],
|
||||
['Kevät 2020', 26, 0, 1, 1, 2, 4, 18, 0],
|
||||
['Syksy 2019', 34, 3, 1, 4, 1, 5, 20, 0],
|
||||
['Kevät 2019', 17, 1, 1, 1, 1, 2, 11, 0],
|
||||
['Syksy 2018', 29, 6, 2, 2, 1, 4, 14, 0],
|
||||
['Kevät 2018', 26, 8, 1, 2, 0, 4, 11, 0],
|
||||
['Syksy 2017', 9, 1, 0, 0, 2, 2, 4, 0],
|
||||
['Syksy 2021', 15, 0, 0, 1, 0, 0, 14, 0],
|
||||
['Kevät 2021', 7, 0, 0, 0, 0, 0, 7, 0],
|
||||
['Syksy 2020', 24, 0, 0, 1, 1, 2, 19, 1],
|
||||
['Kevät 2020', 24, 0, 0, 1, 2, 4, 17, 0],
|
||||
['Syksy 2019', 32, 0, 1, 4, 1, 5, 21, 0],
|
||||
['Kevät 2019', 15, 1, 1, 1, 1, 2, 9, 0],
|
||||
['Syksy 2018', 27, 2, 2, 1, 1, 4, 17, 0],
|
||||
['Kevät 2018', 23, 4, 0, 3, 0, 4, 12, 0],
|
||||
['Syksy 2017', 9, 0, 0, 0, 2, 2, 5, 0],
|
||||
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0],
|
||||
['Syksy 2016', 3, 0, 0, 0, 0, 1, 2, 0],
|
||||
['Kevät 2016', 4, 2, 0, 1, 0, 0, 1, 0],
|
||||
['Syksy 2016', 2, 0, 0, 0, 0, 1, 1, 0],
|
||||
['Kevät 2016', 3, 0, 0, 1, 0, 0, 2, 0],
|
||||
['Syksy 2015', 1, 0, 0, 0, 0, 0, 0, 1],
|
||||
['Kevät 2015', 4, 1, 0, 0, 0, 0, 0, 3],
|
||||
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 0],
|
||||
['Kevät 2015', 3, 0, 0, 0, 0, 0, 0, 3],
|
||||
['Syksy 2014', 2, 0, 0, 1, 0, 0, 1, 0],
|
||||
['Kevät 2014', 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Syksy 2013', 1, 1, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2013', 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['Kevät 2013', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Syksy 2012', 4, 0, 0, 0, 0, 3, 1, 0],
|
||||
['Kevät 2012', 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
@@ -1156,9 +1177,9 @@ describe('Basic user', () => {
|
||||
searchByCourseCode('TKT20014')
|
||||
cy.contains('TKT20014').click()
|
||||
|
||||
cy.contains('TKT20014 • Kypsyysnäyte LuK')
|
||||
cy.contains('50037 • Ruotsinkielinen kypsyysnäyte LuK')
|
||||
cy.contains('50036 • Suomenkielinen kypsyysnäyte LuK')
|
||||
cy.contains('TKT20014') // Kypsyysnäyte LuK
|
||||
cy.contains('50037') // Ruotsinkielinen kypsyysnäyte LuK
|
||||
cy.contains('50036') // Suomenkielinen kypsyysnäyte LuK
|
||||
|
||||
cy.cs('ProviderOrganizationSelect').click()
|
||||
|
||||
@@ -1180,9 +1201,7 @@ describe('Basic user', () => {
|
||||
it('Has right to see all the students, because course rovider is TKT', () => {
|
||||
cy.visit('coursestatistics?courseCodes=%5B%22TKT10004%22%5D&separate=false')
|
||||
cy.cs('course-population-for-2021-2022').click()
|
||||
cy.contains('Students (29)').click()
|
||||
cy.contains('509781')
|
||||
cy.contains('529866')
|
||||
// cy.contains('Students (29)').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1213,15 +1232,15 @@ describe('Only course statistics', () => {
|
||||
|
||||
const attemptsTableContents = [
|
||||
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
|
||||
['Total *', 255, 217, 20, '91.56 %', 73],
|
||||
['Total *', 240, 212, 8, '96.36 %', 73],
|
||||
emptyYear('2023-2024'),
|
||||
['2022-2023', 28, 28, 0, '100.00 %', 28],
|
||||
['2021-2022', 45, 27, 0, '60.00 %', 45],
|
||||
['2020-2021', 34, 34, 0, '100.00 %'],
|
||||
['2019-2020', 60, 57, 3, '95.00 %'],
|
||||
['2018-2019', 46, 39, 7, '84.78 %'],
|
||||
['2017-2018', 35, 26, 9, '74.29 %'],
|
||||
['2016-2017', 7, 6, 1, '85.71 %'],
|
||||
['2022-2023', 28, 27, 0, '96.43 %', 28],
|
||||
['2021-2022', 45, 26, 0, '57.78 %', 45],
|
||||
['2020-2021', 31, 31, 0, '100.00 %'],
|
||||
['2019-2020', 56, 56, 0, '100.00 %'],
|
||||
['2018-2019', 42, 39, 3, '92.86 %'],
|
||||
['2017-2018', 32, 28, 4, '87.50 %'],
|
||||
['2016-2017', 6, 5, 1, '83.33 %'],
|
||||
emptyYear('2015-2016'),
|
||||
emptyYear('2014-2015'),
|
||||
emptyYear('2013-2014'),
|
||||
|
||||
@@ -207,19 +207,20 @@ describe('Degree programme overview', () => {
|
||||
.should('contain', 47)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('unset-breakdown-bar-chart')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
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')
|
||||
cy.cs('unset-median-bar-chart').within(() => {
|
||||
cy.contains('48 graduated').should('be.visible')
|
||||
cy.contains('48 graduated').hover()
|
||||
})
|
||||
|
||||
cy.get('.grad-vals').contains('48 students graduated in year 2022').should('be.visible')
|
||||
|
||||
cy.contains('median study time: 6.5 semesters')
|
||||
cy.contains('24 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
@@ -235,33 +236,37 @@ describe('Degree programme overview', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
cy.cs('unset-breakdown-bar-chart')
|
||||
cy.cs('unset-breakdown-bar-chart')
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('master-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
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')
|
||||
})
|
||||
cy.cs('unset-graduation-times-section')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('2 graduated').should('be.visible')
|
||||
cy.contains('2 graduated').hover()
|
||||
})
|
||||
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
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')
|
||||
})
|
||||
cy.get('.grad-vals').contains('2 students graduated in year 2021').should('be.visible')
|
||||
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')
|
||||
|
||||
cy.cs('unset-graduation-times-section')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('11 graduated').should('be.visible')
|
||||
cy.contains('11 graduated').hover()
|
||||
})
|
||||
|
||||
cy.get('.grad-vals').contains('11 students graduated in year 2023').should('be.visible')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -269,7 +274,7 @@ describe('Degree programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
@@ -355,14 +360,14 @@ describe('Degree programme overview', () => {
|
||||
describe('Population link button works for', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('Class size 8 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2023 - 2024, 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class size 227 students')
|
||||
cy.contains('Class of 2017 - 2026, 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
@@ -371,9 +376,9 @@ describe('Degree programme overview', () => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 26 students')
|
||||
cy.contains('Class of 2022 - 2023, 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
@@ -405,19 +410,30 @@ describe('Degree programme overview', () => {
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
|
||||
cy.cs('programme-progress-bar-chart-section').contains('58.5%').hover()
|
||||
cy.contains('Graduated: 134')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
// CI contains different data than locally
|
||||
it.skip('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-section').scrollIntoView()
|
||||
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
|
||||
cy.cs('unset-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
})
|
||||
|
||||
cy.get('text').contains('16').hover()
|
||||
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, 22. On time."]').click()
|
||||
|
||||
cy.cs('unset-graduation-times-section').within(() => {
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('text').contains('22').click()
|
||||
})
|
||||
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
|
||||
|
||||
cy.get('text').contains('13').hover()
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 13')
|
||||
@@ -427,7 +443,7 @@ describe('Degree programme overview', () => {
|
||||
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('19 graduated').hover()
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('16 graduated on time')
|
||||
@@ -465,13 +481,14 @@ describe('Degree programme overview', () => {
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Showing 10 out of 30 students')
|
||||
})
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
|
||||
it.skip('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('Progress of students of the study track MAT-MAT by starting year')
|
||||
cy.contains('Progress data is currently only available for all students of the degree programme.')
|
||||
})
|
||||
@@ -481,7 +498,7 @@ describe('Degree programme overview', () => {
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').hover()
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
@@ -489,7 +506,7 @@ describe('Degree programme overview', () => {
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
cy.contains('12 graduated').hover()
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
@@ -825,7 +842,8 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('When language center is opened', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
|
||||
const numbers = [2076, 36, 9, 33, 757, 15, 40, 6, 48, 1, 24, 1059, 48, 0]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -55,12 +55,12 @@ describe('When language center is opened', () => {
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.cs('difference-button').click()
|
||||
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
|
||||
checkNumbers([63, 2, 0, 3, 13, 0, 1, 1, 2, 0, 0, 30, 10, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
|
||||
checkNumbers([1184, 28, 6, 27, 497, 4, 10, 2, 33, 0, 9, 535, 33, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -27,14 +27,14 @@ describe('Population statistics tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
|
||||
})
|
||||
|
||||
describe('Population search', () => {
|
||||
it('Info box works', () => {
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
|
||||
cy.cs('PopulationSearch-info-box-button').hover()
|
||||
cy.cs('PopulationSearch-info-box-content').should('be.visible')
|
||||
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
|
||||
@@ -74,8 +74,8 @@ describe('Population statistics tests', () => {
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('Class size 47 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2017 - 2018, 47 students')
|
||||
})
|
||||
|
||||
// TODO: Delete MUI
|
||||
@@ -95,7 +95,7 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
@@ -251,15 +251,15 @@ describe('Population statistics tests', () => {
|
||||
it('Is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.intercept('/api/courseyearlystats**').as('coursePage')
|
||||
cy.cs('toggle-group-module-MAT110').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('MAT11001') // Johdatus yliopistomatematiikkaan
|
||||
cy.contains('AYMAT11001') // Avoin yo: Johdatus yliopistomatematiikkaan
|
||||
cy.contains('57033') // Johdatus yliopistomatematiikkaan
|
||||
cy.contains('A57033') // Avoin yo: Johdatus yliopistomatematiikkaan
|
||||
})
|
||||
|
||||
it('Curriculum selection works', () => {
|
||||
@@ -401,8 +401,8 @@ describe('Population statistics tests', () => {
|
||||
describe('When using IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Population statistics is visible', () => {
|
||||
|
||||
+5
-14
@@ -114,9 +114,9 @@ describe('Students tests', () => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004 • Tilastollinen päättely I')
|
||||
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
|
||||
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
|
||||
cy.contains('MAT12004') // Tilastollinen päättely I
|
||||
cy.contains('AYMAT12004') // Avoin yo: Tilastollinen päättely I
|
||||
cy.contains('57046') // Johdatus tilastolliseen päättelyyn
|
||||
})
|
||||
|
||||
it('Has correct Sisu link', () => {
|
||||
@@ -162,8 +162,8 @@ describe('Students tests', () => {
|
||||
.parent()
|
||||
.within(() => cy.get('a').click())
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
@@ -174,18 +174,13 @@ describe('Students tests', () => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Nov '22")
|
||||
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
cy.get('input').should('have.value', '10')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Jul '22")
|
||||
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
|
||||
cy.contains('button', 'Show semester mean').click()
|
||||
cy.contains('.highcharts-container text', "Jan '22")
|
||||
})
|
||||
|
||||
describe('Bachelor Honours section', () => {
|
||||
@@ -277,13 +272,9 @@ describe('Students tests', () => {
|
||||
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit('/students/550789')
|
||||
cy.contains('.highcharts-container text', '1 Aug 2020')
|
||||
cy.contains('.highcharts-container text', '30 Aug 2024')
|
||||
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
|
||||
cy.get('td').eq(0).click()
|
||||
})
|
||||
cy.contains('.highcharts-container text', '1 Aug 2020')
|
||||
cy.contains('.highcharts-container text', '19 Aug 2023')
|
||||
})
|
||||
|
||||
it("If there's a study plan corresponding to the degree programme, completed credits are displayed", () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('Teachers page tests', () => {
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher1.split(' ')[0])
|
||||
cy.get('table tbody tr').should('have.length', 4)
|
||||
cy.get('table tbody tr').eq(3).contains('td', teacher1)
|
||||
cy.get('table tbody tr').eq(1).contains('td', teacher1)
|
||||
})
|
||||
|
||||
it('Can check teacher page', () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('University view', () => {
|
||||
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('bachelors-faculty-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('29.5%').hover()
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('0 Credits: 0')
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.visit('/populations')
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
|
||||
@@ -190,3 +190,20 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Hover on the selected element.
|
||||
*/
|
||||
Cypress.Commands.add('hover', { prevSubject: 'element' }, (subject, options = {}) => {
|
||||
cy.wrap(subject).trigger('mouseover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
})
|
||||
|
||||
cy.wrap(subject).trigger('mousemove', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
build:
|
||||
context: services
|
||||
dockerfile: backend/Dockerfile
|
||||
image: oodikone-backend:staging
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
@@ -34,6 +35,7 @@ services:
|
||||
build:
|
||||
context: services
|
||||
dockerfile: frontend/Dockerfile
|
||||
image: oodikone-frontend:staging
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
|
||||
+2
-2
@@ -45,9 +45,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -60,9 +60,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
|
||||
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
## Study guidance groups
|
||||
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Frontend: things are done in the same way as with class stats.
|
||||
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
|
||||
|
||||
+56
-63
@@ -25,7 +25,7 @@ export default tseslint.config(
|
||||
'nyc-config.cjs',
|
||||
'.lintstagedrc.mjs',
|
||||
'cypress.config.js',
|
||||
]
|
||||
],
|
||||
},
|
||||
|
||||
// Global options (excl. e2e)
|
||||
@@ -44,8 +44,8 @@ export default tseslint.config(
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.{js,cjs}']
|
||||
}
|
||||
allowDefaultProject: ['*.{js,cjs}'],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
@@ -53,13 +53,13 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'import-x': eslintPluginImportX
|
||||
'import-x': eslintPluginImportX,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
camelcase: 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'err'],
|
||||
@@ -75,31 +75,42 @@ 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',
|
||||
'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 }
|
||||
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 }],
|
||||
|
||||
'import-x/no-commonjs': 'error',
|
||||
'import-x/no-default-export': 'error',
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import-x/no-unused-modules': ['error',
|
||||
'import-x/no-unused-modules': [
|
||||
'error',
|
||||
{
|
||||
missingExports: true,
|
||||
unusedExports: true,
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
|
||||
}],
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
}],
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}'],
|
||||
},
|
||||
],
|
||||
'import-x/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
@@ -112,9 +123,7 @@ export default tseslint.config(
|
||||
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/dot-notation': 'error',
|
||||
'@typescript-eslint/prefer-destructuring': ['error',
|
||||
{ VariableDeclarator: { object: true } }
|
||||
],
|
||||
'@typescript-eslint/prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
|
||||
'@typescript-eslint/no-implied-eval': 'error',
|
||||
@@ -134,12 +143,12 @@ export default tseslint.config(
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json'
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
}),
|
||||
createNodeResolver({
|
||||
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
|
||||
})
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -147,22 +156,22 @@ export default tseslint.config(
|
||||
{
|
||||
files: ['services/backend/src/routes/*.{ts,js}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Disable type-aware linting for javascript files
|
||||
{
|
||||
files: ['{updater,services}/**/*.{js,cjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked]
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
|
||||
// Frontend camelCase and react
|
||||
{
|
||||
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': eslintPluginReact,
|
||||
react: eslintPluginReact,
|
||||
},
|
||||
extends: [
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
@@ -173,14 +182,15 @@ export default tseslint.config(
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'warn',
|
||||
camelcase: 'warn',
|
||||
'react/prop-types': 'off', // Typescript but worse
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/function-component-definition': ['error',
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/no-array-index-key': 'error',
|
||||
@@ -188,9 +198,7 @@ export default tseslint.config(
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error',
|
||||
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
|
||||
],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
|
||||
@@ -198,29 +206,25 @@ export default tseslint.config(
|
||||
'react/jsx-tag-spacing': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'react': {
|
||||
'version': 'detect'
|
||||
}
|
||||
}
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Cypress should only use rules provided by the plugin below
|
||||
{
|
||||
files: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslintPluginCypress.configs.recommended
|
||||
]
|
||||
extends: [eslintPluginCypress.configs.recommended],
|
||||
},
|
||||
|
||||
// Override for backend api tests
|
||||
{
|
||||
files: [
|
||||
"services/backend/tests**/*.ts"
|
||||
],
|
||||
files: ['services/backend/tests/**/*.ts'],
|
||||
rules: {
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'no-console': 'off',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
@@ -228,7 +232,7 @@ export default tseslint.config(
|
||||
files: [
|
||||
'services/backend/**/*.js',
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'updater/sis-updater-scheduler/**/*.js'
|
||||
'updater/sis-updater-scheduler/**/*.js',
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
@@ -239,20 +243,9 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
|
||||
// Rapodiff
|
||||
{
|
||||
files: ['services/backend/src/rapodiff/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Migration files
|
||||
{
|
||||
files: [
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'services/backend/src/database/**/*.js'
|
||||
],
|
||||
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
|
||||
rules: {
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
@@ -270,5 +263,5 @@ export default tseslint.config(
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
|
||||
)
|
||||
|
||||
Generated
+497
-263
File diff suppressed because it is too large
Load Diff
+8
-12
@@ -6,9 +6,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"cypress": "cypress",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:run": "cypress run --browser chrome",
|
||||
"instrument": "./scripts/instrument.sh",
|
||||
"cypress:run": "cypress run --browser firefox",
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"stylelint": "stylelint",
|
||||
@@ -26,13 +24,11 @@
|
||||
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
|
||||
"flushredis": "docker exec -it redis redis-cli flushall",
|
||||
"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 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: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",
|
||||
"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",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
@@ -46,21 +42,21 @@
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"cypress": "^15.9.0",
|
||||
"cypress": "^15.13.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.1",
|
||||
"eslint-plugin-cypress": "^6.2.3",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.2.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^39.0.0",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
# https://github.com/hadolint/hadolint/wiki/DL4006
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Safe-chain install
|
||||
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
FROM node:24-alpine
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
# https://github.com/hadolint/hadolint/wiki/DL4006
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Safe-chain install
|
||||
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY . .
|
||||
COPY --chown=1001:1001 . .
|
||||
|
||||
USER 1001
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
Generated
+403
-271
File diff suppressed because it is too large
Load Diff
@@ -9,23 +9,19 @@
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^10.43.0",
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"express": "^5.2.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.20.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.8",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
@@ -37,9 +33,11 @@
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"supertest": "^7.2.2",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'express-async-errors'
|
||||
import express from 'express'
|
||||
|
||||
import { baseUrl, backendPort } from './config'
|
||||
|
||||
@@ -7,9 +7,7 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
export const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
export const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
export const pateToken = process.env.PATE_API_TOKEN ?? ''
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { omit } from 'lodash-es'
|
||||
import morgan from 'morgan'
|
||||
|
||||
import { FormattedUser } from '../types'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { getFullStudyProgrammeRights } from '../util'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -52,7 +52,7 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
|
||||
const onlyIamRights = !user.isAdmin && fullStudyProgrammeRights.length === 0
|
||||
|
||||
logger.info(message, {
|
||||
...omit(meta, ['studentsUserCanAccess']), // Don't log student list which might be huge
|
||||
...omitKeys(meta, ['studentsUserCanAccess']), // Don't log student list which might be huge
|
||||
isUsageStats: true, // Pass this as a custom field so we can filter by it in Graylog
|
||||
isIamRights: onlyIamRights || usingIamRights, // Needed for Grafana IAM users panel
|
||||
})
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*.csv
|
||||
*.csv#
|
||||
@@ -1,22 +0,0 @@
|
||||
# Rapodiff
|
||||
|
||||
Use this to check differences between oodikone and rapo.
|
||||
|
||||
## How to load the CSV file from Rapo
|
||||
|
||||
1. Go to Rapo and open ”Koulutusohjelmien vuosiseuranta”
|
||||
2. Navigate to ”Opintopisteet ja opetus / Credits and teaching”
|
||||
3. From the faculty dropdown menu, select all the faculties
|
||||
4. From the bottom of the page, click ”Vie -> Tiedot -> CSV”
|
||||
5. Rename the file to `data.csv` and copy it to this directory
|
||||
|
||||
## Usage
|
||||
|
||||
1. Have real oodikone running. Then run rapodiff **in the root directory**: `npm run rapodiff -- args`
|
||||
2. place args with a mode. omit args to print possible modes.
|
||||
|
||||
## Known differences in APIs
|
||||
|
||||
- rapo does not show students that have graduated (it however takes some days for rapo to notice a graduation)
|
||||
- rapo does not handle the starting year of transfers properly (shows in old staring population)
|
||||
- rapo show some students incorrectly passive, see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3521
|
||||
@@ -1,188 +0,0 @@
|
||||
const { orderBy } = require('lodash-es')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
const { parseCsv, printProgressBar } = require('./helpers')
|
||||
|
||||
const diffs = []
|
||||
let noDiffCounter = 0
|
||||
|
||||
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
|
||||
const transformProgrammeCode = oldCode => `${oldCode[4]}H${oldCode.slice(0, 2)}_${oldCode.slice(5, 8)}`
|
||||
|
||||
const formatData = data =>
|
||||
data.reduce((obj, cur) => {
|
||||
const parseNum = str => (str === '' ? 0 : parseFloat(str.replace(',', '.')))
|
||||
const [
|
||||
year,
|
||||
facultyCode,
|
||||
programmeInfo,
|
||||
basic,
|
||||
exchange,
|
||||
otherUni,
|
||||
openUni,
|
||||
special,
|
||||
total,
|
||||
transferredFromAbroad,
|
||||
otherTransferred,
|
||||
] = cur
|
||||
const programmeCode = transformProgrammeCode(programmeInfo.split(' ')[0])
|
||||
if (!obj[programmeCode]) obj[programmeCode] = {}
|
||||
obj[programmeCode][year] = {
|
||||
year: parseNum(year),
|
||||
facultyCode,
|
||||
programmeInfo,
|
||||
basic: parseNum(basic),
|
||||
'incoming-exchange': parseNum(exchange),
|
||||
agreement: parseNum(otherUni),
|
||||
'open-uni': parseNum(openUni),
|
||||
separate: parseNum(special),
|
||||
total: parseNum(total),
|
||||
transferred: parseNum(transferredFromAbroad) + parseNum(otherTransferred),
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const diff = (rapoData, okData, code) => {
|
||||
for (const year of Object.keys(okData)) {
|
||||
if (year !== '2022' && year !== '2023') continue
|
||||
const rapo = rapoData[year]
|
||||
const ok = okData[year]
|
||||
if (!rapo) {
|
||||
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
|
||||
continue
|
||||
}
|
||||
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'transferred']) {
|
||||
const okValue = Math.round(ok[field] || 0)
|
||||
const rapoValue = Math.round(rapo[field] || 0)
|
||||
const diff = Math.abs(okValue - rapoValue)
|
||||
if (diff === 0) {
|
||||
noDiffCounter++
|
||||
continue
|
||||
}
|
||||
const percentage = rapoValue !== 0 ? Number(((diff / rapoValue) * 100).toFixed(2)) : 100
|
||||
diffs.push({ field, year, code, okValue, rapoValue, diff, 'diff %': percentage })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processProgrammes = async data => {
|
||||
let programmeCounter = 0
|
||||
const rapoProgrammeData = formatData(data.slice(1))
|
||||
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
|
||||
for (const programmeCode of allProgrammeCodes) {
|
||||
const okProgrammeData = await computeCreditsProduced(programmeCode, false, true)
|
||||
diff(rapoProgrammeData[programmeCode], okProgrammeData.stats, programmeCode)
|
||||
printProgressBar(allProgrammeCodes.length, ++programmeCounter)
|
||||
}
|
||||
const fieldToSortBy = 'diff' // 'diff %'
|
||||
const orderedDiffs = orderBy(diffs, fieldToSortBy, 'asc')
|
||||
console.table(orderedDiffs)
|
||||
console.log(`${diffs.length} diffs found with current settings. Numbers with no diff: ${noDiffCounter}`)
|
||||
const totalDiff = orderedDiffs.reduce((sum, cur) => cur.diff + sum, 0)
|
||||
console.log('Total difference: ', totalDiff)
|
||||
}
|
||||
|
||||
const programmeCreditsDiff = async fileName => {
|
||||
console.log('Running studyprogramme credits diff')
|
||||
await parseCsv(fileName, processProgrammes)
|
||||
console.log('Diff completed.')
|
||||
}
|
||||
|
||||
/*
|
||||
separate
|
||||
Suoritus on erillisellä opiskeluoikeudella opettajankoulutuksen opintoja suorittavan opiskelijan suorittama
|
||||
Suoritus on erillisellä opiskeluoikeudella opiskelevan suorittama
|
||||
|
||||
agreement
|
||||
Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama
|
||||
|
||||
|
||||
open-uni
|
||||
Suoritus on avoimessa korkeakouluopetuksessa suoritettu
|
||||
|
||||
incoming-exchange
|
||||
Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama
|
||||
|
||||
basic
|
||||
perustutkinto-opiskelija = 1
|
||||
ja
|
||||
Tuntematon
|
||||
tai (ehkä):
|
||||
Suoritus on kotimainen harjoittelujakso
|
||||
?? Opintosuoritus on täydennyskoulutuksessa suoritettu ??
|
||||
|
||||
other
|
||||
perustutkinto-opiskelija = 0 && tuntematon
|
||||
|
||||
*/
|
||||
|
||||
const getCategory = (typeName, isBasic, hyvaksiluettu) => {
|
||||
if (hyvaksiluettu) return 'transferred' // ?? unsure
|
||||
if (isBasic || ['Suoritus on kotimainen harjoittelujakso', 'Tuntematon'].includes(typeName)) return 'basic'
|
||||
if (typeName === 'Suoritus on avoimessa korkeakouluopetuksessa suoritettu') return 'open-uni'
|
||||
if (typeName === 'Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama') return 'incoming-exchange'
|
||||
if (typeName.startsWith('Suoritus on erillisellä opiskeluoikeudella')) return 'separate'
|
||||
if (typeName === 'Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama')
|
||||
return 'agreement'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const processIds = async (rawData, code, field) => {
|
||||
const data = rawData.slice(0, 21628) // 11476 = KTM viimeinen
|
||||
const rapoData = data
|
||||
.map(row => ({
|
||||
id: row[0].slice(11),
|
||||
type: getCategory(row[1], row[3] === '1', row[6] === '1'),
|
||||
typeCode: row[8],
|
||||
isModule: row[9] !== '0',
|
||||
credits: parseFloat(row[10]),
|
||||
included: row[4] === '1',
|
||||
}))
|
||||
.filter(course => !course.isModule && (field === 'transferred' || course.included))
|
||||
const rapoIds = rapoData.filter(course => course.credits > 0 && course.type === field).map(row => row.id)
|
||||
const rapoCredits = rapoData.reduce(
|
||||
(stats, cur) => {
|
||||
stats[cur.type] += cur.credits
|
||||
return stats
|
||||
},
|
||||
{
|
||||
transferred: 0,
|
||||
total: '_',
|
||||
basic: 0,
|
||||
'open-uni': 0,
|
||||
separate: 0,
|
||||
'incoming-exchange': 0,
|
||||
agreement: 0,
|
||||
other: 0,
|
||||
}
|
||||
)
|
||||
const stats = await computeCreditsProduced(code, false, true, field)
|
||||
if (!stats.stats.ids) {
|
||||
console.log({ rapoCredits })
|
||||
console.log('Edit getCreditsProduced code so that it saves a list of the relevant ids you want to compare.')
|
||||
return
|
||||
}
|
||||
const okIdMap = stats.stats.ids
|
||||
const rapoIdMap = rapoIds.reduce((obj, cur) => {
|
||||
obj[cur] = true
|
||||
return obj
|
||||
}, {})
|
||||
const onlyInRapo = Object.keys(rapoIdMap).filter(id => !okIdMap[id])
|
||||
const onlyInOk = Object.keys(okIdMap).filter(id => !rapoIdMap[id])
|
||||
console.log('\n onlyInRapo: ', onlyInRapo.length)
|
||||
console.dir(onlyInRapo.slice(0, 30))
|
||||
console.log('\n onlyInOk: ', onlyInOk.length)
|
||||
console.dir(onlyInOk.slice(0, 30))
|
||||
delete stats.ids
|
||||
console.log(stats.stats['2022'])
|
||||
console.log('rapoCredits: ', rapoCredits)
|
||||
}
|
||||
|
||||
const testNewCalc = async (fileName, code, field) => {
|
||||
await parseCsv(fileName, async data => processIds(data, code, field))
|
||||
}
|
||||
|
||||
module.exports = { programmeCreditsDiff, testNewCalc }
|
||||
@@ -1,36 +0,0 @@
|
||||
const fs = require('fs/promises')
|
||||
const Papa = require('papaparse')
|
||||
|
||||
const parseCsv = async (fileName, fun) => {
|
||||
try {
|
||||
const file = await fs.readFile(`${__dirname}/${fileName}`, 'utf8')
|
||||
Papa.parsePromise = file => {
|
||||
return new Promise((complete, error) => {
|
||||
Papa.parse(file, { complete, error })
|
||||
})
|
||||
}
|
||||
const data = await Papa.parsePromise(file)
|
||||
await fun(data?.data)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const printProgressBar = (total, current) => {
|
||||
const barLength = 30
|
||||
const progress = Math.floor((current / total) * barLength)
|
||||
const empty = barLength - progress
|
||||
|
||||
const progressBar = `[${'#'.repeat(progress)}${'-'.repeat(empty)}]`
|
||||
const percentage = Math.floor((current / total) * 100)
|
||||
|
||||
process.stdout.clearLine()
|
||||
process.stdout.cursorTo(0)
|
||||
process.stdout.write(`${progressBar} ${percentage} %`)
|
||||
|
||||
if (current >= total) {
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseCsv, printProgressBar }
|
||||
@@ -1,31 +0,0 @@
|
||||
const { programmeCreditsDiff, testNewCalc } = require('./creditsDiff')
|
||||
|
||||
// ** To add new mode, just type key and function here **
|
||||
const modes = {
|
||||
// Check credits with current local oodikone data against provided data.csv in rapodiff folder.
|
||||
credits: () => programmeCreditsDiff('data.csv'),
|
||||
// Provide programme code as argument. Parses credits from credits.csv in rapodiff folder. This must be obtained from Rapo people. This tells which credit ids are found in oodikone but not rapo, and vice versa.
|
||||
// notice: check mode requires writing ids in to the stats object in credits calculating function.
|
||||
// Add something like this there:
|
||||
// if (category === 'open-uni' && credits > 0 && new Date(attainmentDate).getFullYear() === 2022) stats.ids[id] = true
|
||||
check: (fileName, code, field) => testNewCalc(fileName, code, field),
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('**RAPODIFF**\n')
|
||||
const mode = process.argv[2]
|
||||
const modeFunction = modes[mode]
|
||||
const arg = process.argv[3]
|
||||
const arg2 = process.argv[4]
|
||||
const arg3 = process.argv[5]
|
||||
if (!modeFunction) {
|
||||
console.log(
|
||||
'Unknown or missing mode. Supply the mode as an argument (e.g. `npm run rapodiff -- credits`). Supported modes:\n'
|
||||
)
|
||||
console.log(Object.keys(modes).join('\n'))
|
||||
return
|
||||
}
|
||||
await modeFunction(arg, arg2, arg3)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0))
|
||||
@@ -42,7 +42,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(compression())
|
||||
|
||||
app.use(headersMiddleware)
|
||||
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
app.use(currentUserMiddleware)
|
||||
app.use(accessLogger)
|
||||
app.use(`${url}/login`, login)
|
||||
app.use(url, courses)
|
||||
@@ -52,8 +52,8 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/semesters/codes`, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(`${url}/v3/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
|
||||
app.use(`${url}/changelog`, changelog)
|
||||
@@ -72,7 +72,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
|
||||
app.use(`${url}/study-programme-pins`, studyProgrammePins)
|
||||
app.use(`${url}/curriculum-periods`, curriculumPeriods)
|
||||
app.get('*', (_, res) => {
|
||||
app.get('', (_, res) => {
|
||||
res.status(404).json({ error: 'unknown endpoint' })
|
||||
})
|
||||
Sentry.setupExpressErrorHandler(app)
|
||||
|
||||
@@ -14,7 +14,7 @@ router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
|
||||
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
|
||||
const result = await getCloseToGraduationData(studentNumbers)
|
||||
|
||||
res.json(result)
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
const result = await getCloseToGraduationData()
|
||||
|
||||
@@ -10,14 +10,19 @@ import {
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
import {
|
||||
getFullStudyProgrammeRights,
|
||||
handleQueryArrays,
|
||||
hasFullAccessToStudentData,
|
||||
validateParamLength,
|
||||
} from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
'/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, includeSpecial } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
@@ -49,11 +54,13 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
'/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { codes, combineSubstitutions, separate } = req.query
|
||||
|
||||
if (!codes) {
|
||||
const courseCodes = handleQueryArrays(codes)
|
||||
|
||||
if (!courseCodes?.length) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
@@ -75,9 +82,19 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
router.get<never, CanError<unknown>, never, { codes: string | string[] }>('/coursedetails', async (req, res) => {
|
||||
const { codes: coursecodes } = req.query
|
||||
const codes = handleQueryArrays(coursecodes)
|
||||
|
||||
if (!codes?.length) return res.status(500).end()
|
||||
const details = await getCourseDetails(codes)
|
||||
|
||||
return res.json(details)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { FormattedUser } from '../types/user'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { Unification } from '@oodikone/shared/types'
|
||||
import { keyBy, mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
|
||||
import {
|
||||
getCourseProvidersForCourses,
|
||||
maxYearsToCreatePopulationFrom,
|
||||
searchAndCombineSubstitutionGroupsToCodes,
|
||||
} from '../services/courses'
|
||||
import { encrypt } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { getStudentTagMap } from '../services/populations/getStudentData'
|
||||
@@ -26,7 +30,7 @@ import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -98,15 +102,11 @@ const obfuscateStuff = ({
|
||||
}
|
||||
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
'/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
|
||||
const { programme, combinedProgramme } = req.query
|
||||
|
||||
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
|
||||
const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
|
||||
return Array.isArray(requestField) ? requestField : [requestField]
|
||||
}
|
||||
const years = handleQueryArrays(req.query.years)
|
||||
const semesters = handleQueryArrays(req.query.semesters)
|
||||
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
|
||||
@@ -172,14 +172,15 @@ router.get<
|
||||
CanError<PopulationstatisticsbycourseResBody>,
|
||||
PopulationstatisticsbycourseReqBody,
|
||||
PopulationstatisticsbycourseParams
|
||||
>('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
>('/populationstatisticsbycourse', async (req, res) => {
|
||||
const {
|
||||
id: userId,
|
||||
roles: userRoles,
|
||||
programmeRights: userProgrammeRights,
|
||||
studentsUserCanAccess: allStudentsUserCanAccess,
|
||||
} = req.user
|
||||
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
|
||||
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses, ...restParams } = req.query
|
||||
const includeSubstitutions = restParams.includeSubstitutions === 'true'
|
||||
|
||||
if (!coursecodeJSON || !from || !to) {
|
||||
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
|
||||
@@ -187,16 +188,17 @@ router.get<
|
||||
|
||||
const isSeparate = separate === 'true'
|
||||
const coursecodes = JSON.parse(coursecodeJSON)
|
||||
const codes = includeSubstitutions ? await searchAndCombineSubstitutionGroupsToCodes(coursecodes) : coursecodes
|
||||
|
||||
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
|
||||
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
|
||||
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(codes, Unification.REGULAR)
|
||||
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
|
||||
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
|
||||
}
|
||||
|
||||
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
const studentNumbers = await findByCourseAndSemesters(codes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
|
||||
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
@@ -217,18 +219,21 @@ router.get<
|
||||
studentsUserCanAccess,
|
||||
})
|
||||
|
||||
return res.json(processed)
|
||||
return res.json({ ...processed, mainCourseCodes: coursecodes, allCourseCodes: codes })
|
||||
})
|
||||
|
||||
// Used in custom population and single study guidance groups
|
||||
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
'/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentNumbers, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
|
||||
const students = handleQueryArrays(studentNumbers)
|
||||
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
? students
|
||||
: intersection(students, studentsUserCanAccess)
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
@@ -242,14 +247,14 @@ router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
|
||||
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
|
||||
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
}
|
||||
)
|
||||
|
||||
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
|
||||
'/v3/populationstatistics/studyprogrammes',
|
||||
'/populationstatistics/studyprogrammes',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
@@ -275,7 +280,7 @@ router.get<
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
|
||||
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
>('/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CurriculumOption } from '@oodikone/shared/types'
|
||||
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
|
||||
type CurriculumOptionParams = { code: string }
|
||||
|
||||
router.get<CurriculumOptionParams, CurriculumOption[] | null>('/curriculum-options/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await getCurriculumOptions(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
|
||||
type CurriculumPeriodParams = { code: string; periodIds: string }
|
||||
|
||||
router.get<CurriculumPeriodParams>('/curriculum/:code/:periodIds', async (req, res) => {
|
||||
const { code, periodIds } = req.params
|
||||
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
|
||||
@@ -23,15 +23,15 @@ import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
type StudyProgrammeParams = { id: string }
|
||||
type UpdateStudyProgrammeQuery = { combined_programme: string }
|
||||
type CreditStatsQuery = {
|
||||
codes: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
|
||||
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats', async (req, res) => {
|
||||
const { codes, specialGroups, yearType } = req.query
|
||||
const stats: CreditStatsPayload = {}
|
||||
for (const code of JSON.parse(codes)) {
|
||||
@@ -40,15 +40,13 @@ router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) =>
|
||||
return res.json(stats)
|
||||
})
|
||||
|
||||
interface GetStatsRequest extends Request {
|
||||
query: {
|
||||
year_type: YearType
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
type StatsRequestQuery = {
|
||||
year_type: YearType
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
|
||||
router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basicstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -75,7 +73,7 @@ router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/graduationstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -102,14 +100,12 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
interface GetCourseStatsRequest extends Request {
|
||||
query: {
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
type CourseStatsQuery = {
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
|
||||
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { combinedProgramme, yearType } = req.query
|
||||
const date = new Date()
|
||||
@@ -123,15 +119,13 @@ router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response)
|
||||
}
|
||||
})
|
||||
|
||||
interface GetStudyTrackStatsRequest extends Request {
|
||||
query: {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
type StudyTrackStatsQuery = {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
|
||||
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/studytrackstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -153,13 +147,16 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
|
||||
},
|
||||
studyRightsOfProgramme,
|
||||
})
|
||||
|
||||
if (updated) {
|
||||
await setStudyTrackStats(updated, specialGroups)
|
||||
return res.json(updated)
|
||||
}
|
||||
return res.json(updated)
|
||||
|
||||
return res.status(500).end()
|
||||
})
|
||||
|
||||
router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response) => {
|
||||
router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
try {
|
||||
const data = await getStudyProgrammeStatsForColorizedCoursesTable(code)
|
||||
@@ -169,7 +166,7 @@ router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
@@ -182,42 +179,42 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
interface GetUpdateViewRequest extends Request {
|
||||
query: {
|
||||
combined_programme: string
|
||||
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
|
||||
'/:id/update_basicview',
|
||||
async (req, res) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateBasicView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.get('/:id/update_basicview', async (req: GetUpdateViewRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
|
||||
'/:id/update_studytrackview',
|
||||
async (req, res) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateStudyTrackView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await updateBasicView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateStudyTrackView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as auth from '../middleware/auth'
|
||||
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
|
||||
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
|
||||
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
|
||||
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
|
||||
import { getFullStudyProgrammeRights, handleQueryArrays, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -91,11 +91,12 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights, iamGroups } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const { providers, semesterStart, semesterEnd } = req.query
|
||||
if (!providers || !semesterStart) {
|
||||
const { providers: providerStack, semesterStart, semesterEnd } = req.query
|
||||
if (!providerStack || !semesterStart) {
|
||||
return res.status(422).send('Missing required query parameters')
|
||||
}
|
||||
|
||||
const providers = handleQueryArrays(providerStack)
|
||||
const providerRights = mapToProviders(fullStudyProgrammeRights)
|
||||
if (
|
||||
!(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
setFacultyProgressStats,
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { GraduationTarget } from '../services/graduationHelpers'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -137,7 +138,12 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const universityData = {
|
||||
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
|
||||
goals: {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
},
|
||||
programmeNames: getProgrammeNames(allFaculties),
|
||||
byGradYear: {
|
||||
medians: {} as Record<string, LevelGraduationStats[]>,
|
||||
|
||||
@@ -26,14 +26,14 @@ const refreshProgrammesByList = (programmeCodes: string[]) => {
|
||||
return 'Added jobs for refreshing programme'
|
||||
}
|
||||
|
||||
router.get('/update/v2/meta', async (_req: Request, res: Response) => {
|
||||
router.get('/update/meta', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISMetadata()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS meta scheduled')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/students', async (_req: Request, res: Response) => {
|
||||
router.get('/update/students', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISStudents()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS students scheduled')
|
||||
@@ -47,7 +47,7 @@ interface PostCustomListRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
|
||||
router.post('/update/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
|
||||
const { type } = req.params
|
||||
const list = req.body
|
||||
const typeToJob = {
|
||||
@@ -65,7 +65,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/programmes', async (_req: Request, res: Response) => {
|
||||
router.get('/update/programmes', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISProgrammes()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS programmes scheduled')
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
|
||||
import { LANGUAGE_CODES } from '@oodikone/shared/language'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
import {
|
||||
ChangeLanguageReqBody,
|
||||
GetUserByIdParams,
|
||||
GetUserByIdResBody,
|
||||
GetUserRolesResBody,
|
||||
GetUsersResBody,
|
||||
ModifyUserRolesReqBody,
|
||||
PostUserEmailReqBody,
|
||||
UserEmailPreviewResBody,
|
||||
UserIDElementsParams,
|
||||
UserIDElementsReqBody,
|
||||
} from '@oodikone/shared/routes/users'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { roles } from '../config/roles'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
import * as userService from '../services/userService'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
|
||||
|
||||
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
|
||||
const results = await userService.findAll()
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
type GetUserRolesResBody = readonly string[]
|
||||
|
||||
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
type GetUserByIdParams = { uid: string }
|
||||
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
|
||||
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
|
||||
|
||||
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
'/:uid',
|
||||
@@ -41,61 +42,35 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
}
|
||||
)
|
||||
|
||||
type ModifyUserRolesReqBody = {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
type ModifyUserRolesResBody = never
|
||||
|
||||
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
|
||||
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
|
||||
'/modify-roles',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
if (error) return res.status(400).json(error)
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
)
|
||||
|
||||
type UserEmailPreviewResBody = {
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
|
||||
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
|
||||
res.json({ subject: accessMessageSubject, html: accessMessageText })
|
||||
})
|
||||
|
||||
type PostUserEmailReqBody = { email: string }
|
||||
type PostUserEmailResBody = never
|
||||
router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['admin']), async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
|
||||
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
|
||||
'/email',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
}
|
||||
)
|
||||
|
||||
type ElementsParams = { uid: string }
|
||||
type ElementsReqBody = { codes: string[] }
|
||||
|
||||
type PostUserUIDElementsResBody = void
|
||||
|
||||
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
@@ -106,9 +81,7 @@ router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, Elemen
|
||||
}
|
||||
)
|
||||
|
||||
type DeleteUserUIDElementsResBody = void
|
||||
|
||||
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
@@ -119,10 +92,7 @@ router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, El
|
||||
}
|
||||
)
|
||||
|
||||
type ChangeLanguageReqBody = { language: Language }
|
||||
type PostChangeLanguageResBody = void
|
||||
|
||||
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
const { language } = req.body
|
||||
|
||||
if (!LANGUAGE_CODES.includes(language)) {
|
||||
|
||||
@@ -40,8 +40,8 @@ const codeLikeTerm = (code: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) =>
|
||||
await CourseModel.findAll({
|
||||
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const courses = await CourseModel.findAll({
|
||||
where: {
|
||||
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
|
||||
...nameLikeTerm(name),
|
||||
@@ -50,6 +50,25 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const coursesWithSubstitutionGroupDetails = await Promise.all(
|
||||
courses.map(async course => {
|
||||
const groupDetails = await Promise.all(
|
||||
course?.substitution_groups?.map(async group => {
|
||||
return await CourseModel.findAll({
|
||||
attributes: ['name', 'code'],
|
||||
where: { code: { [Op.in]: group } },
|
||||
raw: true,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return { ...course, substitution_groups: groupDetails }
|
||||
})
|
||||
)
|
||||
|
||||
return coursesWithSubstitutionGroupDetails
|
||||
}
|
||||
|
||||
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const rawCourses = await getRawCourses(name, code, includeSpecial)
|
||||
return rawCourses
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Credit, Enrollment } from '@oodikone/shared/models'
|
||||
import { EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import {
|
||||
StudentModel,
|
||||
CreditModel,
|
||||
SemesterModel,
|
||||
OrganizationModel,
|
||||
@@ -11,37 +13,84 @@ import {
|
||||
} from '../../models'
|
||||
import { getIsOpen } from './helpers'
|
||||
|
||||
export const getCreditsForCourses = async (codes: string[], unification: Unification) =>
|
||||
CreditModel.findAll({
|
||||
attributes: [
|
||||
'grade',
|
||||
'course_code',
|
||||
'credits',
|
||||
'attainment_date',
|
||||
'student_studentnumber',
|
||||
'studyright_id',
|
||||
'credittypecode',
|
||||
],
|
||||
export const getCreditsForCourses = async (codeGroups: string[][], unification: Unification) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
// We need the credits grouped by student numbers so that we can check if a student has
|
||||
// completed a substitution group
|
||||
const students = await StudentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
model: CreditModel,
|
||||
attributes: [
|
||||
'grade',
|
||||
'course_code',
|
||||
'credits',
|
||||
'attainment_date',
|
||||
'student_studentnumber',
|
||||
'studyright_id',
|
||||
'credittypecode',
|
||||
],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
},
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: codes,
|
||||
},
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
})
|
||||
|
||||
const studentNumberToCredits = students.reduce<Record<string, Credit[]>>((acc, student) => {
|
||||
acc[student.studentnumber] ??= []
|
||||
acc[student.studentnumber] = acc[student.studentnumber].concat(student.credits)
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const completedGroups: Credit[][] = []
|
||||
for (const studentCredits of Object.values(studentNumberToCredits)) {
|
||||
const codesOfPassedCredits = studentCredits
|
||||
.filter(credit => CreditModel.passed(credit))
|
||||
.map(credit => credit.course_code)
|
||||
for (const group of codeGroups) {
|
||||
if (group.length === 1) {
|
||||
// Failed courses are only calculated for the original course and 1-to-1 substitutions
|
||||
const credits = studentCredits.filter(credit => credit.course_code === group.at(0))
|
||||
if (credits.length) {
|
||||
completedGroups.push(credits)
|
||||
}
|
||||
}
|
||||
// For substitution groups we must only get passed groups
|
||||
else if (group.every(code => codesOfPassedCredits.includes(code))) {
|
||||
// The credit in question should always exist because we just checked that it does
|
||||
completedGroups.push(
|
||||
studentCredits.filter(credit => group.includes(credit.course_code) && CreditModel.passed(credit))
|
||||
)
|
||||
} else {
|
||||
// "Partially completed substitution, do not count towards anything"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completedGroups.filter(group => group.length)
|
||||
}
|
||||
|
||||
export const getStudentNumberToSrElementsMap = async (studentNumbers: string[]) => {
|
||||
const studyRights = await SISStudyRightModel.findAll({
|
||||
attributes: ['facultyCode', 'id', 'studentNumber'],
|
||||
@@ -83,26 +132,64 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) =>
|
||||
EnrollmentModel.findAll({
|
||||
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
|
||||
export const getEnrollmentsForCourses = async (codeGroups: string[][], unification: Unification) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
const students = await StudentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
model: EnrollmentModel,
|
||||
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
},
|
||||
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: codes,
|
||||
},
|
||||
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
})
|
||||
|
||||
const studentNumberToEnrollments = students.reduce<Record<string, Enrollment[]>>((acc, student) => {
|
||||
acc[student.studentnumber] ??= []
|
||||
acc[student.studentnumber] = acc[student.studentnumber].concat(student.enrollments)
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Calculate all code groups that have enrollments for all of the courses in the group
|
||||
const completedEnrollments: Enrollment[][] = []
|
||||
for (const studentEnrollments of Object.values(studentNumberToEnrollments)) {
|
||||
const codesOfEnrollments = studentEnrollments.map(enrollment => enrollment.course_code)
|
||||
|
||||
for (const group of codeGroups) {
|
||||
if (group.every(code => codesOfEnrollments.includes(code))) {
|
||||
completedEnrollments.push(
|
||||
studentEnrollments
|
||||
.filter(enrollment => group.includes(enrollment.course_code))
|
||||
.sort((a, b) => b.enrollment_date_time.getTime() - a.enrollment_date_time.getTime())
|
||||
)
|
||||
} else {
|
||||
// Partially completed enrollments for a group, skipping...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completedEnrollments
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import crypto from 'crypto'
|
||||
import { Op, fn as dbFn, col as dbCol } from 'sequelize'
|
||||
|
||||
import { Credit, Enrollment } from '@oodikone/shared/models'
|
||||
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import { dateIsBetween } from '@oodikone/shared/util/datetime'
|
||||
import logger from '../../../src/util/logger'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
|
||||
import { getOpenUniCourseCode } from '../../util'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
getCreditsForCourses,
|
||||
@@ -14,13 +15,6 @@ import {
|
||||
} from './creditsAndEnrollmentsOfCourse'
|
||||
import { FormattedProgramme, getIsOpen } from './helpers'
|
||||
|
||||
const sortMainCode = (codes: string[]) => {
|
||||
if (!codes) {
|
||||
return []
|
||||
}
|
||||
return codes.sort((a, b) => getSortRank(b) - getSortRank(a))
|
||||
}
|
||||
|
||||
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
|
||||
code: studyRightElement.code,
|
||||
name: studyRightElement.name,
|
||||
@@ -47,20 +41,38 @@ type FormattedCredit = {
|
||||
credits: number
|
||||
}
|
||||
|
||||
// Is group in question for a single course (original or 1-to-1 substitution) or a substitution group with multiple courses
|
||||
const isSingleCourse = (group: Credit[] | Enrollment[]): boolean =>
|
||||
group?.length === 1 || [...new Set(group?.map((course: Credit | Enrollment) => course.course_code))].length === 1
|
||||
|
||||
const parseCredit = (
|
||||
credit: CreditModel,
|
||||
creditGroup: Credit[],
|
||||
anonymizationSalt: string | null,
|
||||
mainCourseCode: string,
|
||||
studyRightElements: Array<SISStudyRightElementModel>
|
||||
): FormattedCredit => {
|
||||
const singleCourse = isSingleCourse(creditGroup)
|
||||
// Latest attainment for a course
|
||||
const credit =
|
||||
creditGroup
|
||||
.sort((a, b) => b.attainment_date.getTime() - a.attainment_date.getTime())
|
||||
.find(credit => !CreditModel.failed(credit)) ?? creditGroup.at(0)!
|
||||
|
||||
const courseCode = singleCourse ? credit.course_code : mainCourseCode
|
||||
const grade = singleCourse ? credit.grade : 'substituted'
|
||||
const credits = singleCourse ? credit.credits : creditGroup.reduce((acc, credit) => acc + credit.credits, 0)
|
||||
|
||||
// Take the first attainment for the course (see Student statistics info-box)
|
||||
// TODO: Substitution groups should be marked for the semester when
|
||||
// the group was first finished eg. when was the first time the last missing
|
||||
// course of the group was passed
|
||||
const {
|
||||
semester,
|
||||
grade,
|
||||
course_code: courseCode,
|
||||
credits,
|
||||
attainment_date: attainmentDate,
|
||||
student_studentnumber: studentNumber,
|
||||
studyright_id: studyRightId,
|
||||
} = credit
|
||||
} = creditGroup.at(-1)!
|
||||
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const programmeOfCredit: SISStudyRightElementModel | undefined =
|
||||
@@ -87,7 +99,7 @@ const parseCredit = (
|
||||
attainmentDate,
|
||||
courseCode,
|
||||
grade,
|
||||
passed: !CreditModel.failed(credit),
|
||||
passed: singleCourse ? !CreditModel.failed(credit) : true,
|
||||
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
|
||||
programme,
|
||||
credits,
|
||||
@@ -104,7 +116,7 @@ type FormattedEnrollment = {
|
||||
studentNumber: string
|
||||
}
|
||||
|
||||
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
|
||||
const parseEnrollment = (enrollment: Enrollment, anonymizationSalt: string | null): FormattedEnrollment => {
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
semester,
|
||||
@@ -124,7 +136,22 @@ const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string
|
||||
}
|
||||
}
|
||||
|
||||
const getSubstitutionGroupDetails = async (codeGroups: string[][]) => {
|
||||
const substitutionGroupDetails = await CourseModel.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
where: {
|
||||
code: { [Op.in]: codeGroups.flatMap(group => group) },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
return codeGroups.map(group =>
|
||||
group.map(code => substitutionGroupDetails.find(subCourse => subCourse.code === code)!)
|
||||
)
|
||||
}
|
||||
|
||||
const getYearlyStatsOfNew = async (
|
||||
course: Pick<CourseModel, 'code' | 'name' | 'substitutions' | 'substitution_groups'> | null,
|
||||
courseCode: string,
|
||||
separate: boolean,
|
||||
unification: Unification,
|
||||
@@ -132,27 +159,36 @@ const getYearlyStatsOfNew = async (
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
|
||||
) => {
|
||||
const course: Pick<CourseModel, 'code' | 'name' | 'substitutions'> | null = await CourseModel.findOne({
|
||||
attributes: ['code', 'name', 'substitutions'],
|
||||
where: { code: courseCode },
|
||||
})
|
||||
// Includes main course code and substitutions (if enabled)
|
||||
const creditGroupCodes =
|
||||
combineSubstitutions && course?.substitution_groups
|
||||
? [[courseCode]].concat(course.substitution_groups)
|
||||
: [[courseCode]]
|
||||
|
||||
const substitutedCodes =
|
||||
combineSubstitutions && course?.substitutions ? sortMainCode([...course.substitutions, courseCode]) : [courseCode]
|
||||
let filteredCreditGroupCodes: string[][]
|
||||
switch (unification) {
|
||||
// Include only course codes / group of course codes WITHOUT ANY AY prefix
|
||||
case Unification.REGULAR:
|
||||
filteredCreditGroupCodes = creditGroupCodes.filter(group => group.every(course => !isOpenUniCourseCode(course)))
|
||||
break
|
||||
// Include all courses / group of courses
|
||||
case Unification.OPEN:
|
||||
case Unification.UNIFY:
|
||||
default:
|
||||
filteredCreditGroupCodes = creditGroupCodes
|
||||
break
|
||||
}
|
||||
|
||||
const codes =
|
||||
unification === Unification.REGULAR
|
||||
? substitutedCodes.filter(course => !getOpenUniCourseCode(course))
|
||||
: substitutedCodes
|
||||
|
||||
const [credits, enrollments] = await Promise.all([
|
||||
getCreditsForCourses(codes, unification),
|
||||
getEnrollmentsForCourses(codes, unification),
|
||||
const [creditGroups, enrollmentGroups] = await Promise.all([
|
||||
getCreditsForCourses(filteredCreditGroupCodes, unification),
|
||||
getEnrollmentsForCourses(filteredCreditGroupCodes, unification),
|
||||
])
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
|
||||
for (const credit of credits) {
|
||||
for (const creditGroup of creditGroups) {
|
||||
if (!creditGroup?.length) continue
|
||||
|
||||
const {
|
||||
studentNumber,
|
||||
grade,
|
||||
@@ -163,9 +199,14 @@ const getYearlyStatsOfNew = async (
|
||||
yearName,
|
||||
attainmentDate,
|
||||
programme,
|
||||
courseCode,
|
||||
courseCode: creditCourseCode,
|
||||
credits,
|
||||
} = parseCredit(credit, anonymizationSalt, studentNumberToSrElementsMap[credit.student_studentnumber] ?? [])
|
||||
} = parseCredit(
|
||||
creditGroup,
|
||||
anonymizationSalt,
|
||||
courseCode,
|
||||
studentNumberToSrElementsMap[creditGroup.at(0)?.student_studentnumber ?? 0] ?? []
|
||||
)
|
||||
|
||||
counter.markStudyProgramme(
|
||||
studentNumber,
|
||||
@@ -180,36 +221,49 @@ const getYearlyStatsOfNew = async (
|
||||
|
||||
const groupCode = separate ? semesterCode : yearCode
|
||||
const groupName = separate ? semesterName : yearName
|
||||
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, courseCode, yearCode)
|
||||
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, creditCourseCode, yearCode)
|
||||
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
|
||||
}
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, enrollmentDateTime } =
|
||||
parseEnrollment(enrollment, anonymizationSalt)
|
||||
for (const enrollments of enrollmentGroups) {
|
||||
if (!enrollments?.length) continue
|
||||
|
||||
const groupCode = separate ? semesterCode : yearCode
|
||||
const groupName = separate ? semesterName : yearName
|
||||
for (const enrollment of enrollments) {
|
||||
const {
|
||||
studentNumber,
|
||||
semesterCode,
|
||||
semesterName,
|
||||
yearCode,
|
||||
yearName,
|
||||
courseCode: enrollmentCourseCode,
|
||||
enrollmentDateTime,
|
||||
} = parseEnrollment(enrollment, anonymizationSalt)
|
||||
|
||||
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
const groupCode = separate ? semesterCode : yearCode
|
||||
const groupName = separate ? semesterName : yearName
|
||||
|
||||
counter.markEnrollmentToGroup(
|
||||
studentNumber,
|
||||
enrollmentDateTime,
|
||||
groupCode,
|
||||
groupName,
|
||||
enrollmentCourseCode,
|
||||
yearCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
const substitutionCourses: Pick<CourseModel, 'code' | 'name'>[] =
|
||||
combineSubstitutions && course?.substitutions?.length
|
||||
? await CourseModel.findAll({
|
||||
where: {
|
||||
code: { [Op.in]: codes },
|
||||
},
|
||||
attributes: ['code', 'name'],
|
||||
})
|
||||
: [{ code: course!.code, name: course!.name }]
|
||||
const substitutionGroups: Pick<CourseModel, 'code' | 'name'>[][] =
|
||||
combineSubstitutions && course?.substitution_groups?.length
|
||||
? await getSubstitutionGroupDetails(course.substitution_groups)
|
||||
: [[{ code: course!.code, name: course!.name }]]
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode: courseCode,
|
||||
alternatives: substitutionCourses,
|
||||
alternatives: substitutionGroups,
|
||||
name: course?.name,
|
||||
}
|
||||
}
|
||||
@@ -223,7 +277,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
raw: true,
|
||||
})) as { date: Date } | null
|
||||
|
||||
if (lastAttainmentDate === null) return 0
|
||||
if (lastAttainmentDate?.date == null) return 0
|
||||
|
||||
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
|
||||
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
|
||||
@@ -277,8 +331,20 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const course: Pick<CourseModel, 'code' | 'name' | 'substitutions' | 'substitution_groups'> | null =
|
||||
await CourseModel.findOne({
|
||||
attributes: ['code', 'name', 'substitutions', 'substitution_groups'],
|
||||
where: { code: courseCode },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
logger.error('Course for course stats not found with code' + courseCode)
|
||||
return []
|
||||
}
|
||||
|
||||
const [openStats, regularStats, unifyStats] = await Promise.all([
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.OPEN,
|
||||
@@ -287,6 +353,7 @@ export const getCourseYearlyStats = async (
|
||||
studentNumberToSrElementsMap
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.REGULAR,
|
||||
@@ -295,6 +362,7 @@ export const getCourseYearlyStats = async (
|
||||
studentNumberToSrElementsMap
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.UNIFY,
|
||||
@@ -327,3 +395,29 @@ export const getCourseProvidersForCourses = async (codes: string[]) => {
|
||||
})
|
||||
).map(({ code }) => code)
|
||||
}
|
||||
|
||||
export const getCourseDetails = async (codes: string[]) =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: { code: { [Op.in]: codes } },
|
||||
raw: true,
|
||||
})
|
||||
|
||||
// Add all substitution_groups and coursecodes together
|
||||
export const searchAndCombineSubstitutionGroupsToCodes = async (coursecodes: string[]) => {
|
||||
const substitutionGroups = await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['substitution_groups'],
|
||||
where: {
|
||||
code: { [Op.in]: coursecodes },
|
||||
},
|
||||
})
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
coursecodes.concat(
|
||||
substitutionGroups.flatMap(({ substitution_groups }) => substitution_groups.flatMap(code => code))
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { cloneDeep, omit } from 'lodash-es'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
|
||||
@@ -54,12 +55,12 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
|
||||
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
|
||||
|
||||
if (!medians[level]) {
|
||||
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omit(year, ['y']), median: year.y }))
|
||||
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omitKeys(year, ['y']), median: year.y }))
|
||||
} else {
|
||||
for (const statsForYear of graduationTimes.medians) {
|
||||
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
|
||||
medians[level].push({ ...omitKeys(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
@@ -73,12 +74,12 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
|
||||
|
||||
if (level === 'master' && comboTimes.medians.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omit(year, ['y']), median: year.y }))
|
||||
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omitKeys(year, ['y']), median: year.y }))
|
||||
} else {
|
||||
for (const statsForYear of comboTimes.medians) {
|
||||
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
|
||||
medians.bcMsCombo.push({ ...omitKeys(cloneDeep(statsForYear), ['y']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
@@ -101,7 +102,7 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
|
||||
programmes.medians[level][year.name] = { data: [], programmes: [] }
|
||||
}
|
||||
programmes.medians[level][year.name].data.push(
|
||||
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
|
||||
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
|
||||
)
|
||||
programmes.medians[level][year.name].programmes.push(code)
|
||||
}
|
||||
@@ -116,7 +117,7 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
|
||||
programmes.medians.bcMsCombo[year.name] = { data: [], programmes: [] }
|
||||
}
|
||||
programmes.medians.bcMsCombo[year.name].data.push(
|
||||
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
|
||||
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y'])
|
||||
)
|
||||
programmes.medians.bcMsCombo[year.name].programmes.push(code)
|
||||
}
|
||||
@@ -168,7 +169,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
classSizes.programmes[code] = classSizesByYear
|
||||
|
||||
if (!medians[level]) {
|
||||
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
medians[level] = cloneDeep(basicStats).map(year => ({ ...omitKeys(year, ['y', 'classSize']), median: year.y }))
|
||||
}
|
||||
classSizes[level] ??= {}
|
||||
for (const statsForYear of basicStats) {
|
||||
@@ -177,7 +178,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
|
||||
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
medians[level].push({ ...omitKeys(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
@@ -190,7 +191,10 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
|
||||
if (level === 'master' && comboStats.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({
|
||||
...omitKeys(year, ['y', 'classSize']),
|
||||
median: year.y,
|
||||
}))
|
||||
}
|
||||
classSizes.bcMsCombo ??= {}
|
||||
for (const statsForYear of comboStats) {
|
||||
@@ -198,7 +202,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize ?? 0
|
||||
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
medians.bcMsCombo.push({ ...omitKeys(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
@@ -220,7 +224,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
programmes.medians[level][year.name] = { data: [], programmes: [] }
|
||||
}
|
||||
programmes.medians[level][year.name].data.push(
|
||||
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
|
||||
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
|
||||
)
|
||||
programmes.medians[level][year.name].programmes.push(code)
|
||||
}
|
||||
@@ -235,7 +239,7 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
programmes.medians.bcMsCombo[year.name] = { data: [], programmes: [] }
|
||||
}
|
||||
programmes.medians.bcMsCombo[year.name].data.push(
|
||||
omit({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
|
||||
omitKeys({ ...cloneDeep(year), name: progId, code, median: year.y }, ['times', 'y', 'classSize'])
|
||||
)
|
||||
programmes.medians.bcMsCombo[year.name].programmes.push(code)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { isProduction, pateToken } from '../config'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const pateClient = axios.create({
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
const axios = require('axios')
|
||||
|
||||
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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 => {
|
||||
const accessToken = await getSisuAccessToken(eppn)
|
||||
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
|
||||
return { accessToken, tokenData }
|
||||
}
|
||||
|
||||
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
private_person(id: $subjectUserId) {
|
||||
id
|
||||
first_name:firstNames
|
||||
last_name:lastName
|
||||
call_name:callName
|
||||
eppn:eduPersonPrincipalName
|
||||
email_address:primaryEmail
|
||||
student_number:studentNumber
|
||||
phone_number:phoneNumber
|
||||
document_state:documentState
|
||||
}
|
||||
}`
|
||||
|
||||
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
|
||||
try {
|
||||
graphQlResponse = await axios(config)
|
||||
} 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,
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export const getCurrentSemester = async () => {
|
||||
|
||||
export const getSemestersAndYears = async () => {
|
||||
const semesters = await SemesterModel.findAll()
|
||||
return semesters.reduce(
|
||||
return semesters.reduce<SemestersAndYears>(
|
||||
(acc, semester) => {
|
||||
const { semestercode, name, yearcode, yearname, startdate, enddate } = semester
|
||||
acc.semesters[semestercode] = { semestercode, name, yearcode, startdate, enddate }
|
||||
@@ -68,6 +68,6 @@ export const getSemestersAndYears = async () => {
|
||||
|
||||
return acc
|
||||
},
|
||||
{ years: {}, semesters: {} } as SemestersAndYears
|
||||
{ years: {}, semesters: {} }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,39 +8,39 @@ const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
const params = { params: { token: SECRET_TOKEN } }
|
||||
|
||||
export const abortUpdate = async () => {
|
||||
const response = await client.get('/v1/abort', params)
|
||||
const response = await client.get('/abort', params)
|
||||
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('/courses', data, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISMetadata = async () => {
|
||||
const response = await client.get('/v1/meta', params)
|
||||
const response = await client.get('/meta', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISProgrammes = async () => {
|
||||
const response = await client.get('/v1/programmes', params)
|
||||
const response = await client.get('/programmes', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISRedisCache = async () => {
|
||||
const response = await client.get('/v1/rediscache', params)
|
||||
const response = await client.get('/rediscache', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudents = async () => {
|
||||
const response = await client.get('/v1/students', params)
|
||||
const response = await client.get('/students', params)
|
||||
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('/students', data, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -54,6 +54,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('studyplans', { studentnumbers }, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const getUnifyStatus = (unifyCourses: UnifyStatus): [boolean] | [true, false] =>
|
||||
|
||||
/* from & to are semestercodes if separate = false, or yearcodes in case separate is true. */
|
||||
export const findByCourseAndSemesters = async (
|
||||
coursecodes: string[],
|
||||
codes: string[],
|
||||
from: number,
|
||||
to: number,
|
||||
separate: boolean,
|
||||
@@ -169,7 +169,7 @@ export const findByCourseAndSemesters = async (
|
||||
credit c
|
||||
WHERE
|
||||
c.student_studentnumber = s.studentnumber
|
||||
AND c.course_code IN (:coursecodes)
|
||||
AND c.course_code IN (:codes)
|
||||
AND c.is_open IN (:isOpen)
|
||||
AND c.attainment_date BETWEEN '${startdate.toISOString()}' AND '${enddate.toISOString()}'
|
||||
)
|
||||
@@ -180,7 +180,7 @@ export const findByCourseAndSemesters = async (
|
||||
enrollment e
|
||||
WHERE
|
||||
e.studentnumber = s.studentnumber
|
||||
AND e.course_code IN (:coursecodes)
|
||||
AND e.course_code IN (:codes)
|
||||
AND e.semestercode BETWEEN ${fromSemester} AND ${toSemester}
|
||||
AND e.enrollment_date_time >= '2021-05-31'
|
||||
AND e.state = :enrollmentState
|
||||
@@ -188,7 +188,7 @@ export const findByCourseAndSemesters = async (
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
coursecodes,
|
||||
codes,
|
||||
minYearCode: from,
|
||||
maxYearCode: to,
|
||||
isOpen: unifyStatus,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
import { StudyGuidanceGroupTagModel } from '../models/kone'
|
||||
@@ -50,7 +49,7 @@ export const getAllStudentsUserHasInGroups = async (sisPersonId: string) => {
|
||||
.map(group => group.members)
|
||||
.flat()
|
||||
.map(member => member.personStudentNumber)
|
||||
return uniq(students)
|
||||
return Array.from(new Set(students))
|
||||
}
|
||||
|
||||
const getTagToUpdate = (studyProgramme?: string | null, year?: string | null) => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { range } from 'lodash-es'
|
||||
|
||||
import { Name, StudyProgrammeCourse, YearType } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { mapToProviders, range } from '@oodikone/shared/util'
|
||||
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
|
||||
|
||||
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { ProgrammeModuleModel } from '../../models'
|
||||
@@ -13,34 +13,33 @@ export function getYearsArray(since: number, isAcademicYear: false, yearsCombine
|
||||
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined?: false): number[]
|
||||
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean): Array<string | number>
|
||||
export function getYearsArray(since: number, isAcademicYear: boolean, yearsCombined?: boolean) {
|
||||
const years: Array<string | number> = []
|
||||
const allYears = 'Total'
|
||||
if (yearsCombined) {
|
||||
years.push(allYears)
|
||||
}
|
||||
const today = new Date()
|
||||
const until = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
for (let i = since; i <= until; i++) {
|
||||
const year = isAcademicYear ? `${i} - ${i + 1}` : i
|
||||
years.push(year)
|
||||
const years: Array<string | number> = []
|
||||
|
||||
if (yearsCombined) {
|
||||
years.push('Total')
|
||||
}
|
||||
|
||||
for (let i = since; i <= until; i++) {
|
||||
years.push(isAcademicYear ? `${i} - ${i + 1}` : i)
|
||||
}
|
||||
|
||||
return years
|
||||
}
|
||||
|
||||
export function getYearsObject(params: { years: Array<string | number>; emptyArrays: true }): Record<string, []>
|
||||
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, 0>
|
||||
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: false }): Record<string, number>
|
||||
export function getYearsObject(params: { years: Array<string | number>; emptyArrays?: boolean }) {
|
||||
const { years, emptyArrays = false } = params
|
||||
return years.reduce<Record<string, 0 | []>>((acc, year) => {
|
||||
acc[year] = emptyArrays ? [] : 0
|
||||
return acc
|
||||
}, {})
|
||||
const { years, emptyArrays } = params
|
||||
|
||||
return Object.fromEntries(years.map(year => [year, emptyArrays ? [] : 0]))
|
||||
}
|
||||
|
||||
export const getStatsBasis = (years: Array<string | number>) => {
|
||||
return {
|
||||
graphStats: new Array<number>(years.length).fill(0),
|
||||
tableStats: getYearsObject({ years }) as Record<string, number>,
|
||||
tableStats: getYearsObject({ years }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +56,7 @@ export const getMedian = (values: number[]) => {
|
||||
return (sorted[half - 1] + sorted[half]) / 2.0
|
||||
}
|
||||
|
||||
/** @returns academic starting year e.g. "2019-2020" */
|
||||
export function defineYear(date: Date, isAcademicYear: true): string
|
||||
export function defineYear(date: Date, isAcademicYear: false): number
|
||||
export function defineYear(date: Date, isAcademicYear: boolean): string | number
|
||||
@@ -75,8 +75,109 @@ export function defineYear(date: Date, isAcademicYear: boolean) {
|
||||
return `${year} - ${year + 1}`
|
||||
}
|
||||
|
||||
export const getStartDate = (isAcademicYear: boolean) => {
|
||||
return isAcademicYear ? new Date('2017-08-01') : new Date('2017-01-01')
|
||||
const filterCreditsByDate = (credits: Credit[], threshold: Date) =>
|
||||
credits.filter(credit => threshold <= credit.attainment_date)
|
||||
|
||||
export const getCreditCount = (credits: Credit[], startDate: Date) =>
|
||||
filterCreditsByDate(credits, startDate).reduce((prev, curr) => prev + curr.credits, 0)
|
||||
|
||||
/**
|
||||
* @param startDate exact date when started in programme to cut credit attainment date
|
||||
* @param startYear academic start year
|
||||
*/
|
||||
export const getMonthlyCredits = (
|
||||
credits: Credit[],
|
||||
startDate: Date,
|
||||
startYear: number,
|
||||
monthlyCredits: Record<string, number[]>
|
||||
) => {
|
||||
const filteredCredits = filterCreditsByDate(credits, startDate)
|
||||
const studentMonthlyCredits = getMonthlyCreditsObj(startYear)
|
||||
|
||||
/* Add all credits to a per-month credit map */
|
||||
for (const credit of filteredCredits) {
|
||||
const creditKey = `${credit.attainment_date.getFullYear()}-${credit.attainment_date.getMonth() + 1}`
|
||||
studentMonthlyCredits[creditKey].push(credit.credits)
|
||||
}
|
||||
|
||||
/* Add monthly cumulative credits of individual student to the main obj,
|
||||
must iterate over months in order */
|
||||
let accumulator = 0
|
||||
const sortedKeys = Object.keys(monthlyCredits).sort((a, b) => +new Date(a) - +new Date(b))
|
||||
for (const creditKey of sortedKeys) {
|
||||
for (const credit of studentMonthlyCredits[creditKey]) {
|
||||
accumulator += credit
|
||||
}
|
||||
|
||||
monthlyCredits[creditKey].push(accumulator)
|
||||
}
|
||||
}
|
||||
|
||||
export const getStartDate = (isAcademicYear: boolean, year = 2017) => {
|
||||
return isAcademicYear ? new Date(`${year}-08-01`) : new Date(`${year}-01-01`)
|
||||
}
|
||||
|
||||
export const getYearlyMonthlyCreditsObj = () => {
|
||||
const yearlyMonthlyCredits: Record<string, ReturnType<typeof getMonthlyCreditsObj>> = {}
|
||||
const today = new Date()
|
||||
|
||||
for (let year = getStartDate(true).getFullYear(); year <= today.getFullYear(); year++) {
|
||||
yearlyMonthlyCredits[year] = getMonthlyCreditsObj(year)
|
||||
}
|
||||
|
||||
return yearlyMonthlyCredits
|
||||
}
|
||||
|
||||
const getMonthlyCreditsObj = (academicStartYear?: number) => {
|
||||
const today = new Date()
|
||||
const time = getStartDate(true, academicStartYear)
|
||||
|
||||
const monthlyCredits: Record<string, number[]> = {}
|
||||
today.setDate(1)
|
||||
|
||||
/* NB: JS months start at 0. We need them to start at 1. */
|
||||
while (time < today) {
|
||||
monthlyCredits[`${time.getFullYear()}-${time.getMonth() + 1}`] = []
|
||||
time.setMonth(time.getMonth() + 1)
|
||||
}
|
||||
|
||||
return monthlyCredits
|
||||
}
|
||||
|
||||
export const computePercentiles = (
|
||||
yearlyMonthlyCredits: Record<string, Record<string, number[]>>,
|
||||
percentiles = [10, 25, 50, 75, 90]
|
||||
) => {
|
||||
const values: Record<string, Record<string, [string, number][]>> = {}
|
||||
|
||||
const interpolate = (percentile: number, credits: number[]) => {
|
||||
const n = credits.length
|
||||
if (n === 0) return NaN
|
||||
|
||||
const p = percentile / 100
|
||||
const index = (n - 1) * p
|
||||
const lower = Math.floor(index)
|
||||
const upper = Math.ceil(index)
|
||||
const weight = index - lower
|
||||
|
||||
if (upper >= n) return credits[lower]
|
||||
if (lower === upper) return credits[lower]
|
||||
|
||||
return credits[lower] * (1 - weight) + credits[upper] * weight
|
||||
}
|
||||
|
||||
for (const yearKey of Object.keys(yearlyMonthlyCredits)) {
|
||||
for (const monthlyKey of Object.keys(yearlyMonthlyCredits[yearKey])) {
|
||||
const sortedCredits = yearlyMonthlyCredits[yearKey][monthlyKey].sort((a, b) => a - b)
|
||||
for (const percentile of percentiles) {
|
||||
values[yearKey] ??= {}
|
||||
values[yearKey][percentile] ??= []
|
||||
values[yearKey][percentile].push([monthlyKey, Number(interpolate(percentile, sortedCredits).toFixed(1))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// In the object programmes should be {bachelorCode: masterCode}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { col, fn, Includeable, Op } from 'sequelize'
|
||||
|
||||
import { SISStudyRight } from '@oodikone/shared/models'
|
||||
import { Name, CreditTypeCode } from '@oodikone/shared/types'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import { StudentModel, SISStudyRightModel, SISStudyRightElementModel, CreditModel } from '../../models'
|
||||
|
||||
export const getStudyRightsInProgramme = async (
|
||||
@@ -73,20 +73,15 @@ export const getStudyTracksForProgramme = async (studyProgramme: string) => {
|
||||
attributes: [[fn('DISTINCT', col('study_track')), 'studyTrack']],
|
||||
where: {
|
||||
code: studyProgramme,
|
||||
study_track: { [Op.ne]: null },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
return result
|
||||
.map(studyTrack => studyTrack.studyTrack)
|
||||
.filter(studyTrack => studyTrack != null)
|
||||
.reduce<Record<string, Name | 'All students of the programme'>>(
|
||||
(acc, track) => {
|
||||
acc[track.code] = track.name
|
||||
return acc
|
||||
},
|
||||
{ [studyProgramme]: 'All students of the programme' }
|
||||
)
|
||||
return Object.assign(
|
||||
{ [studyProgramme]: 'All students of the programme' },
|
||||
Object.fromEntries(result.map(({ studyTrack }) => [studyTrack!.code, studyTrack!.name]))
|
||||
)
|
||||
}
|
||||
|
||||
export const getSISStudyRightsOfStudents = async (
|
||||
@@ -96,11 +91,11 @@ export const getSISStudyRightsOfStudents = async (
|
||||
> =>
|
||||
(
|
||||
await SISStudyRightModel.findAll({
|
||||
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
|
||||
where: {
|
||||
studentNumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
attributes: ['id', 'studentNumber', 'extentCode', 'semesterEnrollments', 'startDate', 'endDate'],
|
||||
})
|
||||
).map(studyRight => studyRight.toJSON())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import {
|
||||
GraduationTimes,
|
||||
MedianEntry,
|
||||
@@ -15,12 +15,16 @@ import { getSemestersAndYears } from '../semesters'
|
||||
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
|
||||
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
|
||||
import {
|
||||
computePercentiles,
|
||||
defineYear,
|
||||
getCreditCount,
|
||||
getGoal,
|
||||
getMedian,
|
||||
getMonthlyCredits,
|
||||
getPercentage,
|
||||
getStartDate,
|
||||
getStudyRightElementsWithPhase,
|
||||
getYearlyMonthlyCreditsObj,
|
||||
getYearsArray,
|
||||
getYearsObject,
|
||||
hasTransferredFromOrToProgramme,
|
||||
@@ -28,9 +32,6 @@ import {
|
||||
} from './studyProgrammeHelpers'
|
||||
import { getStudyTracksForProgramme } from './studyRightFinders'
|
||||
|
||||
const getCreditCount = (credits: Credit[], startDate: Date) =>
|
||||
credits.filter(credit => startDate <= credit.attainment_date).reduce((prev, curr) => prev + curr.credits, 0)
|
||||
|
||||
const getGraduationTimeStats = async (
|
||||
studyProgramme: string,
|
||||
graduationTimes: Record<string, Record<string, number[]>>,
|
||||
@@ -206,8 +207,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
startedInProgramme
|
||||
)
|
||||
if (firstPresentAt) {
|
||||
const firstPresentYear = defineYear(firstPresentAt, true)
|
||||
if (year === firstPresentYear) {
|
||||
if (year === defineYear(firstPresentAt, true)) {
|
||||
yearlyStats[year][programmeOrStudyTrack].started += 1
|
||||
}
|
||||
}
|
||||
@@ -332,33 +332,40 @@ const getMainStatsByTrackAndYear = async (
|
||||
emptyArrays: true,
|
||||
})
|
||||
|
||||
const creditCountsByTrack: Record<string, Record<string, number[]>> = {}
|
||||
|
||||
const creditCountsCombo: Record<string, number[]> = doCombo
|
||||
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
|
||||
: {}
|
||||
|
||||
const creditCountsComboByTrack: Record<string, Record<string, number[]>> = {}
|
||||
|
||||
const monthlyCreditsByStartingYear = getYearlyMonthlyCreditsObj()
|
||||
const monthlyCreditsByStartingYearByTrack: Record<string, ReturnType<typeof getYearlyMonthlyCreditsObj>> = {}
|
||||
const monthlyCreditsByStartingYearCombo = getYearlyMonthlyCreditsObj()
|
||||
const monthlyCreditsByStartingYearComboByTrack: Record<string, ReturnType<typeof getYearlyMonthlyCreditsObj>> = {}
|
||||
|
||||
for (const studyRight of studyRightsOfProgramme) {
|
||||
if (!studyRight.semesterEnrollments || !studyRight.student) {
|
||||
continue
|
||||
}
|
||||
if (!studyRight.semesterEnrollments || !studyRight.student) continue
|
||||
|
||||
const studyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
|
||||
if (!studyRightElement) {
|
||||
continue
|
||||
}
|
||||
if (!studyRightElement) continue
|
||||
|
||||
const [hasTransferredFromProgramme, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
|
||||
studyRight,
|
||||
studyRightElement
|
||||
)
|
||||
|
||||
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) {
|
||||
continue
|
||||
}
|
||||
if (!includeAllSpecials && (hasTransferredToProgramme || hasTransferredFromProgramme)) continue
|
||||
|
||||
const startedInProgramme = new Date(studyRightElement.startDate)
|
||||
const startYear = defineYear(startedInProgramme, true)
|
||||
if (!years.includes(startYear)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!years.includes(startYear)) continue
|
||||
|
||||
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
|
||||
const bachelorsStartYear = defineYear(startedInBachelor, true)
|
||||
|
||||
updateCounts(
|
||||
startYear,
|
||||
studyProgramme,
|
||||
@@ -380,18 +387,69 @@ const getMainStatsByTrackAndYear = async (
|
||||
)
|
||||
}
|
||||
|
||||
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
|
||||
// TODO: FIXME: This could be written better
|
||||
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER && years.includes(bachelorsStartYear)) {
|
||||
const bscStartYear = Number(bachelorsStartYear.slice(0, 4))
|
||||
|
||||
getMonthlyCredits(
|
||||
studyRight.student.credits,
|
||||
startedInBachelor,
|
||||
bscStartYear,
|
||||
monthlyCreditsByStartingYearCombo[bscStartYear]
|
||||
)
|
||||
if (studyTrack) {
|
||||
monthlyCreditsByStartingYearComboByTrack[studyTrack] ??= getYearlyMonthlyCreditsObj()
|
||||
getMonthlyCredits(
|
||||
studyRight.student.credits,
|
||||
startedInBachelor,
|
||||
bscStartYear,
|
||||
monthlyCreditsByStartingYearComboByTrack[studyTrack][bscStartYear]
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const academicStartYear = Number(startYear.slice(0, 4))
|
||||
|
||||
getMonthlyCredits(
|
||||
studyRight.student.credits,
|
||||
startedInProgramme,
|
||||
academicStartYear,
|
||||
monthlyCreditsByStartingYear[academicStartYear]
|
||||
)
|
||||
if (studyTrack) {
|
||||
monthlyCreditsByStartingYearByTrack[studyTrack] ??= getYearlyMonthlyCreditsObj()
|
||||
getMonthlyCredits(
|
||||
studyRight.student.credits,
|
||||
startedInProgramme,
|
||||
academicStartYear,
|
||||
monthlyCreditsByStartingYearByTrack[studyTrack][academicStartYear]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!studyRightElement.graduated) {
|
||||
if (countAsBachelorMaster) {
|
||||
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
|
||||
if (startedInBachelor && years.includes(defineYear(startedInBachelor, true))) {
|
||||
creditCountsCombo[defineYear(startedInBachelor, true)].push(
|
||||
getCreditCount(studyRight.student.credits, startedInBachelor)
|
||||
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER && years.includes(bachelorsStartYear)) {
|
||||
creditCountsCombo[bachelorsStartYear].push(getCreditCount(studyRight.student.credits, startedInBachelor))
|
||||
|
||||
if (studyTrack) {
|
||||
creditCountsComboByTrack[studyTrack] ??= doCombo
|
||||
? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true })
|
||||
: {}
|
||||
|
||||
creditCountsComboByTrack[studyTrack][startYear].push(
|
||||
getCreditCount(studyRight.student.credits, startedInProgramme)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
creditCounts[startYear].push(getCreditCount(studyRight.student.credits, startedInProgramme))
|
||||
if (studyTrack) {
|
||||
creditCountsByTrack[studyTrack] ??= getYearsObject({
|
||||
years: years.filter(year => year !== 'Total'),
|
||||
emptyArrays: true,
|
||||
})
|
||||
|
||||
creditCountsByTrack[studyTrack][startYear].push(
|
||||
getCreditCount(studyRight.student.credits, startedInProgramme)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,10 +474,9 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
const graduatedCount: Record<string, number> = {}
|
||||
years.forEach(year => {
|
||||
graduatedCount[year] = yearlyStats[year] ? yearlyStats[year][studyProgramme].graduated : 0
|
||||
})
|
||||
const graduatedCount: Record<string, number> = Object.fromEntries(
|
||||
years.map(year => [year, yearlyStats[year]?.[studyProgramme].graduated ?? 0])
|
||||
)
|
||||
|
||||
const { mainStatsByYear, mainStatsByTrack, otherCountriesCount } = combineStats(
|
||||
years,
|
||||
@@ -428,6 +485,13 @@ const getMainStatsByTrackAndYear = async (
|
||||
combinedProgramme
|
||||
)
|
||||
|
||||
const graduatedCountByTrack: Record<string, Record<string, number>> = Object.fromEntries(
|
||||
Object.keys(mainStatsByTrack).map(track => [
|
||||
track,
|
||||
Object.fromEntries(years.map(year => [year, yearlyStats[year]?.[track]?.graduated ?? 0])),
|
||||
])
|
||||
)
|
||||
|
||||
const finalGraduationTimes = await getGraduationTimeStats(
|
||||
studyProgramme,
|
||||
graduationTimes,
|
||||
@@ -444,12 +508,19 @@ const getMainStatsByTrackAndYear = async (
|
||||
)
|
||||
|
||||
return {
|
||||
monthlyCreditsByStartingYear,
|
||||
monthlyCreditsByStartingYearByTrack,
|
||||
monthlyCreditsByStartingYearCombo,
|
||||
monthlyCreditsByStartingYearComboByTrack,
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
otherCountriesCount,
|
||||
creditCounts,
|
||||
creditCountsByTrack,
|
||||
creditCountsCombo,
|
||||
creditCountsComboByTrack,
|
||||
graduatedCount,
|
||||
graduatedCountByTrack,
|
||||
graduationTimes: finalGraduationTimes,
|
||||
graduationTimesSecondProg: finalCombinedGraduationTimes,
|
||||
}
|
||||
@@ -468,12 +539,10 @@ export const getStudyTrackStatsForStudyProgramme = async ({
|
||||
settings: { specialGroups: boolean }
|
||||
studyRightsOfProgramme: SISStudyRight[]
|
||||
}) => {
|
||||
const isAcademicYear = true
|
||||
const includeYearsCombined = true
|
||||
const since = getStartDate(isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear, includeYearsCombined)
|
||||
const years = getYearsArray(getStartDate(true).getFullYear(), true, true)
|
||||
|
||||
const studyTracks = await getStudyTracksForProgramme(studyProgramme)
|
||||
const hasStudyTracks = Object.keys(studyTracks).length > 1
|
||||
|
||||
const doCombo = await shouldIncludeComboStats(studyProgramme)
|
||||
const stats = await getMainStatsByTrackAndYear(
|
||||
@@ -485,17 +554,44 @@ export const getStudyTrackStatsForStudyProgramme = async ({
|
||||
combinedProgramme
|
||||
)
|
||||
|
||||
const getCorrectCombinedTitles = () => {
|
||||
if (combinedProgramme === 'MH90_001') return tableTitles.studytracksCombined.licentiate
|
||||
return tableTitles.studytracksCombined.master
|
||||
const percentiles: StudyTrackStats['percentiles'] = {
|
||||
main: computePercentiles(stats.monthlyCreditsByStartingYear),
|
||||
byTrack: hasStudyTracks
|
||||
? Object.fromEntries(
|
||||
Object.keys(stats.monthlyCreditsByStartingYearByTrack).map(track => [
|
||||
track,
|
||||
computePercentiles(stats.monthlyCreditsByStartingYearByTrack[track]),
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
combo: doCombo ? computePercentiles(stats.monthlyCreditsByStartingYearCombo) : undefined,
|
||||
comboByTrack:
|
||||
doCombo && hasStudyTracks
|
||||
? Object.fromEntries(
|
||||
Object.keys(stats.monthlyCreditsByStartingYearComboByTrack).map(track => [
|
||||
track,
|
||||
computePercentiles(stats.monthlyCreditsByStartingYearComboByTrack[track]),
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
const graduatedTitles = combinedProgramme ? getCorrectCombinedTitles() : tableTitles.studytracksBasic
|
||||
|
||||
const graduatedTitles = combinedProgramme
|
||||
? combinedProgramme === 'MH90_001'
|
||||
? tableTitles.studytracksCombined.licentiate
|
||||
: tableTitles.studytracksCombined.master
|
||||
: tableTitles.studytracksBasic
|
||||
|
||||
// FIXME: Clean this object. Thanks.
|
||||
const studyTrackStats: StudyTrackStats = {
|
||||
creditCounts: stats.creditCounts,
|
||||
creditCountsByTrack: stats.creditCountsByTrack,
|
||||
creditCountsCombo: stats.creditCountsCombo,
|
||||
creditCountsComboByTrack: stats.creditCountsComboByTrack,
|
||||
percentiles,
|
||||
doCombo,
|
||||
graduatedCount: stats.graduatedCount,
|
||||
graduatedCountByTrack: stats.graduatedCountByTrack,
|
||||
graduationTimes: stats.graduationTimes,
|
||||
graduationTimesSecondProg: stats.graduationTimesSecondProg,
|
||||
id: combinedProgramme ? `${studyProgramme}-${combinedProgramme}` : studyProgramme,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { isEqual, keyBy, omit, uniq } from 'lodash-es'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { User } from '@oodikone/shared/models/user'
|
||||
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
|
||||
import { DetailedProgrammeRights, Role, ExpandedUser, FormattedUser, IamAccess } from '@oodikone/shared/types'
|
||||
import { keyBy, omitKeys } from '@oodikone/shared/util'
|
||||
import { roles } from '../config/roles'
|
||||
import { sequelizeUser } from '../database/connection'
|
||||
import { UserModel } from '../models/user'
|
||||
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
|
||||
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import * as jami from '../util/jami'
|
||||
import { sendNotificationAboutNewUser } from './mailService'
|
||||
import { getSisuAuthData, personSearchQuery, getGraphqlData } from './oriProvider'
|
||||
import { getStudentnumbersByElementdetails } from './students'
|
||||
import { checkStudyGuidanceGroupsAccess, getAllStudentsUserHasInGroups } from './studyGuidanceGroups'
|
||||
|
||||
@@ -49,7 +47,7 @@ export const modifyElementDetails = async (id: string, codes: string[], enable:
|
||||
throw new Error(`User with id ${id} not found`)
|
||||
}
|
||||
if (enable === true) {
|
||||
user.programmeRights = uniq([...user.programmeRights, ...codes])
|
||||
user.programmeRights = Array.from(new Set([...user.programmeRights, ...codes]))
|
||||
} else {
|
||||
user.programmeRights = user.programmeRights.filter(code => !codes.includes(code))
|
||||
}
|
||||
@@ -135,7 +133,16 @@ const formatUser = async (user: ExpandedUser, getStudentAccess = true) => {
|
||||
|
||||
const formatUserForFrontend = async (user: ExpandedUser) => {
|
||||
const formattedUser = await formatUser(user, false)
|
||||
return omit(formattedUser, ['studentsUserCanAccess', 'isAdmin', 'mockedBy', 'userId'])
|
||||
return omitKeys(formattedUser, ['studentsUserCanAccess', 'isAdmin', 'mockedBy', 'userId'])
|
||||
}
|
||||
|
||||
const arraysAreEqual = <T>(a: T[], b: T[]): boolean => {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const updateAccessGroups = async (
|
||||
@@ -160,7 +167,7 @@ const updateAccessGroups = async (
|
||||
...(fullSisuAccess ? ['fullSisuAccess'] : []),
|
||||
] as Role[]
|
||||
|
||||
if (!isEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
|
||||
if (!arraysAreEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
|
||||
userFromDb.roles = newAccessGroups
|
||||
await userFromDb.save()
|
||||
}
|
||||
@@ -173,7 +180,7 @@ export const findAll = async () => {
|
||||
|
||||
const formattedUsers = await Promise.all(
|
||||
users.map(async user => {
|
||||
const { iamGroups, specialGroup, access } = userAccessMap[user.sisuPersonId] || {}
|
||||
const { iamGroups, specialGroup, access } = userAccessMap[user.sisuPersonId] ?? {}
|
||||
const detailedProgrammeRights = getStudyProgrammeRights(access, specialGroup, user.programmeRights)
|
||||
const formattedUser = await formatUserForFrontend({ ...user, detailedProgrammeRights, iamGroups })
|
||||
return formattedUser
|
||||
@@ -194,10 +201,6 @@ export const findOne = async (id: string) => {
|
||||
return formattedUser
|
||||
}
|
||||
|
||||
export const findByUsername = async (username: string) => {
|
||||
return (await UserModel.findOne({ where: { username } }))?.toJSON()
|
||||
}
|
||||
|
||||
export const getOrganizationAccess = async (sisPersonId: string, iamGroups: string[]) => {
|
||||
if (!iamGroups.length) {
|
||||
return {}
|
||||
@@ -262,6 +265,7 @@ export const getUser = async ({
|
||||
sisuPersonId: sisId,
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
|
||||
await updateAccessGroups(iamGroups, specialGroup, sisId, upsertResult)
|
||||
|
||||
const userJSON = upsertResult.toJSON()
|
||||
@@ -272,38 +276,3 @@ export const getUser = async ({
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
export const addNewUser = async user => {
|
||||
const name = user.first_name.concat(' ', user.last_name)
|
||||
try {
|
||||
await UserModel.upsert({
|
||||
fullName: name,
|
||||
username: user.eppn,
|
||||
email: user.email_address,
|
||||
sisuPersonId: user.id,
|
||||
roles: [],
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
throw new Error('Could not add or update user.')
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserFromSisuByEppn = async (requesterEppn: string, newUserEppn: string) => {
|
||||
const { accessToken: requesterAccessToken } = await getSisuAuthData(requesterEppn)
|
||||
const { tokenData: newUserTokenData } = await getSisuAuthData(newUserEppn)
|
||||
const personData = await getGraphqlData(requesterAccessToken, {
|
||||
query: personSearchQuery,
|
||||
variables: { subjectUserId: newUserTokenData.personid },
|
||||
})
|
||||
return personData
|
||||
}
|
||||
|
||||
export const deleteUserById = async userId => {
|
||||
await UserModel.destroy({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type IamAccess = Record<string, Record<'read' | 'write' | 'admin', boolean>>
|
||||
@@ -1,3 +1 @@
|
||||
export { ParsedCourse } from './course'
|
||||
export { IamAccess } from './iamAccess'
|
||||
export { ExpandedUser, FormattedUser } from './user'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { FormattedUser } from './user'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
|
||||
@@ -49,6 +49,9 @@ export const hasFullAccessToStudentData = (roles?: Role[]): boolean => {
|
||||
return !!roles?.some(role => rolesWithFullAccess.includes(role))
|
||||
}
|
||||
|
||||
// Only open uni courses
|
||||
export const isOpenUniCourseCode = (code: string): boolean => /^AY.+?(?:en|fi|sv)?$/.test(code)
|
||||
// Open uni AND normal courses
|
||||
export const getOpenUniCourseCode = (code: string): string[] | null => /^AY?(.+?)(?:en|fi|sv)?$/.exec(code)
|
||||
|
||||
/**
|
||||
@@ -100,3 +103,8 @@ export const safeJSONParse = async <T>(json: string): Promise<T | null> => {
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
|
||||
export const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
|
||||
return Array.isArray(requestField) ? requestField : [requestField]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import axios from 'axios'
|
||||
|
||||
import { IamAccess } from '@oodikone/shared/types'
|
||||
import { importerToken, isStaging, jamiUrl } from '../config'
|
||||
import { IamAccess } from '../types'
|
||||
import logger from './logger'
|
||||
|
||||
const jamiClient = axios.create({
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ import { Sequelize } from 'sequelize'
|
||||
import request, { Response } from 'supertest'
|
||||
|
||||
import { PopulationstatisticsResBody } from '@oodikone/shared/routes/populations'
|
||||
import { dbConnections } from '../src/database/connection'
|
||||
import { initTests } from './utils'
|
||||
import { dbConnections } from '../../src/database/connection'
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
let connection: Sequelize
|
||||
@@ -24,7 +24,7 @@ const populationUrl = (
|
||||
semesters: ('SPRING' | 'FALL')[] = [],
|
||||
students: string[] = []
|
||||
) => {
|
||||
return `/v3/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
|
||||
return `/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
|
||||
}
|
||||
|
||||
void describe('Population statistics', async () => {
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics by course', async () => {
|
||||
await it('should not return anything with missing parameters', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbycourse')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.deepEqual(res.status, 400)
|
||||
assert.deepEqual(res.body.error, 'The body should have a yearcode and coursecode defined')
|
||||
})
|
||||
|
||||
await it('should return correct amount of students for a course', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbycourse?coursecodes=["MAT21005"]&from=70&to=70')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
// Copied from response
|
||||
const testStudent = {
|
||||
firstnames: 'Mira Ilmatar',
|
||||
lastname: 'Lintula',
|
||||
started: '2018-08-01T00:00:00.000Z',
|
||||
studentNumber: '394776',
|
||||
credits: 265,
|
||||
hopsCredits: 0,
|
||||
name: 'Lintula Mira Ilmatar',
|
||||
gender_code: '2',
|
||||
email: 'sisutestidata65689@testisisudata.fi',
|
||||
secondaryEmail: null,
|
||||
phoneNumber: '+358501234567',
|
||||
updatedAt: '2026-05-24T13:43:07.827Z',
|
||||
studyrightStart: '1899-12-31T22:20:11.000Z',
|
||||
option: null,
|
||||
birthdate: '1999-03-01T00:00:00.000Z',
|
||||
sis_person_id: 'hy-hlo-125389506',
|
||||
citizenships: [
|
||||
{
|
||||
en: 'Finland',
|
||||
fi: 'Suomi',
|
||||
sv: 'Finland',
|
||||
},
|
||||
],
|
||||
curriculumVersion: null,
|
||||
hasPersonalIdentityCode: null,
|
||||
tags: [],
|
||||
transferredStudyright: false,
|
||||
studyRights: [
|
||||
{
|
||||
admissionType: null,
|
||||
cancelled: false,
|
||||
extentCode: 5,
|
||||
facultyCode: 'H50',
|
||||
id: 'hy-opinoik-125389507',
|
||||
semesterEnrollments: [
|
||||
{ semester: 137, type: 1 },
|
||||
{ semester: 138, type: 1 },
|
||||
{ semester: 139, type: 1 },
|
||||
{ semester: 140, type: 1 },
|
||||
{ semester: 141, type: 1 },
|
||||
{ semester: 142, type: 1 },
|
||||
{ semester: 143, type: 1 },
|
||||
{ semester: 144, type: 1 },
|
||||
{ semester: 145, type: 1 },
|
||||
{ semester: 146, type: 1 },
|
||||
{ semester: 147, type: 1 },
|
||||
{ semester: 148, statutoryAbsence: false, type: 2 },
|
||||
{ semester: 149, type: 3 },
|
||||
{ semester: 150, type: 3 },
|
||||
{ semester: 151, type: 3 },
|
||||
],
|
||||
startDate: '2018-08-01T00:00:00.000Z',
|
||||
studyRightElements: [
|
||||
{
|
||||
code: 'KH50_001',
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
endDate: '2021-06-11T00:00:00.000Z',
|
||||
graduated: true,
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
phase: 1,
|
||||
startDate: '2018-08-01T00:00:00.000Z',
|
||||
studyTrack: {
|
||||
code: 'MAT-MAT',
|
||||
name: {
|
||||
fi: 'Matematiikka',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'MH50_001',
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:masters-degree',
|
||||
endDate: '2025-12-31T00:00:00.000Z',
|
||||
graduated: false,
|
||||
name: {
|
||||
en: "Master 's Programme in Mathematics and Statistics",
|
||||
fi: 'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
sv: 'Magisterprogrammet i matematik och statistik',
|
||||
},
|
||||
phase: 2,
|
||||
startDate: '2021-06-12T00:00:00.000Z',
|
||||
studyTrack: null,
|
||||
},
|
||||
],
|
||||
tvex: false,
|
||||
},
|
||||
],
|
||||
studyplans: [],
|
||||
}
|
||||
|
||||
assert.strictEqual(res.status, 200)
|
||||
assert.strictEqual(res.body.students.length, 40)
|
||||
assert.deepStrictEqual(res.body.students.sort((a, b) => b.studentNumber - a.studentNumber).at(-1), testStudent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics by student number', async () => {
|
||||
await it('should not return anything with missing parameters', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbystudentnumbers')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.deepEqual(res.status, 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics (study programme)', async () => {
|
||||
await it('should return correctly formatted responses', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.strictEqual(res.status, 200)
|
||||
|
||||
assert.strictEqual(Object.keys(res.body).includes('allProgrammes'), true)
|
||||
assert.strictEqual(Object.keys(res.body).includes('filteredProgrammes'), true)
|
||||
|
||||
assert.notStrictEqual(Object.keys(res.body.allProgrammes).length, 0)
|
||||
assert.notStrictEqual(Object.keys(res.body.filteredProgrammes).length, 0)
|
||||
})
|
||||
|
||||
await it('should return correct programmes to allProgrammes', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
const testMathProgramme = {
|
||||
code: 'KH50_001',
|
||||
curriculumPeriodIds: [
|
||||
'hy-lv-76',
|
||||
'hy-lv-74',
|
||||
'hy-lv-75',
|
||||
'hy-lv-72',
|
||||
'hy-lv-73',
|
||||
'hy-lv-71',
|
||||
'hy-lv-69',
|
||||
'hy-lv-68',
|
||||
'hy-lv-70',
|
||||
],
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
progId: 'MAT',
|
||||
}
|
||||
|
||||
assert.strictEqual(Object.keys(res.body.allProgrammes).length, 972)
|
||||
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme)
|
||||
})
|
||||
|
||||
await it('should return the programmes, that user has access to, to filteredProgrammes', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
const testMathProgramme = {
|
||||
code: 'KH50_001',
|
||||
curriculumPeriodIds: [
|
||||
'hy-lv-76',
|
||||
'hy-lv-74',
|
||||
'hy-lv-75',
|
||||
'hy-lv-72',
|
||||
'hy-lv-73',
|
||||
'hy-lv-71',
|
||||
'hy-lv-69',
|
||||
'hy-lv-68',
|
||||
'hy-lv-70',
|
||||
],
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
progId: 'MAT',
|
||||
}
|
||||
|
||||
assert.strictEqual(Object.keys(res.body.filteredProgrammes).length, 2) // Basic has access to math bachelor + master
|
||||
|
||||
assert.deepStrictEqual(res.body.filteredProgrammes.KH50_001, testMathProgramme)
|
||||
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme) // All programmes should still include filteredProgramme programmes
|
||||
})
|
||||
|
||||
await it.todo(
|
||||
'should return both degree levels of study programme when searching KH90_001 or MH90_001 (ELK, ELL) in filteredProgrammes'
|
||||
)
|
||||
})
|
||||
@@ -28,6 +28,12 @@ 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
|
||||
|
||||
# https://github.com/hadolint/hadolint/wiki/DL4006
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Safe-chain install
|
||||
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
RUN npm install -g serve@^14
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
FROM node:24-alpine
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
# https://github.com/hadolint/hadolint/wiki/DL4006
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Safe-chain install
|
||||
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
|
||||
WORKDIR /opt/app-root/frontend
|
||||
COPY . .
|
||||
COPY --chown=1001:1001 . .
|
||||
|
||||
USER 1001
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
Generated
+232
-69
@@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@mui/icons-material": "^6.4.12",
|
||||
"@mui/material": "^6.4.12",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
@@ -16,28 +16,28 @@
|
||||
"@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",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.18.1",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.2",
|
||||
"rehype-raw": "7.0.0",
|
||||
"unfuck-spa-shibboleth-session": "^5.0.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
@@ -46,6 +46,7 @@
|
||||
"../shared": {
|
||||
"name": "@oodikone/shared",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
@@ -776,10 +777,10 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/roboto": {
|
||||
"node_modules/@fontsource/ibm-plex-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.8.tgz",
|
||||
"integrity": "sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA==",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -1864,9 +1865,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1884,9 +1882,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1904,9 +1899,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1924,9 +1916,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2104,13 +2093,6 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -2120,26 +2102,18 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/highcharts": {
|
||||
"version": "5.0.44",
|
||||
"resolved": "https://registry.npmjs.org/@types/highcharts/-/highcharts-5.0.44.tgz",
|
||||
"integrity": "sha512-Rby1gj3GbK7zzsbK/g9T9hoybUfM+x0sBIXrOlTnj25TIvuTkLend3nKRLLz2upvLJNo36GntO6KmTFoVQXWZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
@@ -2202,17 +2176,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-highcharts": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-highcharts/-/react-highcharts-16.0.7.tgz",
|
||||
"integrity": "sha512-xQSSWvZjZaK+j6/OBAR016KsC/cKptGbzxE4e6uSAuXpiz65PUAtSTpvT22nkvCR2DGx+5+faYwNkn2C/si4/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/highcharts": "^5.0.44",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
@@ -2539,6 +2502,42 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-for-react": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
|
||||
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"size-sensor": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"react": "^15.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2663,6 +2662,12 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2835,6 +2840,64 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-parse5": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"vfile-location": "^5.0.0",
|
||||
"web-namespaces": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
|
||||
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-from-parse5": "^8.0.0",
|
||||
"hast-util-to-parse5": "^8.0.0",
|
||||
"html-void-elements": "^3.0.0",
|
||||
"mdast-util-to-hast": "^13.0.0",
|
||||
"parse5": "^7.0.0",
|
||||
"unist-util-position": "^5.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -2862,6 +2925,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -2875,11 +2957,22 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highcharts": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.4.8.tgz",
|
||||
"integrity": "sha512-5Tke9LuzZszC4osaFisxLIcw7xgNGz4Sy3Jc9pRMV+ydm6sYqsPYdU8ELOgpzGNrbrRNDRBtveoR5xS3SzneEA==",
|
||||
"license": "https://www.highcharts.com/license"
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight-words": {
|
||||
"version": "2.0.0",
|
||||
@@ -2916,6 +3009,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
@@ -3056,9 +3159,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
@@ -3858,6 +3961,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -3988,18 +4103,6 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-highcharts": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-highcharts/-/react-highcharts-16.1.0.tgz",
|
||||
"integrity": "sha512-CHpCSMN96lXKeTIpx8UJPsUgeNeJqh81NN6cbzraiHQBiQz2mzXa5aaIWYbMEQ2NHhAEWU5uj5DPhZY1f1Rq+A==",
|
||||
"dependencies": {
|
||||
"highcharts": "^6.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "~0.14 || ^15.0.0 || ^16.0.0",
|
||||
"react-dom": "~0.14 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
@@ -4119,6 +4222,21 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -4240,6 +4358,12 @@
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/size-sensor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4356,6 +4480,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -4493,6 +4623,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-location": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-message": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||
@@ -4582,6 +4726,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
@@ -4594,6 +4748,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@mui/icons-material": "^6.4.12",
|
||||
"@mui/material": "^6.4.12",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
@@ -19,37 +19,30 @@
|
||||
"@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",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.18.1",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.2",
|
||||
"rehype-raw": "7.0.0",
|
||||
"unfuck-spa-shibboleth-session": "^5.0.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
"highcharts": "$highcharts",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import axios, { AxiosProgressEvent } from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const baseHeaders = isDev
|
||||
@@ -83,16 +83,7 @@ export const RTKApi = createApi({
|
||||
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||
return headers
|
||||
},
|
||||
paramsSerializer: params => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(params).map(([key, val]) => {
|
||||
const subfix = Array.isArray(val) ? '[]' : ''
|
||||
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
|
||||
})
|
||||
|
||||
return searchParams.toString()
|
||||
},
|
||||
paramsSerializer: params => queryParamsToString(params),
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
+69
-53
@@ -1,3 +1,15 @@
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { default as ReactDOMServer } from 'react-dom/server'
|
||||
|
||||
import { CheckIcon, CropSquareIcon, RemoveIcon } from '@/theme'
|
||||
|
||||
const iconToSvg = (Icon, color?) =>
|
||||
ReactDOMServer.renderToStaticMarkup(
|
||||
<span style={{ display: 'inline-block', color }}>
|
||||
<Icon style={{ height: '16px', width: '16px', fill: 'currentColor' }} />
|
||||
</span>
|
||||
)
|
||||
|
||||
export const populationStatisticsToolTips = {
|
||||
advanced: {
|
||||
semesters:
|
||||
@@ -20,11 +32,11 @@ export const populationStatisticsToolTips = {
|
||||
creditAccumulation: `
|
||||
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
|
||||
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
|
||||
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics”-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Yksittäisen opiskelijan kuvaajaa klikkaamalla siirtyy **student statistics**-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”
|
||||
X-akselin skaalaa voi säätää kuvaajan alla olevaa liukusäädintä käyttäen.
|
||||
Taulukon korkeutta voi säätää viereisistä painikkeista (small, medium, large).
|
||||
`,
|
||||
creditStatistics: `
|
||||
**Credits gained**
|
||||
@@ -58,7 +70,7 @@ export const populationStatisticsToolTips = {
|
||||
**Distribution development**
|
||||
|
||||
Pylväsdiagrammi kertoo opiskelijoiden jakauman opintopistehaarukoihin kalenterivuosittain, lukuvuosittain tai lukukausittain eriteltynä.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Stack ordering** -valikosta haluamansa järjestyksen.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Chart settings** -valikosta haluamansa järjestyksen.
|
||||
|
||||
Pylväiden osat toimivat myös suodattimina. Klikkaamalla pylvään osaa näkyviin jäävät vain kyseiset opiskelijat (esimerkiksi keväällä 2024 0–30 opintopistettä suorittaneet).
|
||||
|
||||
@@ -129,73 +141,77 @@ export const populationStatisticsToolTips = {
|
||||
|
||||
Valikoituja selvennyksiä:
|
||||
- **Credits**
|
||||
- **All** listaa opiskelijan suoritetut opintopisteet valitun tarkastelujakson ajalta
|
||||
- **Since** listaa opiskelijan suoritetut opintopisteet määritellystä ajankohdasta alkaen
|
||||
- **All** opiskelijan suoritetut opintopisteet valitun tarkastelujakson ajalta
|
||||
- **Since** opiskelijan suoritetut opintopisteet määritellystä ajankohdasta alkaen
|
||||
- **Start of study right**: Opiskelijan valittuun ohjelmaan liittyvän opinto-oikeuden alkupäivä
|
||||
- **Degree programme**: Näyttää opiskelijan uusimman opinto-oikeuden. Opiskelijan kaikki opinto-oikeudet näkyvät uusimmasta vanhimpaan Excel-tiedostossa, sekä laittamalla hiiri solun päälle.
|
||||
- **Other programme**: Näkyy, jos ryhmälle on asetettu opinto-oikeus. Tämä korvaa Degree programme -sarakkeen, ja toimii muuten samoin mutta näyttää opiskelijan muista opinto-oikeuksista uusimman.
|
||||
- **Started in programme**: Opiskelijan valitussa ohjelmassa aloituspäivämäärä. Kandi+maisteriopiskelijoille kandiksi valmistumispäivämäärä +1.
|
||||
`,
|
||||
studentsClass: `
|
||||
**Students**
|
||||
studentsClass: (theme: Theme) => `
|
||||
**Students**
|
||||
|
||||
Taulukko näyttää oletusarvoisesti vain opiskelijanumerot. Valitsemalla "Show student names" saat näkyviin myös opiskelijoiden nimet.
|
||||
Taulukko näyttää oletusarvoisesti vain opiskelijanumerot. Valitsemalla "Show student names" saat näkyviin myös opiskelijoiden nimet.
|
||||
|
||||
Klikkaamalla opiskelijanumeron vieressä olevaa sinistä henkilösymbolia, siirryt kyseisen opiskelijan "Student statistics" -näkymään.
|
||||
Klikkaamalla opiskelijanumeron vieressä olevaa sinistä henkilösymbolia, siirryt kyseisen opiskelijan "Student statistics" -näkymään.
|
||||
|
||||
Klikkaamalla "Sisu"-kuvaketta, siirryt tarkastelemaan opiskelijan tietoja Sisu-järjestelmässä.
|
||||
Klikkaamalla "Sisu"-kuvaketta, siirryt tarkastelemaan opiskelijan tietoja Sisu-järjestelmässä.
|
||||
|
||||
**General**
|
||||
**General**
|
||||
|
||||
Valikoituja selvennyksiä:
|
||||
- **Credits**
|
||||
- **All** listaa opiskelijan suoritetut opintopisteet valitun tarkastelujakson ajalta
|
||||
- **HOPS** listaa opiskelijan kaikki HOPSiin sijoitut opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
|
||||
- **Since start in programme** listaa opiskelijan ohjelmassa aloittamisen jälkeen suoritut opintopisteet
|
||||
- **Transferred from**: Owpiskelijan vanha koulutusohjelma, josta opiskelija on siirtynyt uuteen.
|
||||
- **Start of study right**: Opiskelijan valittuun ohjelmaan liittyvän opinto-oikeuden alkupäivä
|
||||
- **Started in programme**: Opiskelijan ko. ohjelmassa aloituspäivämäärä. Kandi+maisteriopiskelijoille kandiksi valmistumispäivämäärä +1
|
||||
- **Semesters present**: Näyttää opiskelijan ilmoittautumiset lukukausittain. Laita hiiri solun yläpuolelle nähdäksesi tekstimuotoisen selityksen ilmoittautumisista.
|
||||
- Värikoodit:
|
||||
- Vihreä = Läsnäoleva
|
||||
- Keltainen = Ilmoittautunut poissaolevaksi
|
||||
- Punainen = Ei ilmoittautumista, mutta opiskelijalla on ollut opinto-oikeus
|
||||
- Harmaa = Opiskelijalla ei ollut tällöin opinto-oikeutta
|
||||
- Kruunu kertoo opiskelijan valmistuneen kyseisenä lukukautena. Jos näkymässä on yhdistettynä alempi ja ylempi tutkinto, ylemmän valmistumisen kruunussa on timantti.
|
||||
- Excel-versiossa sarakkeessa näkyy tieto opiskelijan ensimmäisestä ilmoittautumisesta, jonka jälkeen ilmoittautumiset esitettynä symbolein:
|
||||
- \\+ = Ilmoittautunut läsnäolevaksi
|
||||
- o = Ilmoittautunut poissaolevaksi
|
||||
- _ = Ei ilmoittautumista
|
||||
Valikoituja selvennyksiä:
|
||||
- **Credits**
|
||||
- **All**: opiskelijan suoritetut opintopisteet valitun tarkastelujakson ajalta
|
||||
- **HOPS**: opiskelijan kaikki HOPSiin sijoitut opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
|
||||
- **Since start in programme**: opiskelijan ohjelmassa aloittamisen jälkeen suoritut opintopisteet
|
||||
- **Transferred from**: Opiskelijan vanha koulutusohjelma, josta opiskelija on siirtynyt uuteen.
|
||||
- **Start of study right**: Opiskelijan valittuun ohjelmaan liittyvän opinto-oikeuden alkupäivä
|
||||
- **Started in programme**: Opiskelijan ko. ohjelmassa aloituspäivämäärä. Kandi+maisteriopiskelijoille kandiksi valmistumispäivämäärä +1
|
||||
- **Semesters present**: Näyttää opiskelijan ilmoittautumiset lukukausittain. Laita hiiri solun yläpuolelle nähdäksesi tekstimuotoisen selityksen ilmoittautumisista.
|
||||
- Värikoodit:
|
||||
- Vihreä = Läsnäoleva
|
||||
- Keltainen = Ilmoittautunut poissaolevaksi
|
||||
- Punainen = Ei ilmoittautumista, mutta opiskelijalla on ollut opinto-oikeus
|
||||
- Harmaa = Opiskelijalla ei ollut tällöin opinto-oikeutta
|
||||
- Kruunu kertoo opiskelijan valmistuneen kyseisenä lukukautena. Jos näkymässä on yhdistettynä alempi ja ylempi tutkinto, ylemmän valmistumisen kruunussa on timantti.
|
||||
- Excel-versiossa sarakkeessa näkyy tieto opiskelijan ensimmäisestä ilmoittautumisesta, jonka jälkeen ilmoittautumiset esitettynä symbolein:
|
||||
<ul class="no-bullet-list">
|
||||
<li>+ = Ilmoittautunut läsnäolevaksi</li>
|
||||
<li>o = Ilmoittautunut poissaolevaksi</li>
|
||||
<li>_ = Ei ilmoittautumista</li>
|
||||
</ul>
|
||||
|
||||
**Courses**
|
||||
**Courses**
|
||||
|
||||
Tällä välilehdellä näkyy, mitä opetussuunnitelmaan kuuluvia kursseja opiskelija on suorittanut. Valitsemalla "Include substitutions" otetaan mukaan myös arvosanat vastaavista kursseista.
|
||||
Tällä välilehdellä näkyy, mitä opetussuunnitelmaan kuuluvia kursseja opiskelija on suorittanut. Valitsemalla "Include substitutions" otetaan mukaan myös arvosanat vastaavista kursseista.
|
||||
|
||||
Merkkien selitykset:
|
||||
- ✓ Vihreä tarkastusmerkki = Opiskelija on suorittanut kurssin hyväksytysti
|
||||
- ✓ Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla
|
||||
- — Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille
|
||||
- — Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille
|
||||
- □ Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä
|
||||
Merkkien selitykset:
|
||||
<ul class="no-bullet-list">
|
||||
<li>${iconToSvg(CheckIcon, theme.palette.ooditable.success)} Vihreä tarkastusmerkki = Opiskelija on suorittanut kurssin hyväksytysti</li>
|
||||
<li>${iconToSvg(CheckIcon, theme.palette.ooditable.enrollment)} Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla</li>
|
||||
<li>${iconToSvg(RemoveIcon, theme.palette.ooditable.recentEnrollment)} Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille</li>
|
||||
<li>${iconToSvg(RemoveIcon, theme.palette.ooditable.enrollment)} Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille</li>
|
||||
<li>${iconToSvg(CropSquareIcon, theme.palette.ooditable.hops)} Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä</li>
|
||||
</ul>
|
||||
|
||||
**Modules**
|
||||
**Modules**
|
||||
|
||||
Tällä välilehdellä näkyy, mitä opintokokonaisuuksia opiskelija on valinnut opintosuunnitelmaansa. Näkymä ei huomioi valinnaisia opintokokonaisuuksia.
|
||||
Tällä välilehdellä näkyy, mitä opintokokonaisuuksia opiskelija on valinnut opintosuunnitelmaansa. Näkymä ei huomioi valinnaisia opintokokonaisuuksia.
|
||||
|
||||
**Tags**
|
||||
**Tags**
|
||||
|
||||
Opiskelijoille voi luoda tageja ryhmittelyä varten kohdassa "Degree Program" > "Overview" > haluttu koulutusohjelma.
|
||||
Opiskelijoille voi luoda tageja ryhmittelyä varten kohdassa "Degree Program" > "Overview" > haluttu koulutusohjelma.
|
||||
|
||||
**Progress**
|
||||
**Progress**
|
||||
|
||||
Tämä näkymä on tällä hetkellä käytössä vain kandivaiheen opinto-ohjelmissa.
|
||||
Tällä välilehdellä näkyy opintojen kehitys annettujen kriteerien mukaan akateemista vuotta kohti. Kurssi merkataan tehdyksi, jos sillä on suoritusmerkintä tai hyväksiluku
|
||||
minä tahansa lukuvuonna. Opintopistekriteerin täyttämiseen vaaditaan, että opintopisteet on suoritettu kyseisen akateemisen vuoden aikana.
|
||||
Tämä näkymä on tällä hetkellä käytössä vain kandivaiheen opinto-ohjelmissa.
|
||||
Tällä välilehdellä näkyy opintojen kehitys annettujen kriteerien mukaan akateemista vuotta kohti. Kurssi merkataan tehdyksi, jos sillä on suoritusmerkintä tai hyväksiluku
|
||||
minä tahansa lukuvuonna. Opintopistekriteerin täyttämiseen vaaditaan, että opintopisteet on suoritettu kyseisen akateemisen vuoden aikana.
|
||||
|
||||
**Huomaa**, että klikkaamalla taulukon yläreunassa olevaa kolme pistettä sisältävää kuvaketta, voit ladata itsellesi Excel tiedoston.
|
||||
Tiedosto sisältää lisäksi muita sarakkeita, kuten läsnäolon lukukausittain sekä yhteystiedot.
|
||||
**Huomaa**, että klikkaamalla taulukon yläreunassa olevaa kolme pistettä sisältävää kuvaketta, voit ladata itsellesi Excel tiedoston.
|
||||
Tiedosto sisältää lisäksi muita sarakkeita, kuten läsnäolon lukukausittain sekä yhteystiedot.
|
||||
|
||||
**Yhteystietojen käyttö on kuitenkin sallittua vain opintoneuvontaan liittyvissä asiossa.**
|
||||
**Yhteystietojen käyttö on kuitenkin sallittua vain opintoneuvontaan liittyvissä asiossa.**
|
||||
`,
|
||||
studentsCustom: `
|
||||
**Students**
|
||||
@@ -208,11 +224,11 @@ export const populationStatisticsToolTips = {
|
||||
|
||||
Klikkaamalla "Sisu"-nuolta, siirryt tarkastelemaan opiskelijan tietoja Sisu-järjestelmässä
|
||||
|
||||
**All credits** listaa opiskelijan kaikki opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
|
||||
**All credits** opiskelijan kaikki opintopisteet (myös aiemmat suoritukset, eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
|
||||
|
||||
**Credits in HOPS** listaa tarkasteltavaan opinto-ohjelmaan liitettyyn opintosuunnitelmaan kuuluvat opintopisteet
|
||||
**Credits in HOPS** tarkasteltavaan opinto-ohjelmaan liitettyyn opintosuunnitelmaan kuuluvat opintopisteet
|
||||
|
||||
**Credits since start** listaa kuinka paljon opintopisteitä on kertynyt valitussa opinto-ohjelmassa aloittamisen jälkeen
|
||||
**Credits since start** kuinka paljon opintopisteitä on kertynyt valitussa opinto-ohjelmassa aloittamisen jälkeen
|
||||
`,
|
||||
programmeDistributionCoursePopulation: {
|
||||
fi: `**Koulutusohjelma määritetään seuraavasti**:
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Theme, useTheme } from '@mui/material/styles'
|
||||
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 { maxBy, orderBy } from 'lodash-es'
|
||||
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import { GetTextIn } from '@/components/LanguagePicker/useLanguage'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import type { Absence } from '@/types/students'
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
EnrollmentType,
|
||||
} from '@oodikone/shared/types'
|
||||
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
|
||||
import { range } from '@oodikone/shared/util'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
dayjsExtend(isSameOrAfter)
|
||||
@@ -87,23 +90,25 @@ export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent,
|
||||
return mean
|
||||
}
|
||||
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' } as const
|
||||
const studyModuleTexts = { en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' } as const
|
||||
|
||||
export const getTextInWithOpen = (
|
||||
course: { code: string; name: Name },
|
||||
getTextIn: any,
|
||||
getTextIn: GetTextIn,
|
||||
isOpenCourse: boolean,
|
||||
isStudyModuleCredit: boolean
|
||||
) => {
|
||||
if (!course) return ''
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
|
||||
let courseName = getTextIn(course.name)
|
||||
if (
|
||||
isOpenCourse &&
|
||||
!Object.values(openUniTexts).some(text => courseName.toLowerCase().startsWith(text.toLowerCase()))
|
||||
!Object.values(openUniTexts).some(text => courseName?.toLowerCase().startsWith(text.toLowerCase()))
|
||||
) {
|
||||
courseName = `${getTextIn(openUniTexts)}: ${courseName}`
|
||||
}
|
||||
if (isStudyModuleCredit) {
|
||||
courseName += ` [${getTextIn({ en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' })}]`
|
||||
courseName += ` [${getTextIn(studyModuleTexts)}]`
|
||||
}
|
||||
courseName += ` (${course.code})`
|
||||
return courseName
|
||||
@@ -112,11 +117,11 @@ export const getTextInWithOpen = (
|
||||
export const getUnifyTextIn = (unifyCourses: string) => {
|
||||
switch (unifyCourses) {
|
||||
case 'regularStats':
|
||||
return '(normal)'
|
||||
return 'Normal'
|
||||
case 'openStats':
|
||||
return '(open)'
|
||||
return 'Open'
|
||||
case 'unifyStats':
|
||||
return '(open and normal)'
|
||||
return 'Open and normal'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -127,10 +132,10 @@ export const getStudentToTargetCourseDateMap = (
|
||||
students: Pick<FormattedStudent, 'studentNumber' | 'courses'>[],
|
||||
codes: string[]
|
||||
) => {
|
||||
const codeSet = new Set(codes)
|
||||
const uniqueCourses = new Set(codes)
|
||||
return students.reduce((acc, student) => {
|
||||
const targetCourse = student.courses
|
||||
.filter(course => codeSet.has(course.course_code))
|
||||
.filter(course => uniqueCourses.has(course.course_code))
|
||||
.sort((a, b) => +new Date(b.date) - +new Date(a.date))[0]
|
||||
acc[student.studentNumber] = targetCourse ? targetCourse.date : null
|
||||
return acc
|
||||
@@ -436,7 +441,10 @@ export const getEnrollmentTypeTextForExcel = (type: number, statutoryAbsence?: b
|
||||
return 'No study right'
|
||||
}
|
||||
|
||||
export const formatContent = (content: string) => content.replace(/\n +/g, '\n')
|
||||
export const useFormatContent = (content: string | ((theme: Theme) => string)) => {
|
||||
const theme = useTheme()
|
||||
return (content instanceof Function ? content(theme) : content).replace(/\s+$/g, '\n')
|
||||
}
|
||||
|
||||
export const filterInternalReleases = (release: Release) => !release.title.startsWith('Internal:')
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/L
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import updateLocale from 'dayjs/plugin/updateLocale'
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
@@ -22,21 +21,6 @@ import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
// TODO: What other options should be set globally?
|
||||
HighCharts.setOptions({
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
})
|
||||
|
||||
dayjsExtend(updateLocale)
|
||||
dayjs.updateLocale('en', {
|
||||
week: {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
export const BarChart = ({ id, graphStats, years }: { id: string; graphStats: GraphStat[]; years: number[] }) => {
|
||||
if (!graphStats || !id || !years) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphSeries, index) => ({
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 8,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_graduations_and_thesis_of_degree_programme_${id}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress="graduated-and-thesis-writers-of-the-programme-bar-chart">
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450, width: '100%' }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import ReactECharts, { EChartsInstance } from 'echarts-for-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
|
||||
import { InfoBox } from '../InfoBox/InfoBoxWithTooltip'
|
||||
import { getGraduationDataPoints, getGraduationsByCodes, getIncludedCourseCodesByProgrammeCodes } from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 600
|
||||
|
||||
/**
|
||||
* Credit accumulation graph for **class statistics**
|
||||
*
|
||||
* @param students filteredStudents (size must be more than 0)
|
||||
* @param programmeCodes array of 1 or 2 (for combined programme) codes
|
||||
* @param studyPlanFilter whether hops filter is active
|
||||
* @param showBachelorAndMaster (optional)
|
||||
*/
|
||||
export const CreditAccumulationGraph = ({
|
||||
students,
|
||||
programmeCodes,
|
||||
studyPlanFilter,
|
||||
showBachelorAndMaster,
|
||||
}: {
|
||||
students: FormattedStudent[]
|
||||
programmeCodes: string[]
|
||||
studyPlanFilter: boolean
|
||||
showBachelorAndMaster?: boolean
|
||||
}) => {
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const [cutStudyplanCredits, setCutStudyplanCredits] = useState(false)
|
||||
|
||||
// Is same for all students in a population
|
||||
const populationStudyStart = new Date(students[0].studyrightStart).getTime()
|
||||
const creditDateThreshold = useMemo(
|
||||
() =>
|
||||
studyPlanFilter && !cutStudyplanCredits
|
||||
? Math.min(...students.flatMap(s => s.courses.map(c => new Date(c.date).getTime())))
|
||||
: populationStudyStart,
|
||||
[studyPlanFilter, cutStudyplanCredits, students, populationStudyStart]
|
||||
)
|
||||
|
||||
const studyRightStartMarker = studyPlanFilter
|
||||
? [
|
||||
{
|
||||
name: 'Population study start',
|
||||
type: 'line',
|
||||
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#ff0000',
|
||||
type: 'dashed',
|
||||
width: 3,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: 'Population study start',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
data: [{ xAxis: populationStudyStart }],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const creditPoints = useMemo(
|
||||
() =>
|
||||
students.map(student => {
|
||||
const includedCourseCodes = getIncludedCourseCodesByProgrammeCodes(student, programmeCodes)
|
||||
|
||||
const courses = student.courses
|
||||
.filter(c => {
|
||||
const courseCode = c.course_code ?? (c as { course?: { code?: string } }).course?.code
|
||||
|
||||
return (
|
||||
dayjs(c.date).isSameOrAfter(creditDateThreshold) &&
|
||||
!c.isStudyModuleCredit &&
|
||||
(studyPlanFilter ? !!courseCode && includedCourseCodes.has(courseCode) : true)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: number[][] = []
|
||||
const markPointsForStudent: any[] = []
|
||||
const graduations = getGraduationsByCodes(student, programmeCodes, !!showBachelorAndMaster)
|
||||
|
||||
for (const c of courses) {
|
||||
if (c.credittypecode === CreditTypeCode.PASSED || c.credittypecode === CreditTypeCode.APPROVED) {
|
||||
credits += c.credits
|
||||
points.push([new Date(c.date).getTime(), credits])
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: students can graduate without having a single course in hops
|
||||
if (points.length) {
|
||||
graduations.forEach(grad => getGraduationDataPoints(points, markPointsForStudent, grad))
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
markPoint: markPointsForStudent.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: markPointsForStudent,
|
||||
}
|
||||
: undefined,
|
||||
symbolSize: 8,
|
||||
symbol: 'emptyCircle',
|
||||
data: points,
|
||||
smooth: 0.1,
|
||||
emphasis: { focus: 'series' },
|
||||
sampling: 'lttb',
|
||||
}
|
||||
}),
|
||||
[creditDateThreshold, students, studyPlanFilter, programmeCodes, showBachelorAndMaster]
|
||||
)
|
||||
|
||||
const [chartInstance, setChartInstance] = useState<EChartsInstance | null>(null)
|
||||
const lastHighlightedRef = useRef<{ seriesIndex: number; dataIndex: number } | null>(null)
|
||||
|
||||
const handleChartReady = useCallback((chart: EChartsInstance) => {
|
||||
setChartInstance(chart)
|
||||
}, [])
|
||||
|
||||
const resetHighlight = useCallback((chart: any) => {
|
||||
const last = lastHighlightedRef.current
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
lastHighlightedRef.current = null
|
||||
}
|
||||
chart.dispatchAction({ type: 'hideTip' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartInstance
|
||||
if (!chart) return () => undefined
|
||||
|
||||
const zr = chart.getZr()
|
||||
|
||||
const handleMouseMove = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) {
|
||||
resetHighlight(chart)
|
||||
return
|
||||
}
|
||||
|
||||
let nearestSeriesIndex: number | null = null
|
||||
let nearestDataIndex: number | null = null
|
||||
let nearestDist2 = Number.POSITIVE_INFINITY
|
||||
|
||||
creditPoints.forEach((series, seriesIndex) => {
|
||||
if (!Array.isArray(series.data) || series.data.length === 0) return
|
||||
|
||||
series.data.forEach((point, dataIndex) => {
|
||||
if (!Array.isArray(point) || point.length < 2) return
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, point)
|
||||
if (!Array.isArray(pixel)) return
|
||||
|
||||
const dx = pixel[0] - offsetX
|
||||
const dy = pixel[1] - offsetY
|
||||
const dist2 = dx * dx + dy * dy
|
||||
|
||||
if (dist2 < nearestDist2) {
|
||||
nearestSeriesIndex = seriesIndex
|
||||
nearestDataIndex = dataIndex
|
||||
nearestDist2 = dist2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (nearestSeriesIndex == null || nearestDataIndex == null) return
|
||||
|
||||
const next = { seriesIndex: nearestSeriesIndex, dataIndex: nearestDataIndex }
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (last && last.seriesIndex === next.seriesIndex && last.dataIndex === next.dataIndex) return
|
||||
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
}
|
||||
|
||||
chart.dispatchAction({ type: 'highlight', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
chart.dispatchAction({ type: 'showTip', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
lastHighlightedRef.current = next
|
||||
}
|
||||
|
||||
const handleGlobalOut = () => resetHighlight(chart)
|
||||
const handleClick = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) return
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (!last) return
|
||||
|
||||
const studentNumber = creditPoints[last.seriesIndex]?.name
|
||||
if (!studentNumber) return
|
||||
|
||||
window.open(`/students/${studentNumber}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
zr.on('mousemove', handleMouseMove)
|
||||
zr.on('globalout', handleGlobalOut)
|
||||
zr.on('click', handleClick)
|
||||
|
||||
return () => {
|
||||
zr.off('mousemove', handleMouseMove)
|
||||
zr.off('globalout', handleGlobalOut)
|
||||
zr.off('click', handleClick)
|
||||
}
|
||||
}, [chartInstance, creditPoints, resetHighlight])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'none',
|
||||
position: (_point: unknown, params: { value?: [number, number] }) => {
|
||||
const value = params?.value
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const chart = chartInstance
|
||||
if (!chart) return null
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, value)
|
||||
if (!Array.isArray(pixel)) return null
|
||||
|
||||
return [pixel[0], pixel[1]]
|
||||
},
|
||||
formatter: (params: { seriesName?: string; value?: [number, number] }) => {
|
||||
const { value } = params
|
||||
if (!Array.isArray(value)) return params.seriesName ?? ''
|
||||
|
||||
const [timestamp, credits] = value
|
||||
const dateLabel = reformatDate(new Date(timestamp), DateFormat.DISPLAY_DATE)
|
||||
return `<b>${params.seriesName ?? ''}</b><br/>${dateLabel}: ${credits} cr`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0 },
|
||||
{ type: 'slider', xAxisIndex: 0 },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
splitLine: { show: true },
|
||||
scale: true,
|
||||
},
|
||||
series: [...creditPoints, ...studyRightStartMarker],
|
||||
animation: true,
|
||||
}),
|
||||
[chartInstance, creditPoints, studyRightStartMarker]
|
||||
)
|
||||
|
||||
const GraphSizeButton = ({ height, label }) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={height === graphHeight ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
mx: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<GraphSizeButton height={400} label="Small" />
|
||||
<GraphSizeButton height={INITIAL_GRAPH_HEIGHT} label="Medium" />
|
||||
<GraphSizeButton height={900} label="Large" />
|
||||
{studyPlanFilter ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={cutStudyplanCredits} onChange={e => setCutStudyplanCredits(e.target.checked)} />
|
||||
}
|
||||
label="Display credits from study right start"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
|
||||
</Stack>
|
||||
|
||||
<ReactECharts onChartReady={handleChartReady} option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
type GraduationBreakdownProps =
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
fullWidth: true
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
|
||||
yearLabel?: never
|
||||
handleClick?: never
|
||||
names?: never
|
||||
expandKey?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
names?: never
|
||||
expandKey?: never
|
||||
fullWidth?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
|
||||
expandKey: string | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
fullWidth?: never
|
||||
}
|
||||
|
||||
export const GraduationBreakdown = ({
|
||||
cypress,
|
||||
data,
|
||||
fullWidth,
|
||||
handleClick,
|
||||
mode,
|
||||
names,
|
||||
expandKey,
|
||||
yearLabel,
|
||||
}: GraduationBreakdownProps) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
const categories = data.map(item => item.name)
|
||||
const codeMap: Record<string, string | undefined> = {}
|
||||
|
||||
if (expandKey) {
|
||||
for (const item of data) {
|
||||
codeMap[item.name || item.code!] = item.code
|
||||
}
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
if (data.length === 1) {
|
||||
return 250
|
||||
}
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
const name = names[code]
|
||||
if (typeof name === 'string') {
|
||||
return name
|
||||
}
|
||||
return name?.[language] ?? name?.fi ?? ''
|
||||
}
|
||||
|
||||
const getTooltipText = (id: string, seriesName: string, amount: number) => {
|
||||
if (expandKey) {
|
||||
const code = codeMap[id] ?? ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
return `<b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
|
||||
const barWidth = data.length > 8 ? 16 : 20
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: 'On time',
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
data: data.map(item => item.statistics.onTime),
|
||||
},
|
||||
{
|
||||
name: 'Max. year overtime',
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
data: data.map(item => item.statistics.yearOver),
|
||||
},
|
||||
{
|
||||
name: 'Overtime',
|
||||
color: theme.palette.graduationTimes.wayOver,
|
||||
data: data.map(item => item.statistics.wayOver),
|
||||
},
|
||||
].map(seriesItem => ({
|
||||
type: 'bar',
|
||||
name: seriesItem.name,
|
||||
barWidth,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
color: seriesItem.color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#424949',
|
||||
formatter: (params: { value?: number }) => {
|
||||
const value = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return value ? `${value}` : ''
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel?.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
formatter: (params: { name?: string; seriesName?: string; value?: number }) => {
|
||||
const id = params.name ?? ''
|
||||
const seriesName = params.seriesName ?? ''
|
||||
const amount = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(id, seriesName, amount)
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
title: 'View table data',
|
||||
},
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-breakdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 20,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
name: 'Number of students',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts
|
||||
onEvents={getOnEvents(handleClick)}
|
||||
option={option}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: getHeight() }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
export const LineGraph = ({
|
||||
cypress,
|
||||
exportFileName,
|
||||
graphStats,
|
||||
years,
|
||||
}: {
|
||||
cypress: string
|
||||
exportFileName: string
|
||||
graphStats: GraphStat[]
|
||||
years: number[]
|
||||
}) => {
|
||||
if (!graphStats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphStat, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphStat,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
lineStyle: {
|
||||
color,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-line-graph`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStatistics,
|
||||
GraduationStats,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeClassSizes,
|
||||
} from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
type GraduationPoint = GraduationStats & { color?: string; realGoal?: number }
|
||||
|
||||
export const MedianGraduations = ({
|
||||
classSizes,
|
||||
cypress,
|
||||
data,
|
||||
expandKey,
|
||||
fullWidth,
|
||||
goal,
|
||||
goalExceptions,
|
||||
handleClick,
|
||||
level,
|
||||
mode,
|
||||
names,
|
||||
title,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes?: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks' | 'programme']
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
expandKey?: string
|
||||
facultyGraph?: boolean
|
||||
fullWidth?: true
|
||||
goal: number
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
handleClick: (seriesCategory: string) => void | undefined
|
||||
level?: string
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode>
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
let modData: GraduationPoint[] | null = null
|
||||
if (!expandKey && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
|
||||
// change colors for longer medicine goal times
|
||||
modData = JSON.parse(JSON.stringify(data))
|
||||
if (modData) {
|
||||
for (const item of modData) {
|
||||
if (item.code && Object.keys(goalExceptions).includes(item.code)) {
|
||||
const realGoal = goal + goalExceptions[item.code]
|
||||
if (item.median <= realGoal) {
|
||||
item.color = theme.palette.graduationTimes.onTime
|
||||
} else if (item.median <= realGoal + 12) {
|
||||
item.color = theme.palette.graduationTimes.yearOver
|
||||
} else {
|
||||
item.color = theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
item.realGoal = realGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max, { median }) => {
|
||||
return median > max ? median : max
|
||||
}, goal * 2)
|
||||
|
||||
const getClassSize = (category: string) => {
|
||||
return expandKey ? classSizes?.[category]?.[expandKey ?? ''] : classSizes?.[category]
|
||||
}
|
||||
|
||||
const getPercentage = (amount: number, category: string) => {
|
||||
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, category: string) => {
|
||||
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
return names[code]?.[language] ?? names[code]?.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
name: string,
|
||||
code: string,
|
||||
amount: number,
|
||||
median: number,
|
||||
statistics: GraduationStatistics | undefined,
|
||||
realGoal: number | undefined
|
||||
) => {
|
||||
const sortingText =
|
||||
yearLabel === 'Start year'
|
||||
? `<b class="grad-vals">From class of ${expandKey ?? name}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b class="grad-vals">${amount} students graduated in year ${expandKey ?? name}</b>`
|
||||
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 (expandKey) {
|
||||
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}`
|
||||
}
|
||||
|
||||
const resolveBarColor = (median: number, override?: string) => {
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
if (median <= goal) {
|
||||
return theme.palette.graduationTimes.onTime
|
||||
}
|
||||
if (median <= goal + 2) {
|
||||
return theme.palette.graduationTimes.yearOver
|
||||
}
|
||||
return theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
|
||||
const dataWithOverrides: GraduationPoint[] = modData ?? data
|
||||
const seriesData = dataWithOverrides.map(item => ({
|
||||
value: item.median,
|
||||
name: item.name.toString(),
|
||||
code: item.code ?? item.name,
|
||||
amount: item.amount,
|
||||
statistics: item.statistics,
|
||||
realGoal: item.realGoal,
|
||||
itemStyle: {
|
||||
color: resolveBarColor(item.median, item.color),
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
confine: true,
|
||||
formatter: (params: {
|
||||
name?: string
|
||||
value?: number
|
||||
data?: {
|
||||
code?: string
|
||||
amount?: number
|
||||
statistics?: GraduationStatistics
|
||||
realGoal?: number
|
||||
name?: string
|
||||
}
|
||||
}) => {
|
||||
const chartData = params.data
|
||||
if (!chartData) {
|
||||
return ''
|
||||
}
|
||||
const name = params.name ?? chartData.name ?? ''
|
||||
const code = chartData.code ?? name
|
||||
const amount = chartData.amount ?? 0
|
||||
const median = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(name, code, amount, median, chartData.statistics, chartData.realGoal)
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 15,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-medians',
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
name: 'Graduation time (semesters)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
minInterval: 1,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: seriesData.map(item => item.name),
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: seriesData,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideLeft',
|
||||
color: '#424949',
|
||||
formatter: (params: { name?: string; data?: { amount?: number; code?: string } }) => {
|
||||
const amount = params.data?.amount ?? 0
|
||||
const code = params.data?.code ?? params.name ?? ''
|
||||
return getDataLabel(amount, code)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
xAxis: goal,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
{
|
||||
xAxis: goal + 2,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts
|
||||
onEvents={getOnEvents(handleClick)}
|
||||
option={option}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: getHeight() }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
type PercentileProps = {
|
||||
data: Record<string, [string, number][]>
|
||||
title?: string
|
||||
goalLines?: {
|
||||
dates?: (Date | string)[]
|
||||
credits?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
export const PercentileGraph = (props: PercentileProps) => {
|
||||
const classSize = props.data[Object.keys(props.data).at(0) ?? ''].length
|
||||
|
||||
const series = Object.entries(props.data).map(([percentile, values]) => ({
|
||||
name: `${percentile}th`,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
smooth: 0.2,
|
||||
data: values,
|
||||
}))
|
||||
|
||||
/** Credit and graduation goals */
|
||||
const markLines = [
|
||||
{
|
||||
type: 'line',
|
||||
name: '',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#888888',
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
data: props.goalLines?.credits?.map(goal => ({ yAxis: goal })) ?? [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
name: '',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#ff0000',
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
data: props.goalLines?.dates?.map(goal => ({ xAxis: goal })) ?? [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const option = {
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
boundaryGap: false,
|
||||
},
|
||||
title: {
|
||||
text: props.title,
|
||||
subtext: `Class size: ${classSize}`,
|
||||
subtextStyle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
left: 'center',
|
||||
top: 'top',
|
||||
},
|
||||
graphic: {
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
bottom: 25,
|
||||
style: {
|
||||
fill: '#666',
|
||||
text: 'Percentile',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
top: 80,
|
||||
left: 60,
|
||||
right: 60,
|
||||
bottom: 70,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
series: [...markLines, ...series],
|
||||
}
|
||||
|
||||
return <ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 550, width: '100%' }} />
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const formatPercentage = (value: number) => `${value.toFixed(1)}%`
|
||||
|
||||
type ProgressData = {
|
||||
id: string
|
||||
stats: {
|
||||
data: number[]
|
||||
name: string
|
||||
}[]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
export const ProgressBarChart = ({ cypress, data }: { cypress: string; data: ProgressData }) => {
|
||||
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
|
||||
const stats = data.stats ?? []
|
||||
const seriesNamesKey = stats.map(stat => stat.name).join('|')
|
||||
|
||||
useEffect(() => {
|
||||
setLegendSelection({})
|
||||
}, [data.id, seriesNamesKey])
|
||||
|
||||
const seriesCount = stats.length
|
||||
const gradientColors = seriesCount > 1 ? generateGradientColors(seriesCount - 1) : []
|
||||
const colors = [...gradientColors, '#ddd']
|
||||
|
||||
const pointCount = Math.max(data.years?.length ?? 0, ...stats.map(series => series.data.length))
|
||||
const categories = Array.from({ length: pointCount }, (_, index) => data.years?.[index] ?? '')
|
||||
|
||||
const isSeriesVisible = (name: string) => legendSelection[name] !== false
|
||||
|
||||
const totals = Array.from({ length: pointCount }, (_, index) =>
|
||||
stats.reduce((sum, currentSeries) => {
|
||||
if (!isSeriesVisible(currentSeries.name)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
return sum + getNumericValue(currentSeries.data[index])
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const series = stats.map((graphSeries, index) => ({
|
||||
name: graphSeries.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color: colors[index],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params: { data?: { percentage?: number } }) => {
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return formatPercentage(percentage)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
},
|
||||
data: Array.from({ length: pointCount }, (_, pointIndex) => {
|
||||
const rawValue = getNumericValue(graphSeries.data[pointIndex])
|
||||
const total = totals[pointIndex]
|
||||
const percentage = isSeriesVisible(graphSeries.name) && total ? (rawValue / total) * 100 : 0
|
||||
|
||||
return {
|
||||
value: percentage,
|
||||
rawValue,
|
||||
percentage,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
|
||||
if (!params.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setLegendSelection(params.selected)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const option = {
|
||||
legend: {
|
||||
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { data?: { rawValue?: number; percentage?: number }; seriesName?: string }) => {
|
||||
const rawValue = getNumericValue(params.data?.rawValue)
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${rawValue}</b><br/>${formatPercentage(percentage)}`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
top: -10,
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_progress_of_students_in_${data.id}_by_starting_year`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 48,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
z: 10,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-progress-bar-chart`}>
|
||||
<ReactECharts notMerge onEvents={onEvents} option={option} opts={{ renderer: 'svg' }} style={{ height: 500 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
export const StackedBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
labels,
|
||||
exportFileName = 'chart',
|
||||
}: {
|
||||
cypress: string
|
||||
data?: { data: number[]; name: string }[]
|
||||
labels?: string[]
|
||||
exportFileName: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = data.map((graphSeries, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number }) => {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const value = getNumericValue(params.value)
|
||||
|
||||
const total = data.reduce((sum, currentSeries) => sum + getNumericValue(currentSeries.data[dataIndex]), 0)
|
||||
const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${percentage} %</b>`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-stacked-bar-chart`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import type { Absence } from '@/types/students'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import type { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
|
||||
import {
|
||||
createGoalLineData,
|
||||
filterCoursesByStudyPlan,
|
||||
getGraduationDataPoints,
|
||||
getStudentTransferMarkers,
|
||||
} from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 700
|
||||
|
||||
type StudentCreditAccumulationProps = {
|
||||
student: StudentPageStudent
|
||||
absences: Absence[]
|
||||
startDate: Date
|
||||
endDate: number
|
||||
studyRightId?: string
|
||||
selectedStudyPlan?: {
|
||||
programme_code: string
|
||||
included_courses: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const GraphSizeButton = ({
|
||||
graphHeight,
|
||||
height,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
graphHeight: number
|
||||
height: number
|
||||
label: string
|
||||
onSelect: (height: number) => void
|
||||
}) => (
|
||||
<Button color="primary" onClick={() => onSelect(height)} variant={height === graphHeight ? 'contained' : 'outlined'}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const StudentCreditAccumulation = ({
|
||||
student,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
selectedStudyPlan,
|
||||
}: StudentCreditAccumulationProps) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const hasStudyRights = student.studyRights.length > 0
|
||||
|
||||
const selectedStudyRight = useMemo(
|
||||
() => (studyRightId ? student.studyRights.find(studyRight => studyRight.id === studyRightId) : undefined),
|
||||
[student.studyRights, studyRightId]
|
||||
)
|
||||
|
||||
const earliestStudyRightStart = useMemo(() => {
|
||||
const scope = selectedStudyRight ? [selectedStudyRight] : student.studyRights
|
||||
const times = scope
|
||||
.map(studyRight => new Date(studyRight.startDate).getTime())
|
||||
.filter(time => Number.isFinite(time))
|
||||
|
||||
return times.length ? Math.min(...times) : undefined
|
||||
}, [selectedStudyRight, student.studyRights])
|
||||
|
||||
const pointData = useMemo(() => {
|
||||
const filteredCourses = filterCoursesByStudyPlan(student.courses, selectedStudyPlan)
|
||||
.filter(course => new Date(course.date).getTime() <= Date.now())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: Array<{ value: [number, number]; tooltipHtml: string }> = []
|
||||
const graduationMarkPoints: Array<{ coord: number[]; name: string }> = []
|
||||
|
||||
for (const course of filteredCourses) {
|
||||
if (![CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode)) continue
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
credits += gainedCredits
|
||||
const x = new Date(course.date).getTime()
|
||||
const y = credits
|
||||
const courseCode = course.course?.code
|
||||
const courseName = course.course?.name ? getTextIn(course.course.name) : ''
|
||||
const tooltipHtml = [
|
||||
`<strong>${courseName}${courseCode ? ` (${courseCode})` : ''}</strong>`,
|
||||
`Credits: ${course.credits}`,
|
||||
`Grade: ${course.grade || '-'}`,
|
||||
`Date: ${reformatDate(course.date, DateFormat.DISPLAY_DATE)}`,
|
||||
course.isStudyModuleCredit ? '[Study Module]' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>')
|
||||
|
||||
points.push({
|
||||
value: [x, y],
|
||||
tooltipHtml,
|
||||
})
|
||||
}
|
||||
|
||||
const graduationData = (selectedStudyRight ? [selectedStudyRight] : student.studyRights)
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => !!element.graduated)
|
||||
.map(element => ({
|
||||
date: new Date(element.endDate).getTime(),
|
||||
label: `Graduated from ${getTextIn(element.name) ?? ''}`.trim(),
|
||||
}))
|
||||
.sort((a, b) => a.date - b.date)
|
||||
|
||||
if (points.length) {
|
||||
const numericalPoints = points.map(point => [point.value[0], point.value[1]])
|
||||
graduationData.forEach(graduation =>
|
||||
getGraduationDataPoints(numericalPoints, graduationMarkPoints, graduation.date)
|
||||
)
|
||||
|
||||
const pointLookup = new Map(points.map(point => [`${point.value[0]}-${point.value[1]}`, point]))
|
||||
const pointsWithGraduationNodes = numericalPoints.map(value => {
|
||||
const key = `${value[0]}-${value[1]}`
|
||||
return pointLookup.get(key) ?? { value: [value[0], value[1]] as [number, number], tooltipHtml: '' }
|
||||
})
|
||||
|
||||
return { points: pointsWithGraduationNodes, graduationMarkPoints, graduationData }
|
||||
}
|
||||
|
||||
return { points, graduationMarkPoints, graduationData }
|
||||
}, [student, selectedStudyPlan, selectedStudyRight, getTextIn])
|
||||
|
||||
const goalSeries = useMemo(() => {
|
||||
const studyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === selectedStudyPlan?.programme_code
|
||||
)
|
||||
|
||||
const [targetStart, targetEnd] = getStudyRightElementTargetDates(studyRightElement, absences)
|
||||
const graphStart = (targetStart ? new Date(targetStart) : new Date(earliestStudyRightStart ?? startDate)).getTime()
|
||||
const graphEnd = (targetEnd ? new Date(targetEnd) : new Date(endDate)).getTime()
|
||||
|
||||
const filteredAbsences = absences.filter(absence => {
|
||||
const absenceStart = new Date(absence.startDate).getTime()
|
||||
const absenceEnd = new Date(absence.endDate).getTime()
|
||||
return absenceStart <= graphEnd && absenceEnd >= graphStart
|
||||
})
|
||||
|
||||
return createGoalLineData(graphStart, graphEnd, filteredAbsences)
|
||||
}, [selectedStudyRight, selectedStudyPlan, absences, startDate, endDate, earliestStudyRightStart])
|
||||
|
||||
const xAxisMin = useMemo(() => {
|
||||
const defaultMin = new Date(startDate).getTime()
|
||||
if (!hasStudyRights) return defaultMin
|
||||
|
||||
const goalStart = goalSeries.points[0]?.[0]
|
||||
if (typeof goalStart !== 'number' || Number.isNaN(goalStart)) return defaultMin
|
||||
|
||||
return Math.min(defaultMin, goalStart)
|
||||
}, [startDate, hasStudyRights, goalSeries.points])
|
||||
|
||||
const transferMarkers = useMemo(
|
||||
() => getStudentTransferMarkers(student, text => getTextIn(text as never)),
|
||||
[student, getTextIn]
|
||||
)
|
||||
|
||||
const yAxisMax = useMemo(() => {
|
||||
const studentMax = pointData.points.reduce((max, point) => Math.max(max, point.value[1]), 0)
|
||||
const goalMax = hasStudyRights
|
||||
? goalSeries.points.reduce((max, [, credits]) => (Number.isFinite(credits) ? Math.max(max, credits) : max), 0)
|
||||
: 0
|
||||
|
||||
const maxCredits = Math.max(studentMax, goalMax)
|
||||
if (maxCredits <= 0) return undefined
|
||||
|
||||
return Math.ceil(maxCredits / 10) * 10
|
||||
}, [pointData.points, goalSeries.points, hasStudyRights])
|
||||
|
||||
const tooltipsByTimestamp = useMemo(() => {
|
||||
const byTimestamp = new Map<number, string[]>()
|
||||
|
||||
for (const point of pointData.points) {
|
||||
if (!point.tooltipHtml) continue
|
||||
const timestamp = point.value[0]
|
||||
const existing = byTimestamp.get(timestamp) ?? []
|
||||
existing.push(point.tooltipHtml)
|
||||
byTimestamp.set(timestamp, existing)
|
||||
}
|
||||
|
||||
return byTimestamp
|
||||
}, [pointData.points])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| {
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}
|
||||
| Array<{
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
|
||||
const timestamp = params.find(param => param.value)?.value?.[0]
|
||||
if (typeof timestamp === 'number') {
|
||||
const tooltipsAtTimestamp = tooltipsByTimestamp.get(timestamp)
|
||||
if (tooltipsAtTimestamp?.length) {
|
||||
return tooltipsAtTimestamp.join('<br/><br/>')
|
||||
}
|
||||
}
|
||||
|
||||
const studentPoint = params.find(param => param.data?.tooltipHtml)
|
||||
if (studentPoint?.data?.tooltipHtml) return studentPoint.data.tooltipHtml
|
||||
|
||||
return ''
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
},
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
show: false,
|
||||
axisPointer: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: yAxisMax,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
smooth: 0.1,
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
scale: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 11,
|
||||
itemStyle: {
|
||||
color: '#1976d2',
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
},
|
||||
data: pointData.points,
|
||||
markPoint: pointData.graduationMarkPoints.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: pointData.graduationMarkPoints,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
...(hasStudyRights
|
||||
? [
|
||||
{
|
||||
name: 'Goal',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
data: goalSeries.points,
|
||||
showSymbol: false,
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
lineStyle: {
|
||||
color: '#99d8c4',
|
||||
width: 2,
|
||||
},
|
||||
markArea: {
|
||||
silent: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
color: '#1f2937',
|
||||
fontSize: 11,
|
||||
padding: [2, 4],
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.65)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
data: goalSeries.markAreas.map(area => [
|
||||
{
|
||||
name: area.label,
|
||||
xAxis: area.range[0].xAxis,
|
||||
itemStyle: {
|
||||
color: `${area.color}33`,
|
||||
borderColor: area.color,
|
||||
borderType: area.borderType,
|
||||
},
|
||||
},
|
||||
{ xAxis: area.range[1].xAxis },
|
||||
]),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Graduations and transfers',
|
||||
type: 'line',
|
||||
data: [],
|
||||
silent: true,
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [
|
||||
...transferMarkers.map(marker => ({
|
||||
xAxis: marker.value,
|
||||
label: {
|
||||
formatter: marker.label,
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#cbd128', type: 'dashed', width: 2 },
|
||||
})),
|
||||
...pointData.graduationMarkPoints.map(marker => ({
|
||||
xAxis: marker.coord[0],
|
||||
label: {
|
||||
formatter:
|
||||
pointData.graduationData.find(graduation => graduation.date === marker.coord[0])?.label ??
|
||||
'Graduation',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#a333c8', type: 'dashed', width: 2 },
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[
|
||||
student.studentNumber,
|
||||
pointData,
|
||||
goalSeries,
|
||||
transferMarkers,
|
||||
endDate,
|
||||
tooltipsByTimestamp,
|
||||
hasStudyRights,
|
||||
xAxisMin,
|
||||
yAxisMax,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mx: '0.5rem' }}>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={400} label="Small" onSelect={setGraphHeight} />
|
||||
<GraphSizeButton
|
||||
graphHeight={graphHeight}
|
||||
height={INITIAL_GRAPH_HEIGHT}
|
||||
label="Medium"
|
||||
onSelect={setGraphHeight}
|
||||
/>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={1000} label="Large" onSelect={setGraphHeight} />
|
||||
</Stack>
|
||||
<ReactECharts option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import chunk from 'lodash-es/chunk'
|
||||
import groupBy from 'lodash-es/groupBy'
|
||||
import { useMemo } from 'react'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { type GetTextIn, useLanguage } from '../LanguagePicker/useLanguage'
|
||||
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
type SeriesData = {
|
||||
x: number
|
||||
y: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
const semesterChunkify = (courses, semesters, getTextIn: GetTextIn): SeriesData[] => {
|
||||
const semesterChunks = courses.reduce((acc, curr) => {
|
||||
const semester = semesters.find(
|
||||
semester => dayjs(curr.date).isSameOrAfter(semester.startdate) && dayjs(curr.date).isBefore(semester.enddate)
|
||||
)
|
||||
const semesterData = acc.find(data => data.semester === semester.name)
|
||||
if (semesterData) {
|
||||
semesterData.data.push(curr)
|
||||
} else {
|
||||
acc.push({ data: [curr], semester: semester.name })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunks.reduce((acc, curr) => {
|
||||
const gradeSum = curr.data.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.data.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.data.length > 0)
|
||||
acc.push({
|
||||
name: getTextIn(curr.semester),
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr.data[curr.data.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return semesterMeans
|
||||
}
|
||||
|
||||
const gradeMeanSeries = (
|
||||
student: StudentPageStudent,
|
||||
chunksize: number,
|
||||
semesters: SemestersData['semesters'] | undefined,
|
||||
getTextIn: ReturnType<typeof useLanguage>['getTextIn']
|
||||
) => {
|
||||
const filteredCourses = student.courses.filter(
|
||||
course => !Number.isNaN(Number(course.grade)) && !course.isStudyModuleCredit && course.passed
|
||||
)
|
||||
|
||||
const coursesGroupedByDate = groupBy(filteredCourses, 'date')
|
||||
|
||||
const gradesAndMeans = Object.values(coursesGroupedByDate).reduce(
|
||||
(acc, courses) => {
|
||||
for (const course of courses) {
|
||||
acc.grades.push({
|
||||
grade: Number(course.grade),
|
||||
date: course.date.toString(),
|
||||
credits: course.credits,
|
||||
})
|
||||
// Weighted average: each grade is multiplied by the amount of credits the course is worth
|
||||
acc.totalGradeSum += Number(course.grade) * course.credits
|
||||
acc.totalCredits += course.credits
|
||||
}
|
||||
acc.mean.push({ y: acc.totalGradeSum / acc.totalCredits, x: new Date(courses[0].date).getTime() })
|
||||
return acc
|
||||
},
|
||||
{
|
||||
grades: [] as Array<{ grade: number; date: string; credits: number }>,
|
||||
mean: [] as SeriesData[],
|
||||
totalGradeSum: 0,
|
||||
totalCredits: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const size = Number(chunksize) ? chunksize : 3
|
||||
const chunks = chunk(gradesAndMeans.grades, size)
|
||||
|
||||
const groupMeans = chunks.reduce<SeriesData[]>((acc, curr) => {
|
||||
const gradeSum = curr.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.length > 0)
|
||||
acc.push({
|
||||
name: `${curr.length} courses between ${reformatDate(curr[0].date, DateFormat.DISPLAY_DATE)} and ${reformatDate(curr[curr.length - 1].date, DateFormat.DISPLAY_DATE)}`,
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr[curr.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunkify(gradesAndMeans.grades, Object.values(semesters ?? {}), getTextIn)
|
||||
|
||||
return {
|
||||
totalMeans: [{ data: gradesAndMeans.mean }],
|
||||
groupMeans: [{ data: groupMeans }],
|
||||
semesterMeans: [{ data: semesterMeans }],
|
||||
}
|
||||
}
|
||||
|
||||
export const StudentGradeGraphs = ({
|
||||
student,
|
||||
graphMode,
|
||||
groupSize,
|
||||
}: {
|
||||
student: StudentPageStudent
|
||||
graphMode: 'total' | 'group' | 'semester'
|
||||
groupSize: number
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} }
|
||||
|
||||
const { totalMeans, groupMeans, semesterMeans } = useMemo(
|
||||
() => gradeMeanSeries(student, groupSize, allSemesters, getTextIn),
|
||||
[student, groupSize, allSemesters, getTextIn]
|
||||
)
|
||||
|
||||
const selectedSeries: SeriesData[] =
|
||||
graphMode === 'group' ? groupMeans[0].data : graphMode === 'semester' ? semesterMeans[0].data : totalMeans[0].data
|
||||
|
||||
const gradeOption = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| { data?: { x?: number; y?: number; name?: string } }
|
||||
| Array<{ data?: { x?: number; y?: number; name?: string } }>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
const point = params.find(param => param.data)
|
||||
const data = point?.data
|
||||
if (!data) return ''
|
||||
const date = data.x ? reformatDate(new Date(data.x), DateFormat.DISPLAY_DATE) : ''
|
||||
const description = data.name ? `<br/>${data.name}` : ''
|
||||
return `${data.y?.toFixed(2) ?? ''}${date ? `<br/>${date}` : ''}${description}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: 0, filterMode: 'none' },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
min: 1,
|
||||
max: 5.1,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: 0.1,
|
||||
showSymbol: false,
|
||||
data: selectedSeries.map(point => ({
|
||||
value: [point.x, point.y],
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
name: point.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[selectedSeries]
|
||||
)
|
||||
|
||||
return <ReactECharts option={gradeOption} style={{ height: 420 }} />
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { FormattedStudent, Phase } from '@oodikone/shared/types'
|
||||
|
||||
type CourseWithCode = {
|
||||
course_code?: string
|
||||
course?: {
|
||||
code?: string
|
||||
}
|
||||
}
|
||||
|
||||
type StudyPlanLike = {
|
||||
included_courses: string[]
|
||||
}
|
||||
|
||||
export const getOnEvents = (handleClick: ((category: string) => void) | undefined) =>
|
||||
handleClick
|
||||
? {
|
||||
click: (params: { name: string }) => {
|
||||
handleClick(params.name)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
const resolveCourseCode = (course: CourseWithCode) => course.course_code ?? course.course?.code
|
||||
|
||||
const toMillis = (date: Date | string) => new Date(date).getTime()
|
||||
|
||||
/** expandKey is only provided to the expanded instance of a chart */
|
||||
export const getSeriesLabel = (expandKey: string | undefined | null, yearLabel: string | undefined, mode: string) =>
|
||||
expandKey ? `${mode.charAt(0).toUpperCase()}${mode.slice(1)}` : yearLabel
|
||||
|
||||
const getEnrollmentLabel = (enrollmentType: number, statutoryAbsence: boolean) => {
|
||||
if (enrollmentType === 2) return statutoryAbsence ? 'Absent (statutory)' : 'Absent'
|
||||
if (enrollmentType === 3) return 'No enrollment'
|
||||
return 'No study right'
|
||||
}
|
||||
|
||||
const getTermLabel = (startDate: number) => {
|
||||
const date = new Date(startDate)
|
||||
const month = date.getMonth()
|
||||
const year = date.getFullYear()
|
||||
const season = month >= 7 ? 'Fall' : 'Spring'
|
||||
return `${season} ${year}`
|
||||
}
|
||||
|
||||
const getTermRangeLabel = (startDate: number, endTermDate: number) => {
|
||||
const startTerm = getTermLabel(startDate)
|
||||
const endTerm = getTermLabel(endTermDate)
|
||||
if (startTerm === endTerm) return startTerm
|
||||
return `${startTerm} - ${endTerm}`
|
||||
}
|
||||
|
||||
export const getIncludedCourseCodesByProgrammeCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[]
|
||||
): Set<string> => {
|
||||
if (!programmeCodes) return new Set()
|
||||
const studyplans = student.studyplans.filter(studyplan => programmeCodes.includes(studyplan.programme_code))
|
||||
return new Set(studyplans.flatMap(studyplan => studyplan.included_courses))
|
||||
}
|
||||
|
||||
export const getGraduationsByCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[],
|
||||
showBachelorAndMaster: boolean
|
||||
): number[] => {
|
||||
if (!programmeCodes) return []
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => !!graduated) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => !!graduated && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export const filterCoursesByStudyPlan = <TCourse extends CourseWithCode>(
|
||||
courses: TCourse[],
|
||||
studyPlan?: StudyPlanLike
|
||||
): TCourse[] => {
|
||||
if (!studyPlan) return courses
|
||||
return courses.filter(course => {
|
||||
const code = resolveCourseCode(course)
|
||||
return !!code && studyPlan.included_courses.includes(code)
|
||||
})
|
||||
}
|
||||
|
||||
export const createGoalLineData = (
|
||||
graphStartDate: number,
|
||||
graphEndDate: number,
|
||||
absences: {
|
||||
startDate: Date | string
|
||||
endDate: Date | string
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
}[]
|
||||
) => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const getColor = (type: number) => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const now = Date.now()
|
||||
type ClippedAbsence = {
|
||||
start: number
|
||||
end: number
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
labelStart: number
|
||||
labelEnd: number
|
||||
}
|
||||
|
||||
const relevantAbsences = absences
|
||||
.map(absence => {
|
||||
const start = toMillis(absence.startDate)
|
||||
const end = toMillis(absence.endDate)
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
||||
|
||||
const clippedStart = Math.max(start, graphStartDate)
|
||||
const clippedEndByGraph = Math.min(end, graphEndDate)
|
||||
const clippedEnd = absence.enrollmenttype === 3 ? Math.min(clippedEndByGraph, now) : clippedEndByGraph
|
||||
|
||||
if (clippedEnd <= clippedStart) return null
|
||||
|
||||
return {
|
||||
start: clippedStart,
|
||||
end: clippedEnd,
|
||||
enrollmenttype: absence.enrollmenttype,
|
||||
statutoryAbsence: absence.statutoryAbsence,
|
||||
labelStart: clippedStart,
|
||||
labelEnd: clippedStart,
|
||||
}
|
||||
})
|
||||
.filter((absence): absence is ClippedAbsence => absence !== null)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
|
||||
const mergedAbsences = relevantAbsences.reduce<ClippedAbsence[]>((merged, absence) => {
|
||||
const previous = merged[merged.length - 1]
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous.enrollmenttype === absence.enrollmenttype &&
|
||||
previous.statutoryAbsence === absence.statutoryAbsence &&
|
||||
absence.start <= previous.end + DAY_MS
|
||||
) {
|
||||
previous.end = Math.max(previous.end, absence.end)
|
||||
previous.labelEnd = absence.labelEnd
|
||||
return merged
|
||||
}
|
||||
|
||||
merged.push({ ...absence })
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
const points: number[][] = [[graphStartDate, 0]]
|
||||
const markAreas: Array<{
|
||||
range: [{ xAxis: number }, { xAxis: number }]
|
||||
color: string
|
||||
borderType: 'solid' | 'dashed'
|
||||
label: string
|
||||
}> = []
|
||||
|
||||
for (const absence of mergedAbsences) {
|
||||
const absenceStart = absence.start
|
||||
const absenceEnd = absence.end
|
||||
const targetCreditsBeforeAbsence =
|
||||
((absenceStart - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = (absenceEnd - absenceStart) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
points.push([absenceStart, targetCreditsBeforeAbsence])
|
||||
points.push([absenceEnd, targetCreditsBeforeAbsence])
|
||||
const enrollmentLabel = getEnrollmentLabel(absence.enrollmenttype, absence.statutoryAbsence)
|
||||
const termLabel = getTermRangeLabel(absence.labelStart, absence.labelEnd)
|
||||
const text = `${enrollmentLabel}\n(${termLabel})`
|
||||
|
||||
markAreas.push({
|
||||
range: [{ xAxis: absenceStart }, { xAxis: absenceEnd }],
|
||||
color: getColor(absence.enrollmenttype),
|
||||
borderType: absence.statutoryAbsence ? 'dashed' : 'solid',
|
||||
label: text,
|
||||
})
|
||||
}
|
||||
|
||||
const yearsFromStart = (graphEndDate - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
points.push([graphEndDate, endingCredits])
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
|
||||
return { points, markAreas }
|
||||
}
|
||||
|
||||
export const getStudentTransferMarkers = (
|
||||
student: Pick<FormattedStudent, 'studyRights'>,
|
||||
getTextIn: (text: unknown) => string | null | undefined
|
||||
): Array<{ value: number; label: string }> =>
|
||||
student.studyRights.reduce<Array<{ value: number; label: string }>>((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.ANY)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.MASTER)
|
||||
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
for (const element of programmes.slice(0, -1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate).getTime(),
|
||||
label: `Transfer to ${getTextIn(element.name) ?? ''}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
/** HACK: markPoint (in this case the graduation marker) needs a datapoint to attach to. This creates a synthetic data point
|
||||
to the time of graduation and attaches the markPoint to it (remember to sort the datapoints again) */
|
||||
export const getGraduationDataPoints = (
|
||||
dataPoints: number[][],
|
||||
markPoints: { coord: number[]; name: string }[],
|
||||
graduationDate: number
|
||||
) => {
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const index = dataPoints.findIndex(point => point[0] > graduationDate)
|
||||
let y: number
|
||||
if (index <= 0) {
|
||||
y = graduationDate > (dataPoints.at(0)?.[0] ?? 0) ? dataPoints[dataPoints.length - 1][1] : dataPoints[0][1]
|
||||
} else {
|
||||
const yBefore = dataPoints[index - 1][1]
|
||||
const yAfter = dataPoints[index][1]
|
||||
y = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const coord = [graduationDate, y]
|
||||
dataPoints.push(coord)
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
markPoints.push({ coord, name: `Graduation` })
|
||||
}
|
||||
@@ -90,8 +90,6 @@ export const FacultiesTab = () => {
|
||||
totalRow.bySemesters[numberMode]
|
||||
)
|
||||
|
||||
if (!facultyMap) return null
|
||||
|
||||
const tableOptions = {
|
||||
initialState: { columnPinning: { left: ['Course'] } },
|
||||
state: {
|
||||
|
||||
@@ -4,25 +4,14 @@ import Typography from '@mui/material/Typography'
|
||||
|
||||
import { createColumnHelper } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const getFacultyTitle = code => {
|
||||
if (!code) return 'No faculty' // Shouldn't happen
|
||||
if (code === 'OPEN') return 'Open\nuni'
|
||||
if (code === 'OTHER') return 'Other\nfaculty'
|
||||
return code
|
||||
return code ?? 'No faculty'
|
||||
}
|
||||
|
||||
const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
|
||||
title={getTextIn(name) ?? code}
|
||||
>
|
||||
<b>{code}</b>
|
||||
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
|
||||
</div>
|
||||
)
|
||||
|
||||
const columnHelper = createColumnHelper()
|
||||
|
||||
export const useColumns = (getTextIn, faculties, numberMode, colorMode, facultyMap, allTotal) => {
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
import { createColumnHelper } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
|
||||
title={getTextIn(name) ?? code}
|
||||
>
|
||||
<b>{code}</b>
|
||||
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
|
||||
</div>
|
||||
)
|
||||
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const columnHelper = createColumnHelper()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
export const ColorizedCoursesTableContext = createContext(null)
|
||||
export const ColorizedCoursesTableContext = createContext()
|
||||
|
||||
export const useColorizedCoursesTableContext = () => useContext(ColorizedCoursesTableContext)
|
||||
|
||||
@@ -95,3 +96,10 @@ export const CourseFilter = ({ setCourseFilter }) => {
|
||||
<TextField label="Course name/code filter" onChange={event => setTextField(event.target.value)} value={textField} />
|
||||
)
|
||||
}
|
||||
|
||||
export const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<Stack alignItems="baseline" flexDirection="column" py={1} title={getTextIn(name) ?? code}>
|
||||
<b>{code}</b>
|
||||
<i className="colored-info-expander">{getTextIn(name) ?? null}</i>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
.colorized-courses-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.colorized-courses-table > div {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -28,9 +14,9 @@ input[type='radio'] {
|
||||
max-width: 25vw;
|
||||
}
|
||||
|
||||
table tr th,
|
||||
td > div > i {
|
||||
max-width: 340px;
|
||||
.colored-info-expander {
|
||||
color: gray;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
+24
-33
@@ -1,39 +1,36 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { LoadingSection } from '@/components/Loading'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
import { SemestersTab } from './SemestersTab'
|
||||
import './index.css'
|
||||
|
||||
export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, panes, dividerText }) => {
|
||||
useTitle(title)
|
||||
const { data: semesterData } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesterData ?? { semesters: {}, currentSemester: null }
|
||||
export const ColorizedCoursesTable = ({ fetchDataHook, fetchDataHookParams, panes }) => {
|
||||
const { semesters: allSemesters, currentSemester } = useSemesters()
|
||||
const semesters = Object.values(allSemesters).filter(
|
||||
// 135 = Fall 2017
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
|
||||
// INFO: If semesters is defined, currentSemester will be defined.
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester!.semestercode
|
||||
)
|
||||
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook(fetchDataHookParams)
|
||||
|
||||
const [tab, setTab] = useState(0)
|
||||
const [numberMode, setNumberMode] = useState('completions')
|
||||
const [colorMode, setColorMode] = useState('none')
|
||||
const [semesterFilter, setSemesterFilter] = useState(null)
|
||||
const [semesterFilter, setSemesterFilter] = useState<{ start: number; end: number }>({ start: 135, end: 135 })
|
||||
const [filterEmptyCourses, setFilterEmptyCourses] = useState(true)
|
||||
|
||||
const selectedSemesters = useMemo(() => {
|
||||
const selectedSemestersArray = []
|
||||
const selectedSemestersArray: number[] = []
|
||||
if (semesterFilter?.start && semesterFilter?.end) {
|
||||
for (let i = parseInt(semesterFilter.start, 10); i <= parseInt(semesterFilter.end, 10); i++) {
|
||||
for (let i = semesterFilter.start; i <= semesterFilter.end; i++) {
|
||||
selectedSemestersArray.push(i)
|
||||
}
|
||||
}
|
||||
@@ -91,25 +88,19 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
<div className="colorized-courses-table">
|
||||
{dividerText ? (
|
||||
<Divider sx={{ width: '100%', mt: 3, mb: 1 }}>
|
||||
<Typography fontSize="1.3rem" variant="overline">
|
||||
{dividerText}
|
||||
</Typography>
|
||||
</Divider>
|
||||
) : null}
|
||||
|
||||
{displayedPanes.length > 1 ? (
|
||||
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
|
||||
{displayedPanes.map(({ label }) => (
|
||||
<Tab key={label} label={label} />
|
||||
))}
|
||||
</Tabs>
|
||||
) : null}
|
||||
<Paper variant="outlined">{displayedPanes.at(tab)?.render() ?? null}</Paper>
|
||||
</div>
|
||||
</ColorizedCoursesTableContext.Provider>
|
||||
<Stack alignItems="center">
|
||||
{displayedPanes.length > 1 && (
|
||||
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
|
||||
{displayedPanes.map(({ label }) => (
|
||||
<Tab key={label} label={label} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
<Paper variant="outlined">
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
{displayedPanes.at(tab)?.render() ?? null}
|
||||
</ColorizedCoursesTableContext.Provider>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
+3
-4
@@ -12,8 +12,8 @@ import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist/util'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Loading } from '@/components/Loading'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { FormattedStudent, Name } from '@oodikone/shared/types'
|
||||
|
||||
type CreditGainObj = Record<string, { credits: number; name: Name; students: string[] }>
|
||||
@@ -76,8 +76,7 @@ export const CoursePopulationCreditGainTable = ({
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
const { data: faculties } = useGetFacultiesQuery()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
|
||||
const { semesters, currentSemester } = useSemesters()
|
||||
|
||||
if (!faculties || !currentSemester) {
|
||||
return <Loading />
|
||||
@@ -95,7 +94,7 @@ export const CoursePopulationCreditGainTable = ({
|
||||
const programme = findCorrectProgramme(
|
||||
student,
|
||||
codes,
|
||||
allSemesters,
|
||||
semesters,
|
||||
new Date(from),
|
||||
new Date(to),
|
||||
currentSemester.semestercode
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ export const ExternalGradeFilterToggle = ({ grade }) => {
|
||||
active={isActive}
|
||||
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
|
||||
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
|
||||
disabled={false}
|
||||
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
|
||||
/>
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
startYearAtUniFilter,
|
||||
studentNumberFilter,
|
||||
} from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PageLoading } from '@/components/Loading'
|
||||
@@ -28,8 +28,9 @@ import { PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/StudentTable/GeneralTab/format/index'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetCourseDetailsQuery } from '@/redux/courseStats'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
@@ -41,52 +42,49 @@ import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
|
||||
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
|
||||
import { useColumns as columnsGeneralTab } from './studentColumns'
|
||||
|
||||
const CourseTitle = ({ codes, dateRange, unifyCourses }) => {
|
||||
const { useFilterSelector } = useFilters()
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const courseName = useFilterSelector(courseFilter.selectors.selectedCourseName(codes))
|
||||
const header = courseName ? `${getTextIn(courseName)} ${dateRange} ${getUnifyTextIn(unifyCourses)}` : null
|
||||
|
||||
const subHeader = codes.join(', ')
|
||||
|
||||
return <PageTitle subtitle={subHeader} title={header ? `Population of course ${header}` : undefined} />
|
||||
}
|
||||
|
||||
export const CoursePopulation = () => {
|
||||
useTitle('Course population')
|
||||
|
||||
const location = useLocation()
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const [codes, setCodes] = useState<string[]>([])
|
||||
const [codes, setCodes] = useState<{ allCodes: string[]; mainCodes: string[] }>({
|
||||
allCodes: [],
|
||||
mainCodes: [],
|
||||
})
|
||||
|
||||
const { coursecodes, from, to, separate, unifyCourses } = parseQueryParams(location.search)
|
||||
const { coursecodes, from, to, separate, unifyCourses, includeSubstitutions } = parseQueryParams(location.search)
|
||||
|
||||
const { data: courseDetails } = useGetCourseDetailsQuery({ codes: codes.mainCodes })
|
||||
const { data: population, isFetching } = useGetPopulationStatisticsByCourseQuery({
|
||||
coursecodes,
|
||||
from,
|
||||
to,
|
||||
separate,
|
||||
unifyCourses,
|
||||
includeSubstitutions,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const parsedCourseCodes = JSON.parse(coursecodes)
|
||||
if (parsedCourseCodes.length) setCodes(parsedCourseCodes)
|
||||
if (parsedCourseCodes.length) setCodes({ ...codes, mainCodes: parsedCourseCodes })
|
||||
}, [coursecodes])
|
||||
|
||||
useEffect(() => {
|
||||
if (population?.allCourseCodes?.length) setCodes({ ...codes, allCodes: population.allCourseCodes })
|
||||
}, [population])
|
||||
|
||||
const studentToTargetCourseDateMap = useMemo(
|
||||
() => getStudentToTargetCourseDateMap(population?.students ?? [], codes),
|
||||
() => getStudentToTargetCourseDateMap(population?.students ?? [], codes.allCodes),
|
||||
[population?.students, codes]
|
||||
)
|
||||
|
||||
const { data: semesters, isFetching: semestersFetching } = useGetSemestersQuery()
|
||||
const {
|
||||
semesters: allSemesters,
|
||||
years: semesterYears,
|
||||
currentSemester,
|
||||
} = semesters ?? { semesters: {}, years: {}, currentSemester: null }
|
||||
isLoading: semestersFetching,
|
||||
} = useSemesters()
|
||||
|
||||
const getFromToDates = (from: number, to: number, separate: boolean) => {
|
||||
if (semestersFetching) return {}
|
||||
@@ -115,7 +113,7 @@ export const CoursePopulation = () => {
|
||||
? (getTextIn(singleSemester.name) ?? '')
|
||||
: `${new Date(dateFrom).getFullYear()}-${new Date(dateTo).getFullYear()}`
|
||||
|
||||
if (!population || !semesters) return <PageLoading isLoading />
|
||||
if (!population || !currentSemester) return <PageLoading isLoading />
|
||||
|
||||
const getStudentRelevantProgrammes = (students: FormattedStudent[]) =>
|
||||
students.reduce<Map<string, string>>((programmes, student) => {
|
||||
@@ -132,16 +130,27 @@ export const CoursePopulation = () => {
|
||||
return programmes
|
||||
}, new Map())
|
||||
|
||||
// Page title data
|
||||
const courseNames = courseDetails?.map(({ name }) => getTextIn(name)) ?? []
|
||||
const header = courseNames.length ? `${courseNames?.join(', ')}` : undefined
|
||||
|
||||
const createPanels = (filteredStudents: FormattedStudent[], filteredCourses: FilteredCourse[]) => [
|
||||
{
|
||||
title: 'Grade distribution',
|
||||
content: (
|
||||
<CoursePopulationGradeDist courseCodes={codes} from={dateFrom} students={filteredStudents} to={dateTo} />
|
||||
<CoursePopulationGradeDist
|
||||
courseCodes={codes.allCodes}
|
||||
from={dateFrom}
|
||||
students={filteredStudents}
|
||||
to={dateTo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Language distribution',
|
||||
content: <CoursePopulationLanguageDist codes={codes} from={dateFrom} students={filteredStudents} to={dateTo} />,
|
||||
content: (
|
||||
<CoursePopulationLanguageDist codes={codes.allCodes} from={dateFrom} students={filteredStudents} to={dateTo} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Programme distribution',
|
||||
@@ -153,7 +162,12 @@ export const CoursePopulation = () => {
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
<CustomPopulationProgrammeDist coursecodes={codes} from={dateFrom} students={filteredStudents} to={dateTo} />
|
||||
<CustomPopulationProgrammeDist
|
||||
coursecodes={codes.allCodes}
|
||||
from={dateFrom}
|
||||
students={filteredStudents}
|
||||
to={dateTo}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -164,7 +178,12 @@ export const CoursePopulation = () => {
|
||||
{
|
||||
title: 'Credit gains',
|
||||
content: (
|
||||
<CoursePopulationCreditGainTable codes={codes} from={dateFrom} students={filteredStudents} to={dateTo} />
|
||||
<CoursePopulationCreditGainTable
|
||||
codes={codes.allCodes}
|
||||
from={dateFrom}
|
||||
students={filteredStudents}
|
||||
to={dateTo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -186,7 +205,7 @@ export const CoursePopulation = () => {
|
||||
showBachelorAndMaster: false,
|
||||
includePrimaryProgramme: true,
|
||||
|
||||
coursecodes: codes,
|
||||
coursecodes: codes.allCodes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
relatedProgrammeMap: getStudentRelevantProgrammes(filteredStudents),
|
||||
@@ -199,53 +218,67 @@ export const CoursePopulation = () => {
|
||||
},
|
||||
]
|
||||
|
||||
const filters = [
|
||||
genderFilter(),
|
||||
studentNumberFilter(),
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
{
|
||||
key: 'attainment',
|
||||
label: 'Attainment',
|
||||
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
|
||||
const correctProgramme = findCorrectProgramme(
|
||||
student,
|
||||
codes.allCodes,
|
||||
allSemesters,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo),
|
||||
currentSemester?.semestercode
|
||||
)
|
||||
return correctProgramme?.code === studyRightElement.code
|
||||
},
|
||||
description: 'Student had an active study right at the time of course attainment.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
gradeFilter({
|
||||
courseCodes: codes.allCodes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
coursestatistics={population?.coursestatistics}
|
||||
displayTray={!isFetching}
|
||||
filters={[
|
||||
genderFilter(),
|
||||
studentNumberFilter(),
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
{
|
||||
key: 'attainment',
|
||||
label: 'Attainment',
|
||||
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
|
||||
const correctProgramme = findCorrectProgramme(
|
||||
student,
|
||||
codes,
|
||||
allSemesters,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo),
|
||||
currentSemester?.semestercode
|
||||
)
|
||||
return correctProgramme?.code === studyRightElement.code
|
||||
},
|
||||
description: 'Student had an active study right at the time of course attainment.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
gradeFilter({
|
||||
courseCodes: codes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
]}
|
||||
filters={filters}
|
||||
initialOptions={{
|
||||
[programmeFilter.key]: { mode: 'attainment', selectedProgrammes: [] },
|
||||
}}
|
||||
name="CoursePopulation"
|
||||
students={population?.students ?? []}
|
||||
>
|
||||
{(filteredStudents, filteredCourses) => (
|
||||
<>
|
||||
<CourseTitle codes={codes} dateRange={dateRange} unifyCourses={unifyCourses} />
|
||||
<PageTitle title={header}>
|
||||
<Typography color="text.secondary" variant="h6">
|
||||
{codes.mainCodes.join(', ')}
|
||||
</Typography>
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
variant="h6"
|
||||
>{`Class of ${dateRange}, ${population.students.length} students`}</Typography>
|
||||
<Typography color="text.secondary" variant="h6">
|
||||
{(includeSubstitutions === 'true' ? 'Include' : 'Exclude') +
|
||||
' substitutions, ' +
|
||||
getUnifyTextIn(unifyCourses)}
|
||||
</Typography>
|
||||
</PageTitle>
|
||||
<PanelView panels={createPanels(filteredStudents, filteredCourses)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
|
||||
const Tile = ({ children }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', fontWeight: 'bold' }}>{children}</div>
|
||||
)
|
||||
const TileValue = ({ children }) => <div style={{ fontSize: '1.25em' }}>{children}</div>
|
||||
|
||||
export const CreditGraphTooltip = ({ credits, date, grade, isStudyModuleCredit, courseCode, courseName }) => {
|
||||
const title = `${courseName} (${courseCode})`
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardHeader
|
||||
content={`${title}${isStudyModuleCredit ? ' [Study Module]' : ''}`}
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
/>
|
||||
<CardContent style={{ display: 'grid', grid: '1fr 1fr 1fr', gridRowGap: '0.25rem' }}>
|
||||
<Tile>
|
||||
Credits <TileValue>{credits}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Grade <TileValue>{grade}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Date <TileValue>{date}</TileValue>
|
||||
</Tile>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// @ts-check
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import { flatten } from 'lodash-es'
|
||||
import { useRef, useState } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
|
||||
exporting(ReactHighstock.Highcharts)
|
||||
exportData(ReactHighstock.Highcharts)
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
const createGraphOptions = ({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
tooltipFormatter,
|
||||
onPointClicked,
|
||||
startDate,
|
||||
endDate,
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
}) => {
|
||||
const tooltip = {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
if (singleStudent) {
|
||||
Object.assign(tooltip, {
|
||||
formatter() {
|
||||
return tooltipFormatter(this)
|
||||
},
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
plotLines: graduations
|
||||
.map(graduation => ({
|
||||
value: graduation.value,
|
||||
color: '#a333c8',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Graduation ${graduation.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
.concat(
|
||||
studyRightStartLine.map(value => ({
|
||||
value,
|
||||
color: 'red',
|
||||
width: 3,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: 'Population study start',
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
transfers.map(transfer => ({
|
||||
value: transfer.value,
|
||||
color: '#cbd128',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Transfer ${transfer.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
),
|
||||
},
|
||||
rangeSelector: {
|
||||
selected: 5, // Set default zoom to 'All'
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
accessibility: {
|
||||
// Accessibility module doesn't seem to work with Highstock, at least for large amounts of data
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const filterCoursesByStudyPlan = (plan, courses) => {
|
||||
if (!plan) {
|
||||
return courses
|
||||
}
|
||||
return courses.filter(({ course, course_code }) => {
|
||||
if (!course?.code && !course_code) {
|
||||
return false
|
||||
}
|
||||
return plan.included_courses.includes(course_code ?? course.code)
|
||||
})
|
||||
}
|
||||
|
||||
const filterCoursesByDate = (courses, date) => courses.filter(course => dayjs(course.date).isSameOrAfter(dayjs(date)))
|
||||
|
||||
const filterCourses = (student, singleStudent, byStudyPlanOfCode, cutStudyPlanCredits, startDate, studyrightid) => {
|
||||
if (byStudyPlanOfCode && cutStudyPlanCredits)
|
||||
return filterCoursesByDate(
|
||||
filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
),
|
||||
student.studyrightStart
|
||||
)
|
||||
if (byStudyPlanOfCode)
|
||||
return filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
)
|
||||
if (singleStudent) return student.courses
|
||||
return filterCoursesByDate(student.courses, startDate)
|
||||
}
|
||||
|
||||
const singleStudentTooltipFormatter = (point, student, getTextIn) => {
|
||||
const targetCourse = student.courses.find(
|
||||
({ course, date }) => point.key === course.code && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
if (!targetCourse.course) return null
|
||||
|
||||
const payload = {
|
||||
...targetCourse,
|
||||
courseCode: targetCourse.course.code,
|
||||
courseName: getTextIn(targetCourse.course.name),
|
||||
date: reformatDate(targetCourse.date, DateFormat.DISPLAY_DATE),
|
||||
}
|
||||
|
||||
return renderToString(<CreditGraphTooltip {...payload} />)
|
||||
}
|
||||
|
||||
const createGoalSeries = (graphStartDate, graphEndDate, absences) => {
|
||||
const getColor = type => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
const absenceIsBetweenGraphDates = (startDate, endDate) =>
|
||||
(graphStartDate <= startDate && startDate <= graphEndDate) || (graphStartDate <= endDate && endDate <= graphEndDate)
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const absencePoints = absences
|
||||
.filter(a =>
|
||||
a.enrollmenttype === 3
|
||||
? absenceIsBetweenGraphDates(a.startDate, a.endDate) && a.startDate <= new Date()
|
||||
: absenceIsBetweenGraphDates(a.startDate, a.endDate)
|
||||
)
|
||||
.reduce((res, { startDate, endDate, enrollmenttype, statutoryAbsence }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(dayjs(startDate).diff(dayjs(graphStartDate), 'years', true) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = dayjs(endDate).diff(dayjs(startDate), 'years', true)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
res.push([startDate.getTime(), targetCreditsBeforeAbsence, null, null])
|
||||
res.push([endDate.getTime(), targetCreditsBeforeAbsence, enrollmenttype, statutoryAbsence])
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zones = []
|
||||
for (const [start, , enrollmenttype, statutoryAbsence] of absencePoints) {
|
||||
zones.push({
|
||||
value: start,
|
||||
color: getColor(enrollmenttype),
|
||||
dashStyle: statutoryAbsence ? 'Dash' : 'Solid',
|
||||
})
|
||||
}
|
||||
zones.push({ color: '#96d7c3' })
|
||||
|
||||
const yearsFromStart = dayjs(graphEndDate).diff(dayjs(graphStartDate), 'years', true)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
const data = [[graphStartDate, 0], ...absencePoints, [graphEndDate, endingCredits]].sort((a, b) => a[0] - b[0])
|
||||
|
||||
return {
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data,
|
||||
enableMouseTracking: false,
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
}
|
||||
}
|
||||
|
||||
const filterGraduations = (student, selectedStudyRight, getTextIn) => {
|
||||
const graduations = student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => element.graduated)
|
||||
if (!selectedStudyRight)
|
||||
return graduations.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
const selectedGraduation = graduations.filter(({ studyRightId }) => studyRightId === selectedStudyRight.id)
|
||||
if (selectedGraduation.length === 0) return []
|
||||
return selectedGraduation.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
}
|
||||
|
||||
const addGraduation = (points, graduationDate, notFirst) => {
|
||||
const index = points.findIndex(point => point.x > graduationDate)
|
||||
let graduationY
|
||||
if (index <= 0) {
|
||||
graduationY = graduationDate > points[0].x ? points[points.length - 1].y : points[0].y
|
||||
} else {
|
||||
const yBefore = points[index - 1].y
|
||||
const yAfter = points[index].y
|
||||
graduationY = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const marker = {
|
||||
radius: 10,
|
||||
symbol: 'diamond',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const masterMarker = {
|
||||
radius: 12,
|
||||
symbol: 'circle',
|
||||
lineWidth: 2,
|
||||
lineColor: '#ffffff',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
points.push({
|
||||
x: graduationDate,
|
||||
y: graduationY,
|
||||
marker: notFirst ? masterMarker : marker,
|
||||
})
|
||||
points.sort((a, b) => a.x - b.x)
|
||||
}
|
||||
|
||||
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => graduated === true) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => graduated === true && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const createStudentCreditLines = (
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
) =>
|
||||
students.map(student => {
|
||||
const { studyrightStart } = student
|
||||
const code = selectedStudyPlan?.programme_code
|
||||
const studyPlanProgrammeCode = singleStudent
|
||||
? code
|
||||
: studyPlanFilterIsActive && programmeCodes?.length > 0 && programmeCodes[0]
|
||||
|
||||
const { points } = filterCourses(
|
||||
student,
|
||||
singleStudent,
|
||||
studyPlanProgrammeCode,
|
||||
cutStudyPlanCredits,
|
||||
studyrightStart,
|
||||
studyRightId
|
||||
)
|
||||
.filter(({ date }) => new Date(date) <= new Date())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.reduce(
|
||||
(acc, course) => {
|
||||
// Only include passed and transferred courses
|
||||
if (![4, 9].includes(course.credittypecode)) return acc
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
|
||||
const point = {
|
||||
x: new Date(course.date).getTime(),
|
||||
y: acc.credits + gainedCredits,
|
||||
}
|
||||
|
||||
if (singleStudent) point.name = course.course.code
|
||||
|
||||
acc.points.push(point)
|
||||
|
||||
return {
|
||||
...acc,
|
||||
credits: acc.credits + gainedCredits,
|
||||
}
|
||||
},
|
||||
{ credits: 0, points: [] }
|
||||
)
|
||||
|
||||
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
|
||||
|
||||
if (points?.length > 0) {
|
||||
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
|
||||
const xMinusTwoMonths = dayjs(new Date(points[0].x)).subtract(2, 'months').toDate().getTime()
|
||||
points.unshift({
|
||||
x: studyPlanFilterIsActive ? xMinusTwoMonths : Math.max(new Date(studyrightStart).getTime(), xMinusTwoMonths),
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
graduationDates.forEach((graduationDate, index) => addGraduation(points, graduationDate, index > 0))
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: !!singleStudent,
|
||||
radius: 4,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const filterTransfers = (student, getTextIn) =>
|
||||
student.studyRights.reduce((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === 1)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === 2)
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
// The oldest study right element is ignored, as there can be no transfer to it
|
||||
for (const element of programmes.toSpliced(-1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate),
|
||||
studyright: `to ${getTextIn(element.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
export const CreditAccumulationGraphHighCharts = ({
|
||||
students,
|
||||
singleStudent,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
programmeCodes,
|
||||
customPopulation = false,
|
||||
studyPlanFilterIsActive,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
infoBoxContent = '',
|
||||
}) => {
|
||||
const chartRef = useRef()
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(700)
|
||||
const [cutStudyPlanCredits, setCutStudyPlanCredits] = useState(false)
|
||||
const selectedStudyRight =
|
||||
singleStudent && studyRightId ? students[0].studyRights.find(({ id }) => id === studyRightId) : null
|
||||
|
||||
const seriesData = useDeepMemo(
|
||||
() =>
|
||||
createStudentCreditLines(
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
),
|
||||
[
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
]
|
||||
)
|
||||
|
||||
if (singleStudent) {
|
||||
const studyPlanProgrammeCode = selectedStudyPlan ? selectedStudyPlan.programme_code : null
|
||||
const correctStudyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === studyPlanProgrammeCode
|
||||
)
|
||||
|
||||
const startDates = selectedStudyRight
|
||||
? selectedStudyRight.studyRightElements
|
||||
.filter(e => e.phase === correctStudyRightElement.phase)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||
.map(e => e.startDate)
|
||||
: (students[0].studyRights ?? students[0].courses ?? []).map(e => e.startDate ?? e.date)
|
||||
|
||||
const firstDate = startDates.length
|
||||
? startDates.map(date => new Date(date)).sort((a, b) => a.getTime() - b.getTime())[0]
|
||||
: new Date()
|
||||
|
||||
const startDate = firstDate.getTime()
|
||||
|
||||
const [, studyRightTargetEnd] = getStudyRightElementTargetDates(
|
||||
selectedStudyRight?.studyRightElements
|
||||
.filter(element => element.phase === correctStudyRightElement.phase)
|
||||
.sort(({ startDate: a }, { startDate: b }) => new Date(a).getTime() - new Date(b).getTime())
|
||||
.at(0),
|
||||
absences
|
||||
)
|
||||
const ending = selectedStudyRight ? new Date(studyRightTargetEnd) : new Date(endDate ?? new Date())
|
||||
const starting = new Date(startDate)
|
||||
const filteredAbsences = selectedStudyRight
|
||||
? absences.filter(({ startDate, endDate }) => startDate >= starting && endDate <= ending)
|
||||
: absences
|
||||
|
||||
seriesData.push(createGoalSeries(starting.getTime(), ending.getTime(), filteredAbsences))
|
||||
}
|
||||
const getStudyRightStart = () => {
|
||||
const studyRightStartFromStudent = new Date(students[0]?.studyrightStart ?? new Date())
|
||||
if (studyRightStartFromStudent.getFullYear() < 2000)
|
||||
return Math.min(...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime()))))
|
||||
return studyRightStartFromStudent.getTime()
|
||||
}
|
||||
|
||||
const getGraphStart = () => {
|
||||
if (startDate) return new Date(startDate).getTime()
|
||||
if (customPopulation)
|
||||
return Math.min(
|
||||
...flatten(
|
||||
students.map(({ courses }) => {
|
||||
return courses.map(({ date }) => new Date(date).getTime())
|
||||
})
|
||||
)
|
||||
)
|
||||
const studyRightStart = getStudyRightStart()
|
||||
if (studyPlanFilterIsActive && cutStudyPlanCredits) return studyRightStart
|
||||
if (studyPlanFilterIsActive) {
|
||||
// math.min may return a infinite value, if beginning of course credits is selected and student's courses are filtered accordingly.
|
||||
const startPoint = Math.min(
|
||||
...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime())))
|
||||
)
|
||||
return startPoint !== Infinity ? startPoint : studyRightStart
|
||||
}
|
||||
return studyRightStart
|
||||
}
|
||||
|
||||
const graduations = singleStudent ? filterGraduations(students[0], selectedStudyRight, getTextIn) : []
|
||||
const transfers = singleStudent ? filterTransfers(students[0], getTextIn) : []
|
||||
const studyRightStartLine =
|
||||
!singleStudent && studyPlanFilterIsActive && students[0]?.studyrightStart
|
||||
? [new Date(students[0].studyrightStart).getTime()]
|
||||
: []
|
||||
|
||||
const options = createGraphOptions({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
startDate: getGraphStart(),
|
||||
endDate,
|
||||
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], getTextIn),
|
||||
onPointClicked: point => {
|
||||
if (!singleStudent) {
|
||||
if (point.series.name.length > 10) return
|
||||
window.open(`/students/${point.series.name}`, '_blank')
|
||||
}
|
||||
},
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={graphHeight === height ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '400px', marginBottom: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row-reverse',
|
||||
alignItems: 'center',
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{!!infoBoxContent && <InfoBox content={infoBoxContent} />}
|
||||
<div>
|
||||
{!singleStudent && studyPlanFilterIsActive ? (
|
||||
<FormControlLabel
|
||||
control={<Switch />}
|
||||
label="Display credits from study right start"
|
||||
onClick={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{makeGraphSizeButton(400, 'Small')}
|
||||
{makeGraphSizeButton(700, 'Medium')}
|
||||
{makeGraphSizeButton(1000, 'Large')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReactHighstock config={options} ref={chartRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user