mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
273
Commits
chart-refresh
...
3.19.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f31e03ae1 | ||
|
|
70120f27bc | ||
|
|
96889eca28 | ||
|
|
0336c27aac | ||
|
|
5e6a320c88 | ||
|
|
092aefb177 | ||
|
|
8ce01807c2 | ||
|
|
8331e462f1 | ||
|
|
22a672bbe8 | ||
|
|
d21f383b17 | ||
|
|
ca826a387b | ||
|
|
17cbaaa024 | ||
|
|
9aa8e84dbc | ||
|
|
f69c96e807 | ||
|
|
6ec7153c6f | ||
|
|
98bf2de51f | ||
|
|
34cb0c83d9 | ||
|
|
0b76807706 | ||
|
|
e6676044a9 | ||
|
|
4d98abcb39 | ||
|
|
463541a08c | ||
|
|
be835b7ed5 | ||
|
|
db0e6ebb63 | ||
|
|
5f3f3caf3b | ||
|
|
3cb4828eb4 | ||
|
|
418747e6c6 | ||
|
|
57fab874bc | ||
|
|
c000d1a57d | ||
|
|
b6e5b92bdc | ||
|
|
11819d223a | ||
|
|
bb68879566 | ||
|
|
4acee84b14 | ||
|
|
8c261458ba | ||
|
|
eb634285eb | ||
|
|
5de3afe2ae | ||
|
|
12a9396b8f | ||
|
|
40a37e001f | ||
|
|
8e9b0f7844 | ||
|
|
ba93dc8927 | ||
|
|
4935d84842 | ||
|
|
0b207c899a | ||
|
|
99dcdcd5b3 | ||
|
|
ed330f70a7 | ||
|
|
e06239f8a0 | ||
|
|
fcdf2b8fc6 | ||
|
|
dd6e44d6c6 | ||
|
|
6d838a9acc | ||
|
|
629ea3a516 | ||
|
|
980168eb9e | ||
|
|
df88a88550 | ||
|
|
ccfb09eab4 | ||
|
|
582637942e | ||
|
|
742e12201c | ||
|
|
ff015d04bf | ||
|
|
9b59f53f1b | ||
|
|
f2c1181a1d | ||
|
|
2b42935d2d | ||
|
|
8deea058af | ||
|
|
a53e46568e | ||
|
|
cf4ae69ae4 | ||
|
|
6a3ddb865c | ||
|
|
cec8dc0c0b | ||
|
|
7fa14c10f8 | ||
|
|
96a9770fbf | ||
|
|
4b1e416a18 | ||
|
|
258996ea17 | ||
|
|
5c542f8a91 | ||
|
|
ab69044d79 | ||
|
|
2eded61803 | ||
|
|
3fe589811e | ||
|
|
20d5884722 | ||
|
|
8a924487ce | ||
|
|
cf77473e77 | ||
|
|
9383a8bb73 | ||
|
|
21de703da9 | ||
|
|
b58dd5f25c | ||
|
|
dc10aeb14c | ||
|
|
4b38d6dd96 | ||
|
|
deaa41565d | ||
|
|
5359eae68d | ||
|
|
0717fc0703 | ||
|
|
508cce0b36 | ||
|
|
d2fed921e4 | ||
|
|
514407abf8 | ||
|
|
af9f5b15d7 | ||
|
|
ea9b61d473 | ||
|
|
7171bf198e | ||
|
|
14c22af2ef | ||
|
|
2f05de3258 | ||
|
|
a6d69cbff5 | ||
|
|
c0b3decaf7 | ||
|
|
1258c0c55c | ||
|
|
3a0cb6f0ef | ||
|
|
33e8fb7a39 | ||
|
|
39c78791c2 | ||
|
|
54e0469a02 | ||
|
|
9c9e120d8b | ||
|
|
2466aab3a4 | ||
|
|
0ac0d8befa | ||
|
|
e8b46a8b56 | ||
|
|
73ee57b6bc | ||
|
|
4b60f985ac | ||
|
|
b975714c97 | ||
|
|
f7c22f4d6e | ||
|
|
f8cfe9c23a | ||
|
|
e818ffa03a | ||
|
|
571a51f09f | ||
|
|
64deef0c27 | ||
|
|
0e7746184c | ||
|
|
628a93e6df | ||
|
|
e4091d6b45 | ||
|
|
f57dce2259 | ||
|
|
853912f25b | ||
|
|
a55198464c | ||
|
|
b8e39aa29a | ||
|
|
94d0e54e8b | ||
|
|
ed90d13846 | ||
|
|
7731a4c699 | ||
|
|
486613febd | ||
|
|
d7e26d22d0 | ||
|
|
e069c315a5 | ||
|
|
f4ac2b3806 | ||
|
|
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 | ||
|
|
4f4868a32b | ||
|
|
d45bddbeab | ||
|
|
2abda1de50 | ||
|
|
333944d377 | ||
|
|
50c301fe82 | ||
|
|
35ea22e125 | ||
|
|
c3c7b5b479 | ||
|
|
b6494f88d0 | ||
|
|
87bc8b24a9 | ||
|
|
e08d99c801 | ||
|
|
b5f7a8bd86 | ||
|
|
7d7d71b6be | ||
|
|
c1bacd11cc | ||
|
|
110994ea46 | ||
|
|
11bde27811 | ||
|
|
0f4ef4a3cd | ||
|
|
a3bb84e169 | ||
|
|
915d5c097c | ||
|
|
523f6aee3e | ||
|
|
e874454e40 | ||
|
|
45289702d5 | ||
|
|
1ab6366fd1 | ||
|
|
a01b06b8bc | ||
|
|
11dfa4c6f1 | ||
|
|
b94e4fc25d | ||
|
|
c1bb44bd55 | ||
|
|
1d8f92c663 | ||
|
|
b5ee1f2190 | ||
|
|
c5649e9d39 | ||
|
|
2a979af974 | ||
|
|
06964526dc | ||
|
|
b2b471dab5 | ||
|
|
15b9551f7a | ||
|
|
c6f4e6066b | ||
|
|
7ebb60e564 | ||
|
|
b641cf9ca6 | ||
|
|
686697f202 | ||
|
|
296299e635 | ||
|
|
c8b8b033ed | ||
|
|
f64f34b971 | ||
|
|
9b4f689dd3 | ||
|
|
cd32860300 | ||
|
|
7ed58eb38a | ||
|
|
291082e6c3 | ||
|
|
54f363be6e | ||
|
|
539ed1e3b8 | ||
|
|
9d61b50db5 | ||
|
|
8ede9de315 | ||
|
|
1cb43cee6b | ||
|
|
b2617c8933 | ||
|
|
beb1130050 | ||
|
|
1c75204854 | ||
|
|
80bba6e0fc | ||
|
|
47b0dabe75 | ||
|
|
da45c8c150 | ||
|
|
8fd7383217 | ||
|
|
baa6d87c2c | ||
|
|
5ae4c8c7a0 | ||
|
|
cbb7e82d23 | ||
|
|
2768e95920 | ||
|
|
5b7d641ad1 | ||
|
|
37a71d7580 | ||
|
|
aaa7b5de27 | ||
|
|
40bcbc59dc | ||
|
|
5e46ce0cb0 | ||
|
|
517c08091e | ||
|
|
e25dfb2a36 | ||
|
|
aa4e0abb44 | ||
|
|
3e5dc9a9f0 | ||
|
|
afd9541151 | ||
|
|
a5ab3b972c | ||
|
|
4f3350e4f9 | ||
|
|
2b8e1ed98f | ||
|
|
3f311dee69 | ||
|
|
eb6b683296 | ||
|
|
b2153ec3dd | ||
|
|
8949e7baf0 |
@@ -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'
|
||||
|
||||
@@ -16,17 +15,22 @@ jobs:
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
test_api:
|
||||
name: 'Test backend api'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
needs:
|
||||
- lint_packages
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -50,12 +54,15 @@ jobs:
|
||||
run: npm run oodikone:ci:api
|
||||
|
||||
test_end_to_end:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
needs:
|
||||
- lint_packages
|
||||
- test_api
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- name: 'Test course statistics'
|
||||
@@ -78,15 +85,13 @@ jobs:
|
||||
cypress/e2e/Users.js
|
||||
- name: 'Test faculty statistics'
|
||||
spec: cypress/e2e/Faculty_statistics.js
|
||||
- name: 'Test open uni search'
|
||||
spec: cypress/e2e/Openuni.js
|
||||
- name: 'Test language center view'
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Run tests'
|
||||
|
||||
@@ -11,9 +11,9 @@ on:
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
- 'services/**'
|
||||
- '!services/backend/src/rapodiff/**'
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'docker-compose.test.yml'
|
||||
- 'package*.json'
|
||||
|
||||
jobs:
|
||||
@@ -92,12 +92,11 @@ jobs:
|
||||
report_success:
|
||||
name: 'Report deployment success to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
@@ -111,12 +110,11 @@ jobs:
|
||||
report_failure:
|
||||
name: 'Report deployment failure to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
if: failure()
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
|
||||
+324
-146
@@ -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,138 +53,36 @@ 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
|
||||
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
kone_db_migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
user_db_migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
sis_db_migrations:
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
# - name: 'Upload image for other jobs'
|
||||
# uses: actions/upload-artifact@v7
|
||||
# with:
|
||||
# if-no-files-found: error
|
||||
# name: frontend-image
|
||||
# path: /tmp/frontend.tar
|
||||
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
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: '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
|
||||
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:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@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 kone-db
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
needs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: check_migrations
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@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'
|
||||
needs:
|
||||
- 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'
|
||||
pull_docker_images:
|
||||
name: 'Pull all needed docker images and cache them'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -193,16 +93,8 @@ jobs:
|
||||
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: '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: |
|
||||
@@ -223,23 +115,171 @@ jobs:
|
||||
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
|
||||
# === Update databases ===
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
kone_db_migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
user_db_migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
sis_db_migrations:
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- check_migration_files
|
||||
|
||||
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: '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 --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'
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
|
||||
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
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: check_migrations
|
||||
with:
|
||||
filters: |
|
||||
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
|
||||
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
name: 'Run backend api tests'
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
needs:
|
||||
- test_service_exit_statuses
|
||||
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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- 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,17 +301,90 @@ 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
|
||||
|
||||
# === Frontend unit testing ===
|
||||
test_unit_frontend:
|
||||
name: 'Run frontend unit tests'
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./services/frontend
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
working-directory: ./services/frontend
|
||||
|
||||
# === Frontend component testing ===
|
||||
test_components:
|
||||
name: 'Test frontend components'
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefix shared && npm ci --prefix frontend
|
||||
working-directory: ./services
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npm exec -- playwright install --with-deps
|
||||
working-directory: ./services/frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm run test:component
|
||||
working-directory: ./services/frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -296,17 +409,13 @@ jobs:
|
||||
cypress/e2e/Users.js
|
||||
- name: 'Test faculty statistics'
|
||||
spec: cypress/e2e/Faculty_statistics.js
|
||||
- name: 'Test open uni search'
|
||||
spec: cypress/e2e/Openuni.js
|
||||
- name: 'Test language center view'
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -321,9 +430,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 +453,60 @@ jobs:
|
||||
password: ${{ secrets.password }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
|
||||
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'
|
||||
|
||||
@@ -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`
|
||||
@@ -149,8 +149,8 @@ It is recommended to spend some time to become familiar with `docker` and `docke
|
||||
- `docker compose ps`: view the containers in the running environment
|
||||
- `docker compose logs frontend`: print logs for just frontend
|
||||
- `docker compose logs --follow --tail 100 backend`: print last hundred rows of backend logs and begin to follow them in your terminal window
|
||||
- `docker exec -it backend sh`: open bash terminal inside backend container
|
||||
- `docker exec -it sis-db psql -U postgres sis-db-real`: open psql client to investigate sis real data database
|
||||
- `docker exec -it oodikone-backend sh`: open bash terminal inside backend container
|
||||
- `docker exec -it oodikone-sis-db psql -U postgres sis-db-real`: open psql client to investigate sis real data database
|
||||
|
||||
### User types for development
|
||||
|
||||
@@ -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
|
||||
@@ -240,6 +238,20 @@ npm run cypress:run
|
||||
|
||||
Remember to revert the `baseUrl` in [cypress.config.js](./cypress.config.js) to its original value when you're done.
|
||||
|
||||
## How to read clients minds
|
||||
|
||||
Search for client queries in grafana using a variation of this query:
|
||||
|
||||
```
|
||||
{app="oodikone"} | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
|
||||
```
|
||||
|
||||
For quick access to _any and all params_ under certain path:
|
||||
|
||||
```
|
||||
{app="oodikone"} |= `path` | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
|
||||
```
|
||||
|
||||
## ✌🏼 Maintainers and contribution
|
||||
|
||||
[Toska](https://toska.dev) - University of Helsinki
|
||||
|
||||
@@ -43,6 +43,6 @@ fi
|
||||
|
||||
infomsg "Committing and pushing to registry"
|
||||
database=${args[0]}
|
||||
docker commit "$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
|
||||
docker commit "oodikone-$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
|
||||
docker push "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
|
||||
successmsg "Succesfully pushed"
|
||||
|
||||
@@ -52,13 +52,13 @@ fi
|
||||
|
||||
|
||||
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
|
||||
database=${args[0]}
|
||||
docker-compose down --remove-orphans
|
||||
docker-compose up -d "$database"
|
||||
database="${args[0]}"
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d "$database"
|
||||
|
||||
database_dump="$database.sqz"
|
||||
infomsg "Attempting to restore database $database from dump $database_dump"
|
||||
retry docker-compose exec "$database" pg_isready --dbname="$database"
|
||||
docker exec -i "$database" /bin/bash -c "pg_restore --username=postgres \
|
||||
retry docker compose exec "$database" pg_isready --dbname="$database"
|
||||
docker exec -i "oodikone-$database" /bin/bash -c "pg_restore --username=postgres \
|
||||
--format=custom --dbname=$database --no-owner" < "$database_dump"
|
||||
successmsg "Database $database succesfully created"
|
||||
|
||||
@@ -4,7 +4,7 @@ x-defaults: &defaults
|
||||
services:
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
container_name: oodikone-kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
container_name: oodikone-sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: sis-importer-db
|
||||
container_name: oodikone-sis-importer-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
container_name: oodikone-user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
|
||||
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 "oodikone-$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
|
||||
@@ -18,11 +18,11 @@ source "$PROJECT_ROOT"/scripts/functions_for_setup.sh
|
||||
# Try to run cleanup function if things fail.
|
||||
trap cleanup SIGINT SIGTERM ERR EXIT
|
||||
|
||||
# Run docker-compose down on cleanup
|
||||
# Run docker compose down on cleanup
|
||||
cleanup() {
|
||||
trap - SIGINT SIGTERM ERR EXIT
|
||||
warningmsg "Trying to run docker-compose down and remove orphans"
|
||||
docker-compose --profile oodikone --profile updater down --remove-orphans
|
||||
warningmsg "Trying to run docker compose down and remove orphans"
|
||||
docker compose --profile oodikone --profile updater down --remove-orphans
|
||||
}
|
||||
|
||||
# === CLI ===
|
||||
|
||||
+302
-278
@@ -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', () => {
|
||||
@@ -516,7 +537,8 @@ describe('Basic user', () => {
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
})
|
||||
|
||||
describe('Students tab', () => {
|
||||
// TODO: Writing these as backend api tests
|
||||
describe.skip('Students tab', () => {
|
||||
describe('Info boxes', () => {
|
||||
it('Student statistics table', () => {
|
||||
cy.cs('StudentStatistics-info-box-button').click()
|
||||
@@ -538,14 +560,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 +576,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 +600,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 +617,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()
|
||||
@@ -618,7 +640,8 @@ describe('Basic user', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attempts tab', () => {
|
||||
// TODO: Writing these as backend api tests
|
||||
describe.skip('Attempts tab', () => {
|
||||
beforeEach(() => {
|
||||
openAttemptsTab()
|
||||
})
|
||||
@@ -644,15 +667,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 +683,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 +707,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 +724,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 +755,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', () => {
|
||||
@@ -753,22 +776,23 @@ describe('Basic user', () => {
|
||||
cy.contains('Show population').should('be.enabled')
|
||||
})
|
||||
|
||||
describe('Students tab', () => {
|
||||
// TODO: Writing these as backend api tests
|
||||
describe.skip('Students tab', () => {
|
||||
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 +814,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 +872,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 +906,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 %'],
|
||||
@@ -939,7 +963,8 @@ describe('Basic user', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attempts tab', () => {
|
||||
// TODO: Writing these as backend api tests
|
||||
describe.skip('Attempts tab', () => {
|
||||
beforeEach(() => {
|
||||
openAttemptsTab()
|
||||
})
|
||||
@@ -947,18 +972,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 +1005,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 +1063,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 +1097,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 +1181,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,14 +1205,13 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Only course statistics', () => {
|
||||
// TODO: Writing these as backend api tests
|
||||
describe.skip('Only course statistics', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/coursestatistics', 'onlycoursestatistics')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
@@ -1213,15 +1237,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'),
|
||||
|
||||
@@ -32,36 +32,21 @@ const deleteTag = tagName => {
|
||||
|
||||
describe('Degree programme overview', () => {
|
||||
describe('Degree programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
|
||||
it('Correct categories', () => {
|
||||
cy.contains('Bachelor programmes')
|
||||
cy.contains('Master programmes')
|
||||
cy.contains('Combined programmes')
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Degree programme search filter', () => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
|
||||
})
|
||||
|
||||
it('Old and specialized programmes filter', () => {
|
||||
cy.contains('Biotekniikka (ylempi)').should('not.exist')
|
||||
cy.cs('filter-old-programmes-toggle').click()
|
||||
cy.contains('Biotekniikka (ylempi)').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
@@ -207,19 +192,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')
|
||||
@@ -234,34 +220,39 @@ describe('Degree programme overview', () => {
|
||||
it('are split into two graphs', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
cy.cs('year-toggle').click() // Tests are written for calendar year
|
||||
|
||||
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 +260,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 +346,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 +362,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 +396,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 +429,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 +467,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 +484,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 +492,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')
|
||||
@@ -513,6 +516,8 @@ describe('Degree programme overview', () => {
|
||||
|
||||
describe('By credit type tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
cy.cs('year-toggle').click()
|
||||
cy.get('thead').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
@@ -527,7 +532,7 @@ describe('Degree programme overview', () => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11004')
|
||||
cy.get('td').eq(2).contains('Differentiaalilaskenta')
|
||||
cy.get('td').eq(3).contains('1170')
|
||||
cy.get('td').eq(3).contains('1240')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -551,7 +556,7 @@ describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
it('year toggle works', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('year-toggle').click() // Default view now Academic years
|
||||
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2017').click()
|
||||
@@ -565,7 +570,7 @@ describe('Degree programme overview', () => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('1220')
|
||||
cy.get('td').eq(3).contains('1265')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -624,6 +629,8 @@ describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.cs('year-toggle').click()
|
||||
cy.wait(500) // So that the toggle has time to load
|
||||
cy.get('thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
@@ -698,7 +705,7 @@ describe('Degree programme overview', () => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('375')
|
||||
cy.get('td').eq(3).contains('277')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -825,7 +832,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()
|
||||
|
||||
@@ -110,15 +110,15 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
|
||||
@@ -184,7 +184,7 @@ describe.skip('Population Statistics', { testIsolation: false }, () => {
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
|
||||
checkFilteredStudentCount(4)
|
||||
|
||||
@@ -453,9 +453,9 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
|
||||
checkFilteredStudentCount(2)
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.cs('open-uni-search-button').click()
|
||||
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
|
||||
|
||||
cy.get('@search').then($el => {
|
||||
const searchItems = $el.find('div[role=option] > span[class=text]')
|
||||
for (let i = searchItems.length - 1; 0 <= i; i--) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
|
||||
// removed alltogether.
|
||||
describe.skip('Open uni population tests', () => {
|
||||
describe('Open uni form can be used', () => {
|
||||
// When openUniUser is created, change it here
|
||||
beforeEach(() => {
|
||||
cy.init('/openunipopulation', 'admin')
|
||||
})
|
||||
after(() => {
|
||||
cy.init('/openunipopulation', 'admin')
|
||||
cy.contains('Open uni student population')
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
// TODO: Rewrite the whole component
|
||||
it.skip('Finds a proper population', () => {
|
||||
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
|
||||
cy.get('@textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.cs('begin-of-search').click()
|
||||
cy.cs('begin-of-search').clear()
|
||||
cy.cs('begin-of-search').type('01.01.2015')
|
||||
cy.cs('end-of-search').click()
|
||||
cy.cs('end-of-search').clear()
|
||||
cy.cs('end-of-search').type('01.01.2020')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.contains('Beginning of the search for all fields:')
|
||||
cy.contains('01.01.2015')
|
||||
cy.contains('End of the search for enrollments:')
|
||||
cy.contains('01.01.2020')
|
||||
cy.location('pathname').should('eq', '/openunipopulation')
|
||||
cy.location('search').then(search => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const courseCodes = params.getAll('courseCode')
|
||||
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
|
||||
|
||||
expect(params.get('startdate')).to.eq('01-01-2015')
|
||||
expect(params.get('enddate')).to.eq('01-01-2020')
|
||||
})
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
})
|
||||
|
||||
it('Textarea can handle different kinds of input', () => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.contains('Insert course code(s)')
|
||||
.siblings()
|
||||
.get('textarea')
|
||||
.type('tkt10001 tkt10002,tkt10003\n TKT10004,,TKt10005\nTKT-1234')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10001')
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10002')
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10003')
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10004')
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10005')
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'TKT-1234')
|
||||
})
|
||||
|
||||
it('Saves a custom population search', () => {
|
||||
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="save-search"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('TEST-Avoimen-populaatio')
|
||||
cy.get('button').contains('Search population').as('btn')
|
||||
cy.get('@btn').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
})
|
||||
|
||||
it('Updates a custom population search', () => {
|
||||
cy.fixture('openUniPopulation').then(({ courseCodesSet1, courseCodesSet2 }) => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="save-search"]').click()
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
|
||||
cy.get('[data-cy="save-search"]').click()
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[1])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[2])
|
||||
})
|
||||
})
|
||||
|
||||
it('View does not crash with non-existing courses', () => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type('cypress-fun')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'cypress-fun')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
|
||||
+17
-11
@@ -58,17 +58,6 @@ const onlyStudyGuidanceGroupsUser = {
|
||||
hypersonsisuid: 'hy-hlo-2222222',
|
||||
}
|
||||
|
||||
// TODO add user into anon-DB (check the need for changing hypersonid)
|
||||
// REMEMBER TO ADD ROLE openUniSearch
|
||||
// const openUniUser = {
|
||||
// uid: 'openuniuser',
|
||||
// displayname: 'Open Uni User',
|
||||
// 'shib-session-id': 'mock-cypress-session',
|
||||
// hygroupcn: 'grp-oodikone-basics-users;hy-ypa-opa-dojo',
|
||||
// mail: 'grp-toska+mockopenuniuser@helsinki.fi',
|
||||
// hypersonsisuid: 'hy-hlo-9999999',
|
||||
// }
|
||||
|
||||
const userHeaders = [
|
||||
adminUserHeaders,
|
||||
basicUserHeaders,
|
||||
@@ -190,3 +179,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,
|
||||
})
|
||||
})
|
||||
|
||||
+16
-12
@@ -1,10 +1,12 @@
|
||||
name: oodikone
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
backend:
|
||||
<<: *defaults
|
||||
container_name: backend
|
||||
container_name: oodikone-backend
|
||||
depends_on:
|
||||
- kone-db
|
||||
- redis
|
||||
@@ -25,21 +27,23 @@ services:
|
||||
build:
|
||||
context: services
|
||||
dockerfile: backend/Dockerfile
|
||||
image: oodikone-backend:staging
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
container_name: frontend
|
||||
container_name: oodikone-frontend
|
||||
build:
|
||||
context: services
|
||||
dockerfile: frontend/Dockerfile
|
||||
image: oodikone-frontend:staging
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
container_name: oodikone-kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
@@ -48,16 +52,16 @@ services:
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
container_name: oodikone-redis
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
image: redis:7.2.6
|
||||
container_name: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
container_name: oodikone-sis-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
@@ -67,7 +71,7 @@ services:
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
container_name: oodikone-user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
@@ -76,6 +80,7 @@ services:
|
||||
|
||||
jami:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami
|
||||
image: quay.io/toska/jami:production
|
||||
environment:
|
||||
- npm_config_cache=/tmp
|
||||
@@ -84,21 +89,20 @@ services:
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
container_name: jami-db
|
||||
ports:
|
||||
- 5433:5432
|
||||
|
||||
nginx:
|
||||
container_name: oodikone-nginx
|
||||
image: nginx:latest
|
||||
container_name: nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
@@ -112,16 +116,16 @@ services:
|
||||
# Following services are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: sis-importer-db
|
||||
container_name: oodikone-sis-importer-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
container_name: oodikone-updater-worker
|
||||
build:
|
||||
context: updater/sis-updater-worker
|
||||
container_name: sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
@@ -140,9 +144,9 @@ services:
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
container_name: oodikone-updater-scheduler
|
||||
build:
|
||||
context: updater/sis-updater-scheduler
|
||||
container_name: sis-updater-scheduler
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-importer-db
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: oodikone
|
||||
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
@@ -20,3 +22,4 @@ services:
|
||||
|
||||
volumes:
|
||||
redis-data-real:
|
||||
name: oodikone-redis-data-real
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
services:
|
||||
api-tests:
|
||||
container_name: oodikone-api-tests
|
||||
build:
|
||||
context: services
|
||||
dockerfile: backend/dev.Dockerfile
|
||||
volumes:
|
||||
- backend-tests-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- backend-tests-nodemod:/opt/app-root/backend/node_modules
|
||||
- shared-tests-nodemod:/opt/app-root/shared/node_modules
|
||||
environment:
|
||||
CI: 'true'
|
||||
NODE_ENV: 'test'
|
||||
@@ -17,10 +22,10 @@ services:
|
||||
JAMI_URL: 'http://jami:3003/'
|
||||
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
|
||||
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
|
||||
build:
|
||||
context: services/backend
|
||||
dockerfile: dev.Dockerfile
|
||||
command: npm run test
|
||||
|
||||
volumes:
|
||||
backend-tests-nodemod:
|
||||
name: oodikone-backend-tests-nodemod
|
||||
shared-tests-nodemod:
|
||||
name: oodikone-shared-tests-nodemod
|
||||
|
||||
+34
-18
@@ -1,3 +1,5 @@
|
||||
name: oodikone
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -7,7 +9,7 @@ x-build_defaults: &build_defaults
|
||||
services:
|
||||
adminer:
|
||||
<<: *defaults
|
||||
container_name: adminer
|
||||
container_name: oodikone-adminer
|
||||
environment:
|
||||
ADMINER_DESIGN: pepa-linha
|
||||
image: adminer:4.8.1
|
||||
@@ -18,10 +20,10 @@ services:
|
||||
|
||||
backend:
|
||||
<<: *defaults
|
||||
container_name: oodikone-backend
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: services/backend
|
||||
container_name: backend
|
||||
dockerfile: backend/dev.Dockerfile
|
||||
context: services
|
||||
depends_on:
|
||||
- kone-db
|
||||
- redis
|
||||
@@ -45,28 +47,30 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- shared-nodemod:/opt/app-root/shared/node_modules
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
container_name: oodikone-frontend
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: services/frontend
|
||||
container_name: frontend
|
||||
dockerfile: frontend/dev.Dockerfile
|
||||
context: services
|
||||
ports:
|
||||
- 3000:3000
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- shared-nodemod:/opt/app-root/shared/node_modules
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
container_name: oodikone-kone-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
|
||||
profiles:
|
||||
- oodikone
|
||||
@@ -75,8 +79,8 @@ services:
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
container_name: oodikone-redis
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- '6379:6379'
|
||||
@@ -85,7 +89,7 @@ services:
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
container_name: oodikone-sis-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
@@ -95,7 +99,7 @@ services:
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: sis-importer-db
|
||||
container_name: oodikone-sis-importer-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
|
||||
profiles:
|
||||
- updater
|
||||
@@ -106,10 +110,10 @@ services:
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
container_name: oodikone-updater-scheduler
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-scheduler
|
||||
container_name: sis-updater-scheduler
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-importer-db
|
||||
@@ -130,10 +134,10 @@ services:
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
container_name: oodikone-updater-worker
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-worker
|
||||
container_name: sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
@@ -154,7 +158,7 @@ services:
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
container_name: oodikone-user-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
|
||||
profiles:
|
||||
- oodikone
|
||||
@@ -163,6 +167,7 @@ services:
|
||||
|
||||
jami:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami
|
||||
image: quay.io/toska/jami:production
|
||||
env_file:
|
||||
- .env
|
||||
@@ -173,15 +178,14 @@ services:
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
container_name: jami-db
|
||||
ports:
|
||||
- 5433:5432
|
||||
volumes:
|
||||
@@ -189,12 +193,24 @@ services:
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
name: oodikone-backend-nodemod
|
||||
frontend-nodemod:
|
||||
name: oodikone-frontend-nodemod
|
||||
shared-nodemod:
|
||||
name: oodikone-shared-nodemod
|
||||
kone-db-data:
|
||||
name: oodikone-kone-db-data
|
||||
redis-data:
|
||||
name: oodikone-redis-data
|
||||
sis-db-data:
|
||||
name: oodikone-sis-db-data
|
||||
sis-importer-db-data:
|
||||
name: oodikone-sis-importer-db-data
|
||||
sis-updater-scheduler-nodemod:
|
||||
name: oodikone-updater-scheduler-nodemod
|
||||
sis-updater-worker-nodemod:
|
||||
name: oodikone-updater-worker-nodemod
|
||||
user-db-data:
|
||||
name: oodikone-user-db-data
|
||||
jami-data:
|
||||
name: oodikone-jami-data
|
||||
|
||||
@@ -8,11 +8,10 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
|
||||
- courseStatistics
|
||||
- facultyStatistics
|
||||
- fullSisuAccess
|
||||
- openUniSearch
|
||||
- studyGuidanceGroups
|
||||
- teachers
|
||||
|
||||
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
|
||||
The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain views (_Faculties_ and _Guidance groups_ respectively).
|
||||
|
||||
### courseStatistics
|
||||
|
||||
@@ -43,7 +42,6 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
|
||||
| Custom population | | ✅ | ✅ |
|
||||
| Completed courses | ✅ | ✅ | ✅ |
|
||||
| Close to graduation | | ✅ | ✅ |
|
||||
| Open uni population | | ✅ | |
|
||||
| Courses | | ✅ | ✅ |
|
||||
|
||||
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
|
||||
@@ -63,7 +61,6 @@ A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikon
|
||||
| grp-oodikone-basic-users | courseStatistics |
|
||||
| grp-oodikone-users | facultyStatistics |
|
||||
| grp-toska | admin |
|
||||
| hy-ypa-opa-dojo | openUniSearch |
|
||||
|
||||
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,7 +8,7 @@ These instructions explain how to update the schemas for the following databases
|
||||
|
||||
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
|
||||
|
||||
## General guidelines
|
||||
@@ -25,7 +25,7 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
|
||||
- Use **snake_case** in the actual database columns.
|
||||
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [!IMPORTANT]
|
||||
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
### Verifying the migration locally
|
||||
@@ -73,7 +73,7 @@ If you need to **revert a migration locally**, follow these steps:
|
||||
|
||||
## `sis-db`
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
|
||||
|
||||
1. **Update the updater model:**
|
||||
|
||||
+68
-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,17 @@ export default tseslint.config(
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
|
||||
// Frontend tests
|
||||
{
|
||||
files: ['services/frontend/tests/**/*.{ts,tsx}', "services/frontend/playwright/**/*.{ts,tsx}", 'services/frontend/playwright-ct.config.ts'],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'import-x/no-default-export': 'off',
|
||||
'react/jsx-filename-extension': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
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
+13
-16
@@ -6,15 +6,14 @@
|
||||
},
|
||||
"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",
|
||||
"prettier": "prettier",
|
||||
"cli": "./cli.sh",
|
||||
"oodikone": "./run.sh oodikone anon up --detach",
|
||||
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami -f",
|
||||
"oodikone": "npm run docker:down && ./run.sh oodikone anon up --detach",
|
||||
"oodikone:real": "./run.sh oodikone real up --detach",
|
||||
"oodikone:ci:setup": "docker compose --file docker-compose.ci.yml --profile tests up --detach",
|
||||
"oodikone:ci:api": "docker compose --file docker-compose.test.yml up --exit-code-from api-tests",
|
||||
@@ -23,16 +22,14 @@
|
||||
"both": "./run.sh both anon up --detach",
|
||||
"both:real": "./run.sh both real up --detach",
|
||||
"docker:down": "./run.sh both anon down --remove-orphans; ./run.sh both real down --remove-orphans",
|
||||
"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",
|
||||
"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 oodikone-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:docker": "docker exec oodikone-frontend npm i && docker exec oodikone-backend npm i && docker exec oodikone-updater-scheduler npm i && docker exec oodikone-updater-worker npm i",
|
||||
"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 oodikone-frontend npm ci && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
@@ -46,21 +43,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ Usage: $(basename "${BASH_SOURCE[0]}") option [version] command --flag
|
||||
Parameters:
|
||||
* Option: oodikone/updater/both
|
||||
* Version: anon/real. Not necessary when running down/logs/pull.
|
||||
* Command: will be passed to docker-compose.
|
||||
* Command: will be passed to docker compose.
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
|
||||
## Following the naming convention in docker-compose, these are names for database
|
||||
## Following the naming convention in docker compose, these are names for database
|
||||
## services. The real data databases inside service will have names suffixed by "-real".
|
||||
KONE_DB_NAME="kone-db"
|
||||
SIS_DB_NAME="sis-db"
|
||||
@@ -112,11 +112,11 @@ reset_databases() {
|
||||
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
|
||||
|
||||
npm run docker:down
|
||||
docker-compose up -d "${databases[@]}"
|
||||
docker compose up -d "${databases[@]}"
|
||||
|
||||
for database in "${databases[@]}"; do
|
||||
local database_dump="$database_dump_dir/$database.sql.gz"
|
||||
local database_container="$database"
|
||||
local database_container="oodikone-$database"
|
||||
local database_name="$database-real"
|
||||
|
||||
infomsg "Attempting to create database $database_name from dump $database_dump inside container $database_container"
|
||||
@@ -215,6 +215,7 @@ set_up_oodikone_from_scratch() {
|
||||
"$PROJECT_ROOT"
|
||||
"$PROJECT_ROOT/services/frontend"
|
||||
"$PROJECT_ROOT/services/backend"
|
||||
"$PROJECT_ROOT/services/shared"
|
||||
"$PROJECT_ROOT/updater/sis-updater-scheduler"
|
||||
"$PROJECT_ROOT/updater/sis-updater-worker"
|
||||
)
|
||||
@@ -223,7 +224,7 @@ set_up_oodikone_from_scratch() {
|
||||
"$PROJECT_ROOT"/run.sh both down --remove-orphans --volumes --rmi all || infomsg "Cleaning errored, but will continue"
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
rm -rf node_modules|| die "Couldn't remove node_modules in folder $folder"
|
||||
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
|
||||
done
|
||||
|
||||
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ignore-scripts=true
|
||||
min-release-age=7
|
||||
save-prefix=
|
||||
@@ -50,9 +50,13 @@ ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY --from=build /opt/app-root/shared/dist .
|
||||
COPY --from=build /opt/app-root/shared/.npmrc .
|
||||
COPY --from=build /opt/app-root/shared/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY --from=build /opt/app-root/backend/package*.json .
|
||||
COPY --from=build /opt/app-root/backend/.npmrc .
|
||||
COPY --from=build /opt/app-root/backend/package*.json ./
|
||||
COPY --from=build /opt/app-root/backend/dist ./dist
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
@@ -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
|
||||
|
||||
USER 1001
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY --chown=1001:1001 ./shared .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY . .
|
||||
COPY --chown=1001:1001 ./backend .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
Generated
+404
-390
File diff suppressed because it is too large
Load Diff
@@ -3,52 +3,44 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
|
||||
"test": "node --import=tsx --test --test-force-exit --test-reporter spec tests/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-gelf-transporter": "^1.0.2",
|
||||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"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",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
"gelf-pro": "^1.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -5,7 +5,6 @@ export const roles = [
|
||||
'courseStatistics',
|
||||
'facultyStatistics',
|
||||
'fullSisuAccess',
|
||||
'openUniSearch',
|
||||
'studyGuidanceGroups',
|
||||
'teachers',
|
||||
] as const
|
||||
|
||||
@@ -13,7 +13,7 @@ import { updateBasicView, updateStudyTrackView } from './services/studyProgramme
|
||||
import { findAndSaveTeachers } from './services/teachers/top'
|
||||
import { deleteOutdatedUsers } from './services/userService'
|
||||
import logger from './util/logger'
|
||||
import { jobMaker, addToFlow } from './worker/queue'
|
||||
import { jobQueue } from './worker/queue'
|
||||
|
||||
const schedule = (cronTime: string, onTick: () => void) => {
|
||||
const onComplete = null
|
||||
@@ -41,7 +41,7 @@ export const refreshFaculties = async () => {
|
||||
logger.info('Adding jobs to refresh all faculties')
|
||||
const faculties = await getFaculties()
|
||||
for (const faculty of faculties) {
|
||||
jobMaker.faculty(faculty.code)
|
||||
jobQueue.refreshFaculty(faculty.code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,13 @@ export const refreshLanguageCenterData = async () => {
|
||||
}
|
||||
|
||||
const refreshProgrammesAndFaculties = async () => {
|
||||
logger.info('Refreshing all degree programmes and faculties')
|
||||
const facultyCodes = (await getFaculties()).map(faculty => faculty.code)
|
||||
for (const faculty of facultyCodes) {
|
||||
const programmeCodes = (await getDegreeProgrammesOfFaculty(faculty, true))
|
||||
.map(programme => programme.code)
|
||||
.filter(code => isRelevantProgramme(code))
|
||||
await addToFlow(faculty, programmeCodes)
|
||||
await jobQueue.addToFlow(faculty, programmeCodes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ export const refreshProgrammes = async () => {
|
||||
}
|
||||
for (const code of programmeCodes) {
|
||||
// If combined programme is given, this updates only the bachelor programme
|
||||
jobMaker.programme(code)
|
||||
jobQueue.refreshProgramme(code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +97,12 @@ const dailyJobs = async () => {
|
||||
try {
|
||||
await refreshTeacherLeaderboard()
|
||||
await refreshProgrammesAndFaculties()
|
||||
if (languageCenterViewEnabled) jobMaker.languagecenter()
|
||||
jobMaker.closeToGraduation()
|
||||
|
||||
if (languageCenterViewEnabled) {
|
||||
jobQueue.refreshLanguagecenter()
|
||||
}
|
||||
|
||||
jobQueue.refreshCloseToGraduation()
|
||||
} catch (error) {
|
||||
logger.error('Daily jobs failed', error)
|
||||
}
|
||||
@@ -118,11 +123,11 @@ export const startCron = () => {
|
||||
})
|
||||
schedule('0 19 * * 1', () => {
|
||||
logger.info('Updating students whose studyplans have not been updated recently')
|
||||
jobMaker.studyplansUpdate(4)
|
||||
jobQueue.refreshStudyplans(4)
|
||||
})
|
||||
schedule('0 10 * * 2', () => {
|
||||
logger.info('Updating students whose studyplans have not been updated recently')
|
||||
jobMaker.studyplansUpdate(5)
|
||||
jobQueue.refreshStudyplans(5)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -38,6 +38,11 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
|
||||
meta['req-route'] = req.route.path
|
||||
}
|
||||
|
||||
// Ignore admin route that is frequently pinged for updates.
|
||||
if (/.*\/updater\/jobs$/.test(meta.url ?? '')) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = [
|
||||
`${user.mockedBy ? '(mocking) ' : ''}${user.name}`,
|
||||
`${(tokens['response-time'](req, res) ?? '0').split('.')[0]} ms`.padEnd(8, ' '),
|
||||
@@ -52,7 +57,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))
|
||||
@@ -15,7 +15,6 @@ import completedCoursesSearch from './routes/completedCoursesSearch'
|
||||
import courseExclusions from './routes/courseExclusions'
|
||||
import courses from './routes/courses'
|
||||
import curriculumPeriods from './routes/curriculumPeriods'
|
||||
import customOpenUniSearch from './routes/customOpenUniSearch'
|
||||
import customPopulationSearch from './routes/customPopulationSearch'
|
||||
import faculties from './routes/faculties'
|
||||
import feedback from './routes/feedback'
|
||||
@@ -42,7 +41,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,10 +51,9 @@ 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)
|
||||
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
|
||||
if (languageCenterViewEnabled) {
|
||||
@@ -72,7 +70,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()
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Router } from 'express'
|
||||
import { difference, intersection } from 'lodash-es'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import type { CompletedCoursesStudent, CompletedCoursesCourse } from '@oodikone/shared/types'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import { getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
deleteSearch,
|
||||
updateSearch,
|
||||
getUserSearches,
|
||||
createNewUserSearch,
|
||||
deleteUserSearch,
|
||||
updateUserSearch,
|
||||
FoundSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
@@ -19,11 +20,14 @@ const router = Router()
|
||||
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
export type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
|
||||
type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & {
|
||||
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
|
||||
courses: CompletedCoursesCourse[]
|
||||
}
|
||||
>
|
||||
export type SearchReqBody = never
|
||||
export type SearchQuery = {
|
||||
type SearchReqBody = never
|
||||
type SearchQuery = {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
@@ -72,7 +76,7 @@ export type GetSearchResBody = FoundSearch[]
|
||||
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const userId = req.user.id
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
const foundSearches = await getUserSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
@@ -98,7 +102,7 @@ router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async
|
||||
if (courseCodes && !Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courselist must be type of array' })
|
||||
}
|
||||
const createdSearch = await createNewSearch(userId, name, courseCodes)
|
||||
const createdSearch = await createNewUserSearch(userId, name, courseCodes)
|
||||
if (!createdSearch) {
|
||||
return res.status(400).json({ error: 'Failed to create courselist' })
|
||||
}
|
||||
@@ -130,7 +134,7 @@ router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searc
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
const updatedSearch = await updateUserSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
@@ -155,7 +159,7 @@ router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (r
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
const deletedSearch = await deleteUserSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
|
||||
@@ -9,15 +9,20 @@ import {
|
||||
CourseYearlyStatsReqBody,
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
|
||||
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))) {
|
||||
@@ -26,7 +31,7 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
|
||||
|
||||
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
|
||||
|
||||
const mergedCourses = new Map<string, CourseWithSubsId>()
|
||||
const mergedCourses = new Map<string, CourseWithSubsDetails>()
|
||||
|
||||
for (const course of courses) {
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
@@ -46,14 +51,16 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
|
||||
}
|
||||
)
|
||||
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
export 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)
|
||||
res.json(results)
|
||||
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
return 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,148 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
deleteSearch,
|
||||
updateSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
|
||||
import { safeJSONParse } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
|
||||
type SearchReqBody = never
|
||||
type SearchQuery = {
|
||||
courselist: string
|
||||
startdate: string
|
||||
enddate: string
|
||||
}
|
||||
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { courselist, startdate, enddate } = req.query
|
||||
|
||||
const courseCodes = (await safeJSONParse(courselist)) ?? []
|
||||
if (!Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
|
||||
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
|
||||
if (enddate === 'null') {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
|
||||
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
|
||||
return res.json(customOpenUniSearches)
|
||||
})
|
||||
|
||||
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const { id: userId } = req.user
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
type CreateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type CreateSearchReqBody = {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
|
||||
const { courselist, name } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
}
|
||||
|
||||
if (courseCodes && !Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Course codes must be type of array' })
|
||||
}
|
||||
|
||||
const createdSearch = await createNewSearch(userId, name, courseCodes)
|
||||
if (!createdSearch) {
|
||||
return res.status(400).json({ error: 'Failed to create search' })
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
id: createdSearch.id,
|
||||
userId: createdSearch.userId,
|
||||
name: createdSearch.name,
|
||||
courseList: createdSearch.courseCodes,
|
||||
updatedAt: createdSearch.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
type UpdateSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type UpdateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type UpdateSearchReqBody = {
|
||||
courselist: string[]
|
||||
}
|
||||
|
||||
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { courselist } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: updatedSearch.id,
|
||||
userId: updatedSearch.userId,
|
||||
name: updatedSearch.name,
|
||||
courseList: updatedSearch.courseCodes,
|
||||
updatedAt: updatedSearch.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
type DeleteSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type DeleteSearchResBody = CanError<string>
|
||||
|
||||
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json(id)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
||||
|
||||
import type { CanError, GenericApplicationError } from '@oodikone/shared/routes'
|
||||
import type {
|
||||
FacultyGraduationStatistics,
|
||||
Graduated,
|
||||
Name,
|
||||
NameWithCode,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
setBasicStats,
|
||||
getThesisWritersStats,
|
||||
setThesisWritersStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
getFacultyStudentStats,
|
||||
setFacultyStudentStats,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
setFacultyProgressStats,
|
||||
type BasicData,
|
||||
type ThesisWriterData,
|
||||
GraduationData,
|
||||
getGraduationStats,
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress } from '../services/faculty/facultyStudentProgress'
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
@@ -135,17 +135,29 @@ router.get<
|
||||
return res.json(await getFacultyCredits(faculty.code, yearType === 'ACADEMIC_YEAR'))
|
||||
})
|
||||
|
||||
type GetGraduationStatsResBody = GraduationData
|
||||
type GetGraduationStatsQuery = {
|
||||
programme_filter: ProgrammeFilter
|
||||
}
|
||||
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetGraduationStatsResBody, GenericApplicationError>,
|
||||
CanError<FacultyGraduationStatistics, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetGraduationStatsQuery
|
||||
>('/:id/graduationtimes', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
// Recursively clears graduation time arrays "times" from result.
|
||||
// We don't need to send them to client,
|
||||
// but university view (uses same data) needs it to compute medians.
|
||||
const clearTimes = (data: any) => {
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach(clearTimes)
|
||||
} else if (!!data && typeof data === 'object') {
|
||||
if (data.times) data.times = []
|
||||
Object.values(data).forEach(clearTimes)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const { id: facultyId } = req.params
|
||||
const { programme_filter: programmeFilter } = req.query
|
||||
|
||||
@@ -153,7 +165,7 @@ router.get<
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const data = await getGraduationStats(faculty.code, programmeFilter)
|
||||
if (data) return res.json(data)
|
||||
if (data) return res.json(clearTimes(data))
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
@@ -161,7 +173,7 @@ router.get<
|
||||
const updatedStats = await countGraduationTimes(faculty.code, programmes)
|
||||
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
|
||||
|
||||
return res.json(updatedStats)
|
||||
return res.json(clearTimes(updatedStats))
|
||||
})
|
||||
|
||||
type GetProgressStatsResBody = {
|
||||
|
||||
@@ -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,21 +102,17 @@ 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
|
||||
|
||||
const requiredFields = [years, semesters, programme]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
if (requiredFields.some(field => !field.length)) {
|
||||
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
|
||||
}
|
||||
|
||||
@@ -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,25 @@ 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',
|
||||
router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
|
||||
'/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentNumbers, tags } = req.body
|
||||
const { studentNumbers, tags } = req.body ?? { studentNumbers: [] }
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
|
||||
const students = handleQueryArrays(studentNumbers)
|
||||
|
||||
if (!students.length) {
|
||||
return res.status(422).json({ error: 'Body should include student numbers' })
|
||||
}
|
||||
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
? students
|
||||
: intersection(students, studentsUserCanAccess)
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
@@ -242,14 +251,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 +284,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 (
|
||||
!(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
import { Graduated, NameWithCode } from '@oodikone/shared/types'
|
||||
import { FacultyGraduationStatistics, Graduated, NameWithCode } from '@oodikone/shared/types'
|
||||
import { MediansByCategory, UniversityGraduationStatistics } from '@oodikone/shared/types/graduations'
|
||||
import { magicFacultyCode } from '../config/organizationConstants'
|
||||
import { OrganizationModel } from '../models'
|
||||
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
|
||||
import { countGraduationTimes, LevelGraduationStats } from '../services/faculty/facultyGraduationTimes'
|
||||
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
|
||||
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
|
||||
import {
|
||||
getFacultyProgressStats,
|
||||
@@ -22,13 +23,12 @@ const router = Router()
|
||||
|
||||
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
|
||||
|
||||
const getProgrammeNames = (faculties: OrganizationModel[]) => {
|
||||
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
|
||||
const getProgrammeNames = (faculties: OrganizationModel[]) =>
|
||||
faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
|
||||
const { name, code } = faculty.toJSON()
|
||||
obj[faculty.code] = { code, ...name }
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
|
||||
interface GetProgressStatsRequest extends Request {
|
||||
query: {
|
||||
@@ -120,16 +120,20 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
|
||||
res.status(200).json(universityData)
|
||||
})
|
||||
|
||||
router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
const degreeNames = ['bachelor', 'bcMsCombo', 'master', 'doctor'] as const
|
||||
router.get('/allgraduationstats', async (_req: Request, res: Response<UniversityGraduationStatistics>) => {
|
||||
const degreeTypes = ['bachelor', 'bcMsCombo', 'master', 'doctor'] as const
|
||||
/** University view does not support old degree programmes */
|
||||
const programmeFilter = 'NEW_DEGREE_PROGRAMMES' as const
|
||||
|
||||
const allFaculties = await getSortedFaculties()
|
||||
const facultyCodes = allFaculties.map(faculty => faculty.code)
|
||||
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
|
||||
const programmeFilter = 'NEW_DEGREE_PROGRAMMES'
|
||||
const facultyData: Record<string, FacultyGraduationStatistics> = {}
|
||||
|
||||
for (const facultyCode of facultyCodes) {
|
||||
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
|
||||
let data = await getGraduationStats(facultyCode, programmeFilter, true)
|
||||
|
||||
if (!data) {
|
||||
logger.info(`Data missing from server: Refreshing graduation faculty data for faculty ${facultyCode}`)
|
||||
logger.info(`Data missing from redis: Refreshing graduation faculty data for faculty ${facultyCode}`)
|
||||
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, true)
|
||||
data = await countGraduationTimes(facultyCode, programmes)
|
||||
await setGraduationStats(data, programmeFilter)
|
||||
@@ -137,48 +141,50 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
facultyData[facultyCode] = data
|
||||
}
|
||||
|
||||
const universityData = {
|
||||
goals: {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
const goals: UniversityGraduationStatistics['goals'] = {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
}
|
||||
const programmeNames = getProgrammeNames(allFaculties)
|
||||
|
||||
const byGradYear: UniversityGraduationStatistics['byGradYear'] = {
|
||||
medians: {
|
||||
bachelor: [],
|
||||
bcMsCombo: [],
|
||||
master: [],
|
||||
doctor: [],
|
||||
},
|
||||
programmeNames: getProgrammeNames(allFaculties),
|
||||
byGradYear: {
|
||||
medians: {} as Record<string, LevelGraduationStats[]>,
|
||||
programmes: {
|
||||
medians: {} as Record<
|
||||
string,
|
||||
Record<string, { programmes: string[]; data: Array<LevelGraduationStats & { name: string; code: string }> }>
|
||||
>,
|
||||
programmes: {
|
||||
medians: {
|
||||
bachelor: {},
|
||||
bcMsCombo: {},
|
||||
master: {},
|
||||
doctor: {},
|
||||
},
|
||||
},
|
||||
classSizes: {
|
||||
bachelor: null as Record<string, number> | null,
|
||||
bcMsCombo: null as Record<string, number> | null,
|
||||
master: null as Record<string, number> | null,
|
||||
doctor: null as Record<string, number> | null,
|
||||
programmes: {} as Record<string, Record<string, Record<string, number>>>,
|
||||
},
|
||||
}
|
||||
const classSizes: UniversityGraduationStatistics['classSizes'] = {
|
||||
programmes: {},
|
||||
}
|
||||
|
||||
const unifyTotals = (facultyData: Record<string, LevelGraduationStats[]>, isLast: boolean) => {
|
||||
const mediansForUniversity = universityData.byGradYear.medians
|
||||
for (const degree of degreeNames) {
|
||||
if (!mediansForUniversity[degree]) mediansForUniversity[degree] = []
|
||||
if (!facultyData[degree]) continue
|
||||
for (const yearStats of facultyData[degree]) {
|
||||
const universityStats = mediansForUniversity[degree]
|
||||
const universityYearStats = mediansForUniversity[degree].find(stats => stats.name === yearStats.name)
|
||||
/** Computes university level stats */
|
||||
const unifyTotals = (facultyData: MediansByCategory, isLast: boolean) => {
|
||||
const mediansForUniversity = byGradYear.medians
|
||||
for (const degreeType of degreeTypes) {
|
||||
if (!facultyData[degreeType]) continue
|
||||
|
||||
for (const facultyYearStats of facultyData[degreeType]) {
|
||||
const universityYearStats = mediansForUniversity[degreeType].find(stats => stats.name === facultyYearStats.name)
|
||||
if (!universityYearStats) {
|
||||
universityStats.push(yearStats)
|
||||
mediansForUniversity[degreeType].push(facultyYearStats)
|
||||
} else {
|
||||
universityYearStats.times.push(...yearStats.times)
|
||||
universityYearStats.amount += yearStats.amount
|
||||
universityYearStats.statistics.onTime += yearStats.statistics.onTime
|
||||
universityYearStats.statistics.yearOver += yearStats.statistics.yearOver
|
||||
universityYearStats.statistics.wayOver += yearStats.statistics.wayOver
|
||||
universityYearStats.times.push(...facultyYearStats.times)
|
||||
universityYearStats.amount += facultyYearStats.amount
|
||||
universityYearStats.statistics.onTime += facultyYearStats.statistics.onTime
|
||||
universityYearStats.statistics.yearOver += facultyYearStats.statistics.yearOver
|
||||
universityYearStats.statistics.wayOver += facultyYearStats.statistics.wayOver
|
||||
if (isLast) {
|
||||
universityYearStats.median = getMedian(universityYearStats.times)
|
||||
}
|
||||
@@ -187,29 +193,30 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
const unifyProgrammeStats = (facultyData: Record<string, LevelGraduationStats[]>, facultyCode: string) => {
|
||||
const mediansForUniversity = universityData.byGradYear.programmes.medians
|
||||
for (const degree of degreeNames) {
|
||||
if (!facultyData[degree]) continue
|
||||
if (!mediansForUniversity[degree]) mediansForUniversity[degree] = {}
|
||||
for (const yearData of facultyData[degree]) {
|
||||
if (!mediansForUniversity[degree][yearData.name]) {
|
||||
mediansForUniversity[degree][yearData.name] = { programmes: [], data: [] }
|
||||
/** Computes faculty breakdown */
|
||||
const unifyFacultyStats = (facultyData: MediansByCategory, facultyCode: string) => {
|
||||
const mediansForUniversity = byGradYear.programmes.medians
|
||||
for (const degreeType of degreeTypes) {
|
||||
if (!facultyData[degreeType]) continue
|
||||
|
||||
for (const facultyYearStats of facultyData[degreeType]) {
|
||||
if (!mediansForUniversity[degreeType][facultyYearStats.name]) {
|
||||
mediansForUniversity[degreeType][facultyYearStats.name] = { programmes: [], data: [] }
|
||||
}
|
||||
const uniYearStats = mediansForUniversity[degree][yearData.name]
|
||||
const uniYearStats = mediansForUniversity[degreeType][facultyYearStats.name]
|
||||
if (!uniYearStats.programmes.find(prog => prog === facultyCode)) {
|
||||
uniYearStats.programmes.push(facultyCode)
|
||||
}
|
||||
const uniYearFacultyStats = uniYearStats.data.find(item => item.code === facultyCode)
|
||||
if (!uniYearFacultyStats) {
|
||||
uniYearStats.data.push({ ...cloneDeep(yearData), name: facultyCode, code: facultyCode })
|
||||
uniYearStats.data.push({ ...cloneDeep(facultyYearStats), name: facultyCode, code: facultyCode })
|
||||
} else {
|
||||
uniYearFacultyStats.times.push(...uniYearFacultyStats.times)
|
||||
uniYearFacultyStats.median = getMedian(uniYearFacultyStats.times)
|
||||
uniYearFacultyStats.amount += yearData.amount
|
||||
uniYearFacultyStats.statistics.onTime += yearData.statistics.onTime
|
||||
uniYearFacultyStats.statistics.yearOver += yearData.statistics.yearOver
|
||||
uniYearFacultyStats.statistics.wayOver += yearData.statistics.wayOver
|
||||
uniYearFacultyStats.amount += facultyYearStats.amount
|
||||
uniYearFacultyStats.statistics.onTime += facultyYearStats.statistics.onTime
|
||||
uniYearFacultyStats.statistics.yearOver += facultyYearStats.statistics.yearOver
|
||||
uniYearFacultyStats.statistics.wayOver += facultyYearStats.statistics.wayOver
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,27 +225,20 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
for (let i = 0; i < facultyCodes.length; i++) {
|
||||
const facultyCode = facultyCodes[i]
|
||||
const data = facultyData[facultyCode]
|
||||
unifyTotals(data.byGradYear.medians, i === facultyCodes.length - 1)
|
||||
unifyProgrammeStats(data.byGradYear.medians, facultyCode)
|
||||
for (const degree of degreeNames) {
|
||||
if (!data.classSizes[degree]) {
|
||||
continue
|
||||
}
|
||||
const facultyClassSizes = data.classSizes[degree] as Record<string, number>
|
||||
if (!universityData.classSizes[degree]) {
|
||||
universityData.classSizes[degree] = facultyClassSizes
|
||||
} else {
|
||||
Object.entries(facultyClassSizes).forEach(([key, value]) => {
|
||||
universityData.classSizes[degree]![key] += value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { programmes, ...rest } = data.classSizes
|
||||
universityData.classSizes.programmes[facultyCode] = rest as Record<string, Record<string, number>>
|
||||
unifyTotals(data.byGradYear.medians, i === facultyCodes.length - 1)
|
||||
unifyFacultyStats(data.byGradYear.medians, facultyCode)
|
||||
|
||||
const { programmes: _, ...rest } = data.classSizes
|
||||
classSizes.programmes[facultyCode] = rest
|
||||
}
|
||||
|
||||
res.status(200).json(universityData)
|
||||
res.status(200).json({
|
||||
goals,
|
||||
programmeNames,
|
||||
byGradYear,
|
||||
classSizes,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -4,6 +4,7 @@ import { languageCenterViewEnabled } from '../config'
|
||||
import { refreshFaculties, refreshProgrammes } from '../events'
|
||||
import {
|
||||
abortUpdate,
|
||||
nukeRedis,
|
||||
updateSISCoursesByCourseCode,
|
||||
updateSISMetadata,
|
||||
updateSISProgrammes,
|
||||
@@ -12,28 +13,28 @@ import {
|
||||
updateSISStudentsByStudentNumber,
|
||||
} from '../services/sisUpdaterService'
|
||||
import logger from '../util/logger'
|
||||
import { getJobs, jobMaker, removeWaitingJobs } from '../worker/queue'
|
||||
import { jobQueue } from '../worker/queue'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const refreshFacultiesByList = (facultyCodes: string[]) => {
|
||||
facultyCodes.forEach(code => jobMaker.faculty(code))
|
||||
facultyCodes.forEach(code => jobQueue.refreshFaculty(code))
|
||||
return 'Added jobs for refreshing faculties'
|
||||
}
|
||||
|
||||
const refreshProgrammesByList = (programmeCodes: string[]) => {
|
||||
programmeCodes.forEach(code => jobMaker.programme(code))
|
||||
programmeCodes.forEach(code => jobQueue.refreshProgramme(code))
|
||||
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 +48,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 +66,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')
|
||||
@@ -80,11 +81,17 @@ router.get('/refresh_redis_cache', async (req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/nuke_redis', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested complete wipe of redis`)
|
||||
const response = await nukeRedis()
|
||||
if (response) res.status(200).json('Complete flush of redis scheduled')
|
||||
})
|
||||
|
||||
router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
|
||||
logger.info(
|
||||
`${req.user.username} requested refresh of teacher leaderboard for the current and previous academic year`
|
||||
)
|
||||
jobMaker.teacherLeaderboard()
|
||||
jobQueue.refreshTeacherLeaderboard()
|
||||
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
|
||||
})
|
||||
|
||||
@@ -104,13 +111,13 @@ router.post('/refresh_language_center_data', (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of language center data`)
|
||||
if (!languageCenterViewEnabled)
|
||||
res.status(418).json({ error: 'The language center functionality is not activated in your environment.' })
|
||||
jobMaker.languagecenter()
|
||||
jobQueue.refreshLanguagecenter()
|
||||
res.status(200).json('Added job for refreshing language center data')
|
||||
})
|
||||
|
||||
router.post('/refresh-close-to-graduation', (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of close to graduation data`)
|
||||
jobMaker.closeToGraduation()
|
||||
jobQueue.refreshCloseToGraduation()
|
||||
res.status(200).json('Added job for refreshing close to graduation data')
|
||||
})
|
||||
|
||||
@@ -120,13 +127,12 @@ router.get('/abort', async (_req: Request, res: Response) => {
|
||||
})
|
||||
|
||||
router.get('/jobs', async (_req: Request, res: Response) => {
|
||||
const waiting = await getJobs('waiting')
|
||||
const active = await getJobs('active')
|
||||
res.status(200).json({ waiting, active })
|
||||
const jobs = await jobQueue.getJobs()
|
||||
res.status(200).json(jobs)
|
||||
})
|
||||
|
||||
router.delete('/jobs', async (_req: Request, res: Response) => {
|
||||
await removeWaitingJobs()
|
||||
await jobQueue.clearWaitingJobs()
|
||||
res.status(200).end()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,64 +1,30 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import {
|
||||
CompletedCoursesCourse,
|
||||
CompletedCoursesStudent,
|
||||
CreditTypeCode,
|
||||
EnrollmentState,
|
||||
StudentCredits,
|
||||
} from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold, omitKeys } from '@oodikone/shared/util'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
|
||||
|
||||
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>
|
||||
|
||||
interface StudentCredit {
|
||||
date: Date
|
||||
courseCode: string
|
||||
creditType: CreditTypeCode
|
||||
substitution: string | null
|
||||
}
|
||||
|
||||
interface StudentEnrollment {
|
||||
date: Date
|
||||
courseCode: string
|
||||
substitution: string | null
|
||||
}
|
||||
|
||||
interface StudentInfo {
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
type StudentCredits = Record<
|
||||
string,
|
||||
Omit<StudentInfo, 'credits' | 'enrollments'> & {
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
}
|
||||
>
|
||||
|
||||
export interface FormattedStudent extends StudentInfo {
|
||||
studentNumber: string
|
||||
allEnrollments: StudentEnrollment[]
|
||||
}
|
||||
|
||||
interface StudentWithStudyplanNested
|
||||
extends Pick<
|
||||
StudentModel,
|
||||
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
|
||||
> {
|
||||
type StudentWithStudyplanNested = Pick<
|
||||
StudentModel,
|
||||
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
|
||||
> & {
|
||||
studyplans: { included_courses: string[] }[]
|
||||
}
|
||||
|
||||
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
|
||||
type StudentWithCourses = Omit<StudentWithStudyplanNested, 'studyplans'> & {
|
||||
coursesInStudyPlan: string[]
|
||||
}
|
||||
|
||||
const getCourses = async (courseCodes: string[]) => {
|
||||
const courses: Courses = (
|
||||
const courses: CompletedCoursesCourse[] = (
|
||||
await CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions'],
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: where(fn('LOWER', col('code')), {
|
||||
[Op.in]: courseCodes.map(code => code.toLowerCase()),
|
||||
}),
|
||||
@@ -67,44 +33,76 @@ const getCourses = async (courseCodes: string[]) => {
|
||||
return courses
|
||||
}
|
||||
|
||||
const getCredits = async (
|
||||
courses: Courses,
|
||||
const getPassedCredits = async (
|
||||
courses: CompletedCoursesCourse[],
|
||||
fullCourseCodes: string[],
|
||||
courseCodes: string[],
|
||||
studentNumbers: string[]
|
||||
) => {
|
||||
const credits: Array<
|
||||
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
|
||||
> = await CreditModel.findAll({
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
const credits: Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>[] =
|
||||
await CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
order: [['attainment_date', 'DESC']],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
credittypecode: {
|
||||
[Op.not]: CreditTypeCode.FAILED,
|
||||
},
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
const formattedCredits = credits.map(credit => {
|
||||
const originalCode = courseCodes.includes(credit.course_code)
|
||||
? null
|
||||
: courses.find(course => course.substitutions.includes(credit.course_code))?.code
|
||||
return {
|
||||
courseCode: originalCode ?? credit.course_code,
|
||||
substitution: originalCode ? credit.course_code : null,
|
||||
studentNumber: credit.student_studentnumber,
|
||||
creditType: credit.credittypecode,
|
||||
date: credit.attainment_date,
|
||||
})
|
||||
|
||||
const creditsByStudentNumber = Object.groupBy(credits, ({ student_studentnumber }) => student_studentnumber)
|
||||
|
||||
const formattedCredits: {
|
||||
courseCode: string
|
||||
substitution: string[] | null
|
||||
studentNumber: string
|
||||
creditType: CreditTypeCode
|
||||
date: Date
|
||||
}[] = []
|
||||
|
||||
Object.entries(creditsByStudentNumber).map(([studentNumber, credits]) => {
|
||||
// We know that credits will exist, because of the way we created the previous object
|
||||
const studentCreditCourseCodes = credits!.map(({ course_code }) => course_code)
|
||||
for (const course of courses) {
|
||||
// Also handle the main course code by adding it as a group
|
||||
for (const group of [[course.code]].concat(course.substitution_groups)) {
|
||||
if (group.every(code => studentCreditCourseCodes.includes(code))) {
|
||||
// We just checked that (group) course code exists in credits so .find(...)! is ok
|
||||
const groupCredits = group.map(code => credits!.find(credit => credit.course_code === code)!)
|
||||
if (groupCredits.length) {
|
||||
const groupCreditCodes = groupCredits.map(({ course_code }) => course_code)
|
||||
formattedCredits.push({
|
||||
courseCode: course.code,
|
||||
substitution:
|
||||
groupCreditCodes.includes(course.code) && groupCreditCodes.length === 1 ? null : groupCreditCodes,
|
||||
studentNumber,
|
||||
creditType: groupCredits.length > 1 ? CreditTypeCode.PASSED : groupCredits.at(0)!.credittypecode,
|
||||
date: groupCredits?.at(0)?.attainment_date ?? new Date(), // Credits are sorted by date in desc. order
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return formattedCredits
|
||||
}
|
||||
|
||||
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
|
||||
const getEnrollments = async (
|
||||
courses: CompletedCoursesCourse[],
|
||||
fullCourseCodes: string[],
|
||||
studentNumbers: string[]
|
||||
) => {
|
||||
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
|
||||
await EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
|
||||
order: [['enrollment_date_time', 'DESC']],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
@@ -115,17 +113,45 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
|
||||
state: {
|
||||
[Op.eq]: EnrollmentState.ENROLLED,
|
||||
},
|
||||
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
|
||||
},
|
||||
})
|
||||
const formattedEnrollments = enrollments.map(enrollment => {
|
||||
const originalCode = courses.find(course => course.substitutions.includes(enrollment.course_code))?.code
|
||||
return {
|
||||
courseCode: originalCode ?? enrollment.course_code,
|
||||
substitution: originalCode ? enrollment.course_code : null,
|
||||
studentNumber: enrollment.studentnumber,
|
||||
date: enrollment.enrollment_date_time,
|
||||
|
||||
const enrollmentsByStudents = Object.groupBy(enrollments, ({ studentnumber }) => studentnumber)
|
||||
|
||||
const formattedEnrollments: {
|
||||
courseCode: string
|
||||
substitution: string[] | null
|
||||
studentNumber: string
|
||||
date: Date
|
||||
}[] = []
|
||||
|
||||
Object.entries(enrollmentsByStudents).map(([studentNumber, enrollments]) => {
|
||||
// We know that enrollments will exist, because of the way we created the previous object
|
||||
const studentEnrollmentCourseCodes = enrollments!.map(({ course_code }) => course_code)
|
||||
for (const course of courses) {
|
||||
// Also handle the main course code by adding it as a group
|
||||
for (const group of [[course.code]].concat(course.substitution_groups)) {
|
||||
if (group.every(code => studentEnrollmentCourseCodes.includes(code))) {
|
||||
// We just checked that (group) course code exists in enrollments so .find(...)! is ok
|
||||
const groupEnrollments = group.map(code => enrollments!.find(enrollment => enrollment.course_code === code)!)
|
||||
if (groupEnrollments.length) {
|
||||
const groupEnrollmentCodes = groupEnrollments.map(({ course_code }) => course_code)
|
||||
formattedEnrollments.push({
|
||||
courseCode: course.code,
|
||||
substitution:
|
||||
groupEnrollmentCodes.includes(course.code) && groupEnrollmentCodes.length === 1
|
||||
? null
|
||||
: groupEnrollmentCodes,
|
||||
studentNumber,
|
||||
date: groupEnrollments?.at(0)?.enrollment_date_time ?? new Date(), // Enrollments are sorted by date in desc. order
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return formattedEnrollments
|
||||
}
|
||||
|
||||
@@ -156,18 +182,23 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
|
||||
export const getCompletedCourses = async (
|
||||
studentNumbers: string[],
|
||||
courseCodes: string[]
|
||||
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
|
||||
): Promise<{ students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]; courses: CompletedCoursesCourse[] }> => {
|
||||
const courses = await getCourses(courseCodes)
|
||||
const courseCodesSet = new Set(courseCodes)
|
||||
courses.forEach(course => {
|
||||
|
||||
// Get *ALL* courses including any courses in any substitution groups
|
||||
for (const course of courses) {
|
||||
courseCodesSet.add(course.code)
|
||||
course.substitutions.forEach(substitution => {
|
||||
courseCodesSet.add(substitution)
|
||||
})
|
||||
})
|
||||
for (const group of course.substitution_groups) {
|
||||
for (const code of group) {
|
||||
courseCodesSet.add(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullCourseCodes = Array.from(courseCodesSet)
|
||||
|
||||
const credits = await getCredits(courses, fullCourseCodes, courseCodes, studentNumbers)
|
||||
const credits = await getPassedCredits(courses, fullCourseCodes, studentNumbers)
|
||||
const enrollments = await getEnrollments(courses, fullCourseCodes, studentNumbers)
|
||||
const studentInfo = await getStudents(studentNumbers)
|
||||
|
||||
@@ -223,27 +254,30 @@ export const getCompletedCourses = async (
|
||||
})
|
||||
})
|
||||
|
||||
const students = Object.keys(studentCredits).reduce((acc: FormattedStudent[], studentNumber) => {
|
||||
const student = studentCredits[studentNumber]
|
||||
acc.push({
|
||||
studentNumber,
|
||||
sis_person_id: student.sis_person_id,
|
||||
credits: student.credits,
|
||||
enrollments: {},
|
||||
allEnrollments: student.enrollments,
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
}, [] as FormattedStudent[])
|
||||
const students = Object.keys(studentCredits).reduce<CompletedCoursesStudent[]>(
|
||||
(acc: CompletedCoursesStudent[], studentNumber) => {
|
||||
const student = studentCredits[studentNumber]
|
||||
acc.push({
|
||||
studentNumber,
|
||||
sis_person_id: student.sis_person_id,
|
||||
credits: student.credits,
|
||||
enrollments: {},
|
||||
allEnrollments: student.enrollments,
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
students.forEach(student => {
|
||||
courseCodes.forEach(courseCode => {
|
||||
const [latestEnrollment] = student.allEnrollments
|
||||
.filter(enrollment => enrollment.courseCode === courseCode || enrollment.substitution === courseCode)
|
||||
.filter(enrollment => enrollment.courseCode === courseCode)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
student.enrollments[courseCode] = latestEnrollment
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { getSortRank } from '../../../src/util/sortRank'
|
||||
import { CourseWithSubsDetails } from '@oodikone/shared/types/course'
|
||||
import { CourseModel } from '../../models'
|
||||
|
||||
const nameLikeTerm = (name: string) => {
|
||||
@@ -40,8 +38,8 @@ const codeLikeTerm = (code: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) =>
|
||||
await CourseModel.findAll({
|
||||
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const courses = await CourseModel.findAll({
|
||||
where: {
|
||||
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
|
||||
...nameLikeTerm(name),
|
||||
@@ -50,42 +48,23 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const rawCourses = await getRawCourses(name, code, includeSpecial)
|
||||
return rawCourses
|
||||
.map(course => ({
|
||||
...course,
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
}))
|
||||
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code)) as CourseWithSubsId[]
|
||||
}
|
||||
const coursesWithSubstitutionGroupDetails = await Promise.all(
|
||||
courses.map<Promise<CourseWithSubsDetails>>(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,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const courses = await getCourses(name, code, includeSpecial)
|
||||
|
||||
const visitedCourses = new Set<string>()
|
||||
|
||||
const assignSubstitutionGroup = (course: CourseWithSubsId, index: number) => {
|
||||
const relatedCourses = courses.filter(currentCourse => course.substitutions.includes(currentCourse.code))
|
||||
relatedCourses.unshift(course)
|
||||
relatedCourses.forEach(relatedCourse => {
|
||||
visitedCourses.add(relatedCourse.id)
|
||||
relatedCourse.subsId = index
|
||||
return { ...course, substitution_groups: groupDetails }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
courses
|
||||
.filter(course => !visitedCourses.has(course.id) && !visitedCourses.has(course.code))
|
||||
.forEach((course, index) => assignSubstitutionGroup(course, index))
|
||||
|
||||
return courses
|
||||
return coursesWithSubstitutionGroupDetails
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Credit, Enrollment } from '@oodikone/shared/models'
|
||||
import { EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
|
||||
import {
|
||||
StudentModel,
|
||||
CreditModel,
|
||||
SemesterModel,
|
||||
OrganizationModel,
|
||||
@@ -11,37 +14,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 +133,65 @@ 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', 'studyright_id'],
|
||||
include: [
|
||||
{
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
},
|
||||
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
|
||||
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
|
||||
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,14 @@
|
||||
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 { enrollmentTimeDateThreshold, getSemesterCodeAt } from '@oodikone/shared/util'
|
||||
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 { getSemestersAndYears, SemestersAndYears } from '../semesters'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
getCreditsForCourses,
|
||||
@@ -14,13 +17,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 +43,37 @@ 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)
|
||||
|
||||
// TODO: Calculate substitutions correctly once course_unit_realisation hits
|
||||
const attainment =
|
||||
creditGroup
|
||||
.sort((a, b) => b.attainment_date.getTime() - a.attainment_date.getTime()) // desc
|
||||
.find(credit => CreditModel.passed(credit)) ??
|
||||
creditGroup.find(credit => CreditModel.failed(credit)) ??
|
||||
creditGroup.at(0)!
|
||||
|
||||
const courseCode = singleCourse ? attainment.course_code : mainCourseCode
|
||||
const grade = singleCourse ? attainment.grade : 'substituted'
|
||||
const credits = singleCourse ? attainment.credits : creditGroup.reduce((acc, credit) => acc + credit.credits, 0)
|
||||
|
||||
const {
|
||||
semester,
|
||||
grade,
|
||||
course_code: courseCode,
|
||||
credits,
|
||||
attainment_date: attainmentDate,
|
||||
student_studentnumber: studentNumber,
|
||||
studyright_id: studyRightId,
|
||||
} = credit
|
||||
} = attainment
|
||||
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const programmeOfCredit: SISStudyRightElementModel | undefined =
|
||||
@@ -87,7 +100,7 @@ const parseCredit = (
|
||||
attainmentDate,
|
||||
courseCode,
|
||||
grade,
|
||||
passed: !CreditModel.failed(credit),
|
||||
passed: singleCourse ? !CreditModel.failed(attainment) : true,
|
||||
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
|
||||
programme,
|
||||
credits,
|
||||
@@ -95,36 +108,77 @@ const parseCredit = (
|
||||
}
|
||||
|
||||
type FormattedEnrollment = {
|
||||
yearCode: number
|
||||
yearName: string
|
||||
semesterCode: number
|
||||
semesterName: Name
|
||||
courseCode: string
|
||||
enrollmentDateTime: Date
|
||||
programme: FormattedProgramme
|
||||
studentNumber: string
|
||||
}
|
||||
|
||||
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
|
||||
const parseEnrollment = (
|
||||
enrollment: Enrollment,
|
||||
anonymizationSalt: string | null,
|
||||
studyRightElements: Array<SISStudyRightElementModel>
|
||||
): FormattedEnrollment => {
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
semester,
|
||||
enrollment_date_time: enrollmentDateTime,
|
||||
course_code: courseCode,
|
||||
studyright_id: studyRightId,
|
||||
} = enrollment
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const programmeOfCredit: SISStudyRightElementModel | undefined =
|
||||
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
|
||||
studyRightElements
|
||||
.filter(({ startDate, endDate }) => dateIsBetween(enrollmentDateTime, startDate, endDate))
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||
.at(0) // The newest studyRightElement
|
||||
|
||||
const programme = programmeOfCredit
|
||||
? formatStudyRightElement(programmeOfCredit)
|
||||
: {
|
||||
code: 'OTHER',
|
||||
name: { en: 'Other', fi: 'Muu', sv: 'Andra' },
|
||||
facultyCode: 'OTHER',
|
||||
organization: { name: { en: 'Other', fi: 'Muu', sv: 'Andra' } },
|
||||
}
|
||||
|
||||
return {
|
||||
yearCode,
|
||||
yearName,
|
||||
semesterCode,
|
||||
semesterName,
|
||||
courseCode,
|
||||
enrollmentDateTime,
|
||||
programme,
|
||||
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
|
||||
}
|
||||
}
|
||||
|
||||
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 getSemesterAndYearByDate = (
|
||||
date: Date,
|
||||
semesters: SemestersAndYears['semesters'],
|
||||
years: SemestersAndYears['years']
|
||||
) => {
|
||||
const semesterCode = getSemesterCodeAt(semesters, date) ?? getSemesterCodeAt(semesters, new Date())!
|
||||
|
||||
const semester = semesters[semesterCode]
|
||||
const year = years[semester.yearcode]
|
||||
|
||||
return { year, semester }
|
||||
}
|
||||
|
||||
const getYearlyStatsOfNew = async (
|
||||
course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null,
|
||||
courseCode: string,
|
||||
separate: boolean,
|
||||
unification: Unification,
|
||||
@@ -132,27 +186,38 @@ 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 { semesters, years } = await getSemestersAndYears()
|
||||
|
||||
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 +228,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,
|
||||
@@ -178,38 +248,68 @@ const getYearlyStatsOfNew = async (
|
||||
programme.organization
|
||||
)
|
||||
|
||||
// Credits/attainments have quaranteed matching attainment_date and semesters/years
|
||||
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,
|
||||
courseCode: enrollmentCourseCode,
|
||||
enrollmentDateTime,
|
||||
programme,
|
||||
} = parseEnrollment(
|
||||
enrollment,
|
||||
anonymizationSalt,
|
||||
studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
|
||||
)
|
||||
|
||||
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
// Enrollments can have conflicting enrollment_date_time and semestercode, so we need to manually select
|
||||
// semestercode matching enrollment_date_time
|
||||
const { semester, year } = getSemesterAndYearByDate(enrollment.enrollment_date_time, semesters, years)
|
||||
|
||||
counter.markStudyProgramme(
|
||||
studentNumber,
|
||||
year.yearcode,
|
||||
false, // passed
|
||||
0, // credits
|
||||
programme.code,
|
||||
programme.name,
|
||||
programme.facultyCode,
|
||||
programme.organization
|
||||
)
|
||||
|
||||
const groupCode = separate ? semester.semestercode : year.yearcode
|
||||
const groupName = separate ? semester.name : year.yearname
|
||||
|
||||
counter.markEnrollmentToGroup(
|
||||
studentNumber,
|
||||
enrollmentDateTime,
|
||||
groupCode,
|
||||
groupName,
|
||||
enrollmentCourseCode,
|
||||
year.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 +323,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)
|
||||
@@ -259,6 +359,7 @@ export const getCourseYearlyStats = async (
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -277,8 +378,20 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null = await CourseModel.findOne({
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: { code: courseCode },
|
||||
raw: true,
|
||||
})
|
||||
|
||||
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 +400,7 @@ export const getCourseYearlyStats = async (
|
||||
studentNumberToSrElementsMap
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.REGULAR,
|
||||
@@ -295,6 +409,7 @@ export const getCourseYearlyStats = async (
|
||||
studentNumberToSrElementsMap
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
courseCode,
|
||||
separate,
|
||||
Unification.UNIFY,
|
||||
@@ -327,3 +442,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,35 +1,78 @@
|
||||
import { cloneDeep, omit } from 'lodash-es'
|
||||
|
||||
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { Name, DegreeProgrammeType, FacultyGraduationStatistics, ClassSizes } from '@oodikone/shared/types'
|
||||
import type {
|
||||
GraduationStatistics,
|
||||
MedianEntry,
|
||||
MediansByCategory,
|
||||
MediansByProgrammes,
|
||||
ProgrammeGraduationStats,
|
||||
ProgrammeMedians,
|
||||
} from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
|
||||
import { getGraduationStatsForStudyTrack } from '../studyProgramme/studyProgrammeGraduations'
|
||||
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrackStats'
|
||||
import type { ProgrammesOfOrganization } from './faculty'
|
||||
|
||||
export type LevelGraduationStats = Omit<Unarray<GraduationTimes['medians']>, 'y'> & { median: number }
|
||||
|
||||
type ProgrammeStats = {
|
||||
data: Array<Omit<LevelGraduationStats, 'times'> & { code: string }>
|
||||
programmes: string[]
|
||||
}
|
||||
|
||||
export const programmeTypes = {
|
||||
[DegreeProgrammeType.BACHELOR]: 'bachelor',
|
||||
[DegreeProgrammeType.MASTER]: 'master',
|
||||
[DegreeProgrammeType.DOCTOR]: 'doctor',
|
||||
} as const
|
||||
|
||||
const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
|
||||
const newStats: ProgrammeGraduationStats[] = []
|
||||
const medians: Record<string, LevelGraduationStats[]> = {}
|
||||
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
|
||||
const setYearlyBreakdown = (code: string, progId: string, level: ProgrammeMedians, year: MedianEntry) => {
|
||||
if (year.y === 0) return
|
||||
|
||||
level[year.name] ??= { data: [], programmes: [] }
|
||||
level[year.name].programmes.push(code)
|
||||
level[year.name].data.push(
|
||||
omitKeys({ ...structuredClone(year), name: progId, code, median: year.y }, ['y', 'classSize'])
|
||||
)
|
||||
}
|
||||
|
||||
const mergeStats = (
|
||||
medians: MediansByCategory,
|
||||
level: keyof MediansByCategory,
|
||||
statsForYear: MedianEntry,
|
||||
classSizes?: ClassSizes
|
||||
) => {
|
||||
if (classSizes) {
|
||||
classSizes[level][statsForYear.name] ??= 0
|
||||
classSizes[level][statsForYear.name] += statsForYear.classSize ?? 0
|
||||
}
|
||||
|
||||
const gradStatsForYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
|
||||
if (!gradStatsForYear) {
|
||||
medians[level].push({ ...omitKeys(structuredClone(statsForYear), ['y']), median: statsForYear.y })
|
||||
} else {
|
||||
gradStatsForYear.times.push(...statsForYear.times)
|
||||
gradStatsForYear.median = getMedian(gradStatsForYear.times)
|
||||
gradStatsForYear.amount += statsForYear.amount
|
||||
gradStatsForYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
gradStatsForYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
gradStatsForYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
}
|
||||
|
||||
const getStatsByGraduationYear = async (
|
||||
facultyProgrammes: ProgrammesOfOrganization
|
||||
): Promise<GraduationStatistics['byGradYear']> => {
|
||||
const newStats: Pick<ProgrammeGraduationStats, 'id' | 'graduationTimes' | 'comboTimes'>[] = []
|
||||
const medians: MediansByCategory = { bachelor: [], master: [], bcMsCombo: [], doctor: [] }
|
||||
const programmes: { medians: MediansByProgrammes } = {
|
||||
medians: {
|
||||
bachelor: {},
|
||||
master: {},
|
||||
bcMsCombo: {},
|
||||
doctor: {},
|
||||
},
|
||||
}
|
||||
|
||||
for (const programme of facultyProgrammes) {
|
||||
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
|
||||
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
|
||||
continue
|
||||
}
|
||||
const statsFromRedis = await getGraduationStats(programme.code, '', 'CALENDAR_YEAR', 'SPECIAL_INCLUDED')
|
||||
@@ -50,92 +93,44 @@ const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganizat
|
||||
}
|
||||
|
||||
for (const { id: programmeCode, graduationTimes, comboTimes } of newStats) {
|
||||
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
|
||||
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
|
||||
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
|
||||
|
||||
if (!medians[level]) {
|
||||
medians[level] = cloneDeep(graduationTimes.medians).map(year => ({ ...omit(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 })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 'master' && comboTimes.medians.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboTimes.medians).map(year => ({ ...omit(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 })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!programmes.medians[level]) {
|
||||
programmes.medians[level] = {}
|
||||
}
|
||||
|
||||
for (const year of graduationTimes.medians) {
|
||||
if (year.y === 0) continue
|
||||
if (!programmes.medians[level][year.name]) {
|
||||
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'])
|
||||
)
|
||||
programmes.medians[level][year.name].programmes.push(code)
|
||||
mergeStats(medians, level, year)
|
||||
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], year)
|
||||
}
|
||||
|
||||
if (level === 'master' && comboTimes.medians.length) {
|
||||
if (!programmes.medians.bcMsCombo) {
|
||||
programmes.medians.bcMsCombo = {}
|
||||
}
|
||||
for (const year of comboTimes.medians) {
|
||||
if (year.y === 0) continue
|
||||
if (!programmes.medians.bcMsCombo[year.name]) {
|
||||
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'])
|
||||
)
|
||||
programmes.medians.bcMsCombo[year.name].programmes.push(code)
|
||||
mergeStats(medians, 'bcMsCombo', year)
|
||||
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, year)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { medians, programmes }
|
||||
}
|
||||
|
||||
const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
|
||||
const newStats: Array<Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>> = []
|
||||
const medians: Record<string, LevelGraduationStats[]> = {}
|
||||
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
|
||||
const classSizes: Record<string, Record<string, number | Record<string, number>>> = { programmes: {} }
|
||||
const getStatsByStartYear = async (
|
||||
facultyProgrammes: ProgrammesOfOrganization
|
||||
): Promise<GraduationStatistics['byStartYear'] & Pick<GraduationStatistics, 'classSizes'>> => {
|
||||
const classSizes: ClassSizes = { bachelor: {}, master: {}, bcMsCombo: {}, doctor: {}, programmes: {} }
|
||||
const newStats: Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>[] = []
|
||||
const medians: MediansByCategory = { bachelor: [], master: [], bcMsCombo: [], doctor: [] }
|
||||
const programmes: { medians: MediansByProgrammes } = {
|
||||
medians: {
|
||||
bachelor: {},
|
||||
master: {},
|
||||
bcMsCombo: {},
|
||||
doctor: {},
|
||||
},
|
||||
}
|
||||
|
||||
for (const programme of facultyProgrammes) {
|
||||
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
|
||||
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
|
||||
continue
|
||||
}
|
||||
const statsFromRedis = await getStudyTrackStats(programme.code, '', 'SPECIAL_EXCLUDED')
|
||||
const statsFromRedis = await getStudyTrackStats(programme.code, null, 'SPECIAL_EXCLUDED')
|
||||
if (statsFromRedis) {
|
||||
newStats.push(statsFromRedis)
|
||||
continue
|
||||
@@ -155,9 +150,13 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
}
|
||||
|
||||
for (const { id: programmeCode, graduationTimes } of newStats) {
|
||||
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
|
||||
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
|
||||
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
|
||||
if (!(programmeCode in graduationTimes)) continue
|
||||
|
||||
if (!(programmeCode in graduationTimes)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const statsForProgramme = graduationTimes[programmeCode]
|
||||
const basicStats = statsForProgramme.medians.basic
|
||||
const comboStats = statsForProgramme.medians.combo
|
||||
@@ -165,88 +164,31 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
(acc, year) => ({ ...acc, [year.name]: year.classSize ?? 0 }),
|
||||
{}
|
||||
)
|
||||
classSizes.programmes[code] = classSizesByYear
|
||||
classSizes.programmes[programmeCode] = classSizesByYear
|
||||
|
||||
if (!medians[level]) {
|
||||
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
}
|
||||
classSizes[level] ??= {}
|
||||
for (const statsForYear of basicStats) {
|
||||
classSizes[level][statsForYear.name] ??= 0
|
||||
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize ?? 0
|
||||
|
||||
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
mergeStats(medians, level, statsForYear, classSizes)
|
||||
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], statsForYear)
|
||||
}
|
||||
|
||||
if (level === 'master' && comboStats.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
}
|
||||
classSizes.bcMsCombo ??= {}
|
||||
for (const statsForYear of comboStats) {
|
||||
classSizes.bcMsCombo[statsForYear.name] ??= 0
|
||||
;(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 })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
}
|
||||
|
||||
if (!programmes.medians[level]) {
|
||||
programmes.medians[level] = {}
|
||||
}
|
||||
|
||||
for (const year of basicStats) {
|
||||
if (year.y === 0) continue
|
||||
if (!programmes.medians[level][year.name]) {
|
||||
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'])
|
||||
)
|
||||
programmes.medians[level][year.name].programmes.push(code)
|
||||
}
|
||||
|
||||
if (level === 'master' && comboStats.length) {
|
||||
if (!programmes.medians.bcMsCombo) {
|
||||
programmes.medians.bcMsCombo = {}
|
||||
}
|
||||
for (const year of comboStats) {
|
||||
if (year.y === 0) continue
|
||||
if (!programmes.medians.bcMsCombo[year.name]) {
|
||||
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'])
|
||||
)
|
||||
programmes.medians.bcMsCombo[year.name].programmes.push(code)
|
||||
mergeStats(medians, 'bcMsCombo', statsForYear, classSizes)
|
||||
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, statsForYear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { medians, programmes, classSizes }
|
||||
}
|
||||
|
||||
export const countGraduationTimes = async (faculty: string, programmesOfFaculty: ProgrammesOfOrganization) => {
|
||||
export const countGraduationTimes = async (
|
||||
facultyCode: string,
|
||||
programmesOfFaculty: ProgrammesOfOrganization
|
||||
): Promise<FacultyGraduationStatistics> => {
|
||||
const { medians: mediansByGraduationYear, programmes: programmesByGraduationYear } =
|
||||
await getStatsByGraduationYear(programmesOfFaculty)
|
||||
|
||||
const {
|
||||
medians: mediansByStartYear,
|
||||
programmes: programmesByStartYear,
|
||||
@@ -255,7 +197,7 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
|
||||
|
||||
const goals = {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
|
||||
bcMsCombo: facultyCode === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
exceptions: {
|
||||
@@ -279,7 +221,7 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
|
||||
}, {})
|
||||
|
||||
return {
|
||||
id: faculty,
|
||||
id: facultyCode,
|
||||
goals,
|
||||
byGradYear: {
|
||||
medians: mediansByGraduationYear,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { FacultyGraduationStatistics, Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { DegreeProgramme } from '../../routes/faculties'
|
||||
import { redisClient } from '../redis'
|
||||
import { FacultyProgressData } from './facultyStudentProgress'
|
||||
@@ -29,16 +29,16 @@ const createRedisKeyForFacultyStudents = (id: string, specialGroups: SpecialGrou
|
||||
return `FACULTY_STUDENTS_STATS_${id}_${specialGroups}`
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
Faculty data objects have graduation times left in as arrays, so that
|
||||
university-level evaluation overview can count median-times by itself.
|
||||
university-level evaluation overview can count median-times by itself (in backend).
|
||||
Faculties, however, don't need this, and it isn't needed in frontend
|
||||
for universityview either. This removes them from the object.
|
||||
for university view either. This clears the array.
|
||||
*/
|
||||
const removeGraduationTimes = (data: GraduationData) => {
|
||||
const removeGraduationTimes = (data: FacultyGraduationStatistics) => {
|
||||
Object.values(data.byGradYear.medians).forEach(array =>
|
||||
array.forEach(yearStat => {
|
||||
yearStat.times = null
|
||||
yearStat.times = []
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export type GraduationData = {
|
||||
classSizes: Record<string, Record<string, number | Record<string, number>>>
|
||||
}
|
||||
|
||||
export const setGraduationStats = async (data: GraduationData, programmeFilter: ProgrammeFilter) => {
|
||||
export const setGraduationStats = async (data: FacultyGraduationStatistics, programmeFilter: ProgrammeFilter) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForGraduationTimeStats(id, programmeFilter)
|
||||
await redisClient.set(redisKey, JSON.stringify(data))
|
||||
@@ -170,7 +170,7 @@ export const getGraduationStats = async (id: string, programmeFilter: ProgrammeF
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
}
|
||||
const data = JSON.parse(dataFromRedis) as GraduationData
|
||||
const data = JSON.parse(dataFromRedis) as FacultyGraduationStatistics
|
||||
if (!keepGraduationTimes) {
|
||||
removeGraduationTimes(data)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const GraduationTarget = {
|
||||
FOUR_YEARS: 8,
|
||||
FIVE_YEARS: 10,
|
||||
SIX_YEARS: 12,
|
||||
} as const
|
||||
}
|
||||
|
||||
/** Uses semesters, not months */
|
||||
export const countTimeCategories = (times: number[], target: number) => {
|
||||
|
||||
@@ -106,8 +106,8 @@ export const computeLanguageCenterData = async () => {
|
||||
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'studyright_id', 'state'],
|
||||
where: {
|
||||
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
|
||||
semestercode: { [Op.gte]: autumnSemester2017 },
|
||||
state: { [Op.in]: ['ENROLLED', 'REJECTED'] },
|
||||
// This doesn't need the `enrollmentTimeDateThreshold`, ColorizedDataTable doesn't use studyright_ids
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
@@ -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,33 +0,0 @@
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel } from '../../models'
|
||||
|
||||
export const formatCourseInfo = (course: CourseModel) => {
|
||||
return {
|
||||
label: course.code,
|
||||
name: course.name,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatOpenCredits = (credit: CreditModel) => {
|
||||
return {
|
||||
attainmentCourseCode: credit.course_code,
|
||||
attainmentDate: credit.attainment_date,
|
||||
attainmentGrade: credit.grade,
|
||||
attainmentStudentNumber: credit.student_studentnumber,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatOpenEnrollments = (enrollment: EnrollmentModel) => {
|
||||
return {
|
||||
enrollmentCourseCode: enrollment.course_code,
|
||||
enrollmentDateTime: enrollment.enrollment_date_time,
|
||||
enrollmentStudentNumber: enrollment.studentnumber,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatStudentInfo = (student: StudentModel) => {
|
||||
return {
|
||||
studentNumber: student.studentnumber,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export type FoundSearch = {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export const getOpenUniSearches = async (userId: string) => {
|
||||
export const getUserSearches = async (userId: string) => {
|
||||
const savedSearches = await getOpenUniSearchesByUser(userId)
|
||||
if (savedSearches === undefined) {
|
||||
return []
|
||||
@@ -34,7 +34,7 @@ export const getOpenUniSearches = async (userId: string) => {
|
||||
return foundSearches
|
||||
}
|
||||
|
||||
export const createNewSearch = async (userId: string, name: string, courseCodes: string[]) => {
|
||||
export const createNewUserSearch = async (userId: string, name: string, courseCodes: string[]) => {
|
||||
const savedSearches = await getOpenUniSearchesByUser(userId)
|
||||
if (savedSearches?.some(search => search.name === name)) {
|
||||
return null
|
||||
@@ -51,7 +51,7 @@ export const createNewSearch = async (userId: string, name: string, courseCodes:
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteSearch = async (userId: string, id: string) => {
|
||||
export const deleteUserSearch = async (userId: string, id: string) => {
|
||||
try {
|
||||
const deleted = await deleteOpenUniSearch(userId, id)
|
||||
if (!deleted) {
|
||||
@@ -64,7 +64,7 @@ export const deleteSearch = async (userId: string, id: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSearch = async (userId: string, id: string, courseCodes: string[]) => {
|
||||
export const updateUserSearch = async (userId: string, id: string, courseCodes: string[]) => {
|
||||
try {
|
||||
const updated = await updateOpenUniPopulationSearch(userId, id, courseCodes)
|
||||
if (!updated) {
|
||||
|
||||
@@ -1,94 +1,4 @@
|
||||
import { Op, WhereOptions } from 'sequelize'
|
||||
|
||||
import { SISStudyRight } from '@oodikone/shared/models'
|
||||
import { EnrollmentState, ExtentCode } from '@oodikone/shared/types'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, SISStudyRightModel } from '../../models'
|
||||
import { OpenUniPopulationSearchModel } from '../../models/kone'
|
||||
import { formatCourseInfo, formatOpenCredits, formatOpenEnrollments, formatStudentInfo } from './format'
|
||||
|
||||
export const getCredits = async (courseCodes: string[], startdate: Date) =>
|
||||
(
|
||||
await CreditModel.findAll({
|
||||
attributes: ['attainment_date', 'course_code', 'grade', 'student_studentnumber'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: startdate,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(formatOpenCredits)
|
||||
|
||||
export const getStudentInfo = async (studentNumbers: string[]) =>
|
||||
(
|
||||
await StudentModel.findAll({
|
||||
attributes: ['studentnumber', 'email', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(formatStudentInfo)
|
||||
|
||||
export const getEnrollments = async (courseCodes: string[], startDate: Date, endDate: Date) =>
|
||||
(
|
||||
await EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
is_open: true,
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.lte]: endDate,
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
})
|
||||
).map(formatOpenEnrollments)
|
||||
|
||||
export const getCourseNames = async (courseCodes: string[]) =>
|
||||
(
|
||||
await CourseModel.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(formatCourseInfo)
|
||||
|
||||
export const getStudyRights = async (studentNumbers: string[]) => {
|
||||
const where: WhereOptions<SISStudyRight> = {
|
||||
extentCode: {
|
||||
[Op.in]: [
|
||||
ExtentCode.BACHELOR,
|
||||
ExtentCode.BACHELOR_AND_MASTER,
|
||||
ExtentCode.MASTER,
|
||||
ExtentCode.LICENTIATE,
|
||||
ExtentCode.DOCTOR,
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
if (studentNumbers.length > 0) {
|
||||
where.studentNumber = {
|
||||
[Op.in]: studentNumbers,
|
||||
}
|
||||
}
|
||||
|
||||
return await SISStudyRightModel.findAll({
|
||||
attributes: ['startDate', 'endDate', 'studentNumber'],
|
||||
where,
|
||||
})
|
||||
}
|
||||
|
||||
export const getOpenUniSearchesByUser = async (userId: string) => {
|
||||
return await OpenUniPopulationSearchModel.findAll({
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { SISStudyRightModel } from '../../models'
|
||||
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
|
||||
|
||||
type CourseInfo = {
|
||||
status: {
|
||||
passed: Date | null
|
||||
failed: Date | null
|
||||
unfinished: Date | null
|
||||
}
|
||||
}
|
||||
|
||||
type StudentStats = Record<
|
||||
string,
|
||||
{
|
||||
courseInfo: Record<string, CourseInfo>
|
||||
email: string
|
||||
secondaryEmail: string
|
||||
totals: {
|
||||
passed: number
|
||||
failed: number
|
||||
unfinished: number
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
const uniq = objects => [...new Set(objects)]
|
||||
|
||||
const calculateTotalsForStudent = (studentStats: StudentStats, studentNumber: string) => {
|
||||
Object.keys(studentStats[studentNumber].courseInfo).forEach(course => {
|
||||
const { status } = studentStats[studentNumber].courseInfo[course]
|
||||
if (status.passed) {
|
||||
studentStats[studentNumber].totals.passed += 1
|
||||
} else if (status.failed) {
|
||||
studentStats[studentNumber].totals.failed += 1
|
||||
} else if (status.unfinished) {
|
||||
studentStats[studentNumber].totals.unfinished += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getAllCourseCodes = (courseCodes: string[]) => {
|
||||
const ayCourseCodes = courseCodes.map(courseCode => `AY${courseCode}`)
|
||||
return courseCodes.concat(ayCourseCodes)
|
||||
}
|
||||
|
||||
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
|
||||
return startDate <= studyRight.startDate && studyRight.startDate <= new Date()
|
||||
}
|
||||
|
||||
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
|
||||
return studyRight.startDate <= startDate && new Date() <= studyRight.endDate
|
||||
}
|
||||
|
||||
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => studyRight.endDate <= new Date()
|
||||
|
||||
const getEmptyCourseInfo = (): CourseInfo => ({
|
||||
status: {
|
||||
passed: null,
|
||||
failed: null,
|
||||
unfinished: null,
|
||||
},
|
||||
})
|
||||
|
||||
const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
|
||||
if (!courseInfo.status.passed || dayjs(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
|
||||
courseInfo.status.passed = attainmentDate
|
||||
}
|
||||
}
|
||||
|
||||
const updateFailedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
|
||||
if (
|
||||
!courseInfo.status.passed &&
|
||||
(!courseInfo.status.failed || dayjs(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
|
||||
) {
|
||||
courseInfo.status.failed = attainmentDate
|
||||
}
|
||||
}
|
||||
|
||||
const updateUnfinishedStatus = (courseInfo: CourseInfo, enrollmentDateTime: Date) => {
|
||||
if (
|
||||
!courseInfo.status.passed &&
|
||||
!courseInfo.status.failed &&
|
||||
(!courseInfo.status.unfinished || dayjs(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
|
||||
) {
|
||||
courseInfo.status.unfinished = enrollmentDateTime
|
||||
}
|
||||
}
|
||||
|
||||
export const getCustomOpenUniCourses = async (courseCodes: string[], startDate: Date, endDate: Date) => {
|
||||
const allCourseCodes = getAllCourseCodes(courseCodes)
|
||||
const allCredits = await getCredits(allCourseCodes, startDate)
|
||||
const allEnrollments = await getEnrollments(allCourseCodes, startDate, endDate)
|
||||
const courses = await getCourseNames(courseCodes)
|
||||
const studentNumbers = uniq(allEnrollments.map(enrollment => enrollment.enrollmentStudentNumber)) as string[]
|
||||
const allStudyRights = await getStudyRights(studentNumbers)
|
||||
const studentInfo = await getStudentInfo(studentNumbers)
|
||||
|
||||
const passedGrades = ['1', '2', '3', '4', '5', 'Hyv.', 'hyv.', 'HT', 'TT']
|
||||
const failedGrades = ['Hyl.', 'HYL', '0']
|
||||
|
||||
const studentsWithCurrentStudyRight = allStudyRights
|
||||
.filter(
|
||||
studyRight =>
|
||||
isStartDateOutsideInterval(studyRight, startDate) ||
|
||||
isStartDateInsideAndEndDateOutside(studyRight, startDate) ||
|
||||
isEndDateBeforeNow(studyRight)
|
||||
)
|
||||
.map(studyRight => studyRight.studentNumber)
|
||||
|
||||
const uniqueStudentsWithCurrentStudyRight = uniq(studentsWithCurrentStudyRight)
|
||||
|
||||
const studentStats: StudentStats = {}
|
||||
|
||||
for (const { studentNumber, email, secondaryEmail } of studentInfo) {
|
||||
if (uniqueStudentsWithCurrentStudyRight.includes(studentNumber)) {
|
||||
continue
|
||||
}
|
||||
if (!(studentNumber in studentStats)) {
|
||||
studentStats[studentNumber] = {
|
||||
courseInfo: courseCodes.reduce((acc, code) => ({ ...acc, [code.replace('AY', '')]: getEmptyCourseInfo() }), {}),
|
||||
email,
|
||||
secondaryEmail,
|
||||
totals: { passed: 0, failed: 0, unfinished: 0 },
|
||||
}
|
||||
}
|
||||
const studentCourses = studentStats[studentNumber].courseInfo
|
||||
for (const { attainmentCourseCode, attainmentDate, attainmentStudentNumber, attainmentGrade } of allCredits) {
|
||||
if (attainmentStudentNumber === studentNumber) {
|
||||
const courseCode = attainmentCourseCode.replace('AY', '')
|
||||
if (passedGrades.includes(attainmentGrade)) {
|
||||
updatePassedStatus(studentCourses[courseCode], attainmentDate)
|
||||
} else if (failedGrades.includes(attainmentGrade)) {
|
||||
updateFailedStatus(studentCourses[courseCode], attainmentDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { enrollmentStudentNumber, enrollmentCourseCode, enrollmentDateTime } of allEnrollments) {
|
||||
if (enrollmentStudentNumber === studentNumber) {
|
||||
const courseCode = enrollmentCourseCode.replace('AY', '')
|
||||
updateUnfinishedStatus(studentCourses[courseCode], enrollmentDateTime)
|
||||
}
|
||||
}
|
||||
calculateTotalsForStudent(studentStats, studentNumber)
|
||||
}
|
||||
const openUniStats = { students: studentStats, courses }
|
||||
return openUniStats
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { Op } from 'sequelize'
|
||||
import { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
|
||||
import { dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import {
|
||||
CreditModel,
|
||||
EnrollmentModel,
|
||||
@@ -48,8 +50,8 @@ export type StudentEnrollment = Pick<
|
||||
Enrollment,
|
||||
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber' | 'studyright_id'
|
||||
>
|
||||
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> =>
|
||||
EnrollmentModel.findAll({
|
||||
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> => {
|
||||
return EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber', 'studyright_id'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
@@ -57,11 +59,12 @@ export const getEnrollments = (studentNumbers: string[], startDate: string): Pro
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: {
|
||||
[Op.gte]: startDate,
|
||||
[Op.gte]: dateMaxFromList(new Date(startDate), enrollmentTimeDateThreshold),
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
}
|
||||
|
||||
type StudentPersonalData = Pick<
|
||||
Student,
|
||||
|
||||
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
|
||||
|
||||
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
|
||||
attributes: ['code', 'name', 'substitution_groups', 'is_study_module'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getOpenUniCourseCode } from '../../src/util'
|
||||
import { CourseModel, OrganizationModel } from '../models'
|
||||
|
||||
export const getAllProviders = async () => {
|
||||
const providers = OrganizationModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['code', 'name'],
|
||||
})
|
||||
return providers
|
||||
@@ -9,7 +11,8 @@ export const getAllProviders = async () => {
|
||||
|
||||
export const getCourseCodesOfProvider = async (provider: string) => {
|
||||
const coursesByProvider = await CourseModel.findAll({
|
||||
attributes: ['id', 'code', 'substitutions'],
|
||||
raw: true,
|
||||
attributes: ['id', 'code', 'substitution_groups'],
|
||||
include: {
|
||||
model: OrganizationModel,
|
||||
required: true,
|
||||
@@ -22,12 +25,13 @@ export const getCourseCodesOfProvider = async (provider: string) => {
|
||||
},
|
||||
})
|
||||
|
||||
const coursesWithOpenUniSubstitutions = coursesByProvider.map(({ code, substitutions }) => {
|
||||
if (!substitutions?.length) {
|
||||
const coursesWithOpenUniSubstitutions = coursesByProvider.map(({ code, substitution_groups }) => {
|
||||
if (!substitution_groups?.length) {
|
||||
return [code]
|
||||
}
|
||||
const alternatives = [`AY-${code}`, `AY${code}`, `A-${code}`]
|
||||
return [code].concat(substitutions.filter(sub => alternatives.includes(sub)))
|
||||
// This is ok to flatten, because we only want all credits completed by students, not what courses are
|
||||
// completed and how => we don't care about substitutions
|
||||
return [code].concat(substitution_groups.filter(group => group.some(code => getOpenUniCourseCode(code))).flat())
|
||||
})
|
||||
|
||||
return coursesWithOpenUniSubstitutions.flat()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Name } from '@oodikone/shared/types'
|
||||
import { dateMinFromList, dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import { SemesterModel } from '../models'
|
||||
|
||||
type SemestersAndYears = {
|
||||
export type SemestersAndYears = {
|
||||
years: Record<
|
||||
string,
|
||||
{
|
||||
@@ -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,44 @@ 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
|
||||
}
|
||||
|
||||
export const nukeRedis = async () => {
|
||||
const response = await client.get('/nuke_redis', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -54,6 +59,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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import type { Credit, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
|
||||
import { FormattedStudentForSearch, StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import { enrollmentTimeDateThreshold, splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import { dbConnections } from '../database/connection'
|
||||
import {
|
||||
StudentModel,
|
||||
@@ -118,7 +119,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 +170,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,15 +181,15 @@ 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.enrollment_date_time >= '${dayjs(enrollmentTimeDateThreshold).format('YYYY-MM-DD')}'
|
||||
AND e.state = :enrollmentState
|
||||
);
|
||||
`,
|
||||
{
|
||||
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) => {
|
||||
|
||||
@@ -2,13 +2,15 @@ import { memoize } from 'lodash-es'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { Name, CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
|
||||
import { dateIsBetween } from '@oodikone/shared/util/datetime'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, ProgrammeModuleModel } from '../../models'
|
||||
import logger from '../../util/logger'
|
||||
|
||||
export const getAllProgrammeCourses = async (providerCode: string) => {
|
||||
const courses = await CourseModel.findAll({
|
||||
attributes: ['id', 'code', 'name', 'substitutions'],
|
||||
const courses: Pick<CourseModel, 'id' | 'code' | 'name' | 'substitution_groups'>[] = await CourseModel.findAll({
|
||||
attributes: ['id', 'code', 'name', 'substitution_groups'],
|
||||
include: [
|
||||
{
|
||||
model: OrganizationModel,
|
||||
@@ -27,7 +29,7 @@ export const getAllProgrammeCourses = async (providerCode: string) => {
|
||||
return courses
|
||||
}
|
||||
|
||||
const getCourseCode = (code: string) => {
|
||||
const removeOpenUniCodePrefix = (code: string) => {
|
||||
if (code.startsWith('AY')) {
|
||||
return code.replace('AY', '')
|
||||
}
|
||||
@@ -39,14 +41,23 @@ const getCourseCode = (code: string) => {
|
||||
|
||||
export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
|
||||
try {
|
||||
const enrollmentsCourses = await EnrollmentModel.findAll({
|
||||
attributes: ['studentnumber', 'course_code'],
|
||||
const enrollmentsCourses: Array<
|
||||
Pick<EnrollmentModel, 'studentnumber' | 'course_code' | 'enrollment_date_time' | 'course'>
|
||||
> = await EnrollmentModel.findAll({
|
||||
raw: true,
|
||||
nest: true,
|
||||
attributes: ['studentnumber', 'course_code', 'enrollment_date_time'],
|
||||
include: {
|
||||
model: CourseModel,
|
||||
attributes: ['is_study_module'],
|
||||
},
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: programmeCourses,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.between]: [from, to],
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
@@ -54,27 +65,36 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
|
||||
const allEnrollments = new Map<string, Set<string>>()
|
||||
for (const { studentnumber, course_code: courseCode } of enrollmentsCourses) {
|
||||
const code = getCourseCode(courseCode)
|
||||
const code = removeOpenUniCodePrefix(courseCode)
|
||||
if (!allEnrollments.has(code)) {
|
||||
allEnrollments.set(code, new Set())
|
||||
}
|
||||
allEnrollments.get(code)!.add(studentnumber)
|
||||
}
|
||||
|
||||
const credits = await CreditModel.findAll({
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule'],
|
||||
// TODO: This is being called for each year, could be optimized to call only once
|
||||
const credits: Array<
|
||||
Pick<
|
||||
CreditModel,
|
||||
'course_code' | 'student_studentnumber' | 'credittypecode' | 'isStudyModule' | 'attainment_date'
|
||||
>
|
||||
> = await CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule', 'attainment_date'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: programmeCourses,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.between]: [from, to],
|
||||
},
|
||||
},
|
||||
order: [['credittypecode', 'ASC']], // Passed credits for a student have to be handled first
|
||||
})
|
||||
|
||||
// Pick only this (between to, from) year's credits
|
||||
const filteredCredits = credits.filter(credit => dateIsBetween(credit.attainment_date, from, to))
|
||||
|
||||
const courseCodeToName = (
|
||||
await CourseModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['code', 'name'],
|
||||
where: {
|
||||
code: programmeCourses,
|
||||
@@ -85,8 +105,8 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
return acc
|
||||
}, new Map())
|
||||
|
||||
const creditCourses = credits.map(credit => ({
|
||||
code: getCourseCode(credit.course_code),
|
||||
const creditCourses = filteredCredits.map(credit => ({
|
||||
code: removeOpenUniCodePrefix(credit.course_code),
|
||||
studentNumber: credit.student_studentnumber,
|
||||
creditTypeCode: credit.credittypecode,
|
||||
courseName: courseCodeToName.get(credit.course_code)!,
|
||||
@@ -97,6 +117,15 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
const notCompletedByCourseCodes = new Map<string, Set<string>>()
|
||||
const courses = new Map<string, { name: Name; isStudyModule: boolean }>()
|
||||
|
||||
const studentHasPassedCourse = (studentNumber: string, courseCode: string) =>
|
||||
!!credits.find(
|
||||
credit =>
|
||||
credit.student_studentnumber === studentNumber &&
|
||||
credit.course_code === courseCode &&
|
||||
CreditModel.passed(credit)
|
||||
) ||
|
||||
(passedByCourseCodes.get(courseCode)?.has(studentNumber) ?? false)
|
||||
|
||||
for (const { code, courseName, creditTypeCode, isStudyModule, studentNumber } of creditCourses) {
|
||||
if (!courses.has(code)) {
|
||||
courses.set(code, {
|
||||
@@ -106,29 +135,38 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
passedByCourseCodes.set(code, new Set())
|
||||
notCompletedByCourseCodes.set(code, new Set())
|
||||
}
|
||||
if ([CreditTypeCode.PASSED, CreditTypeCode.IMPROVED, CreditTypeCode.APPROVED].includes(creditTypeCode)) {
|
||||
|
||||
if (CreditModel.passed({ credittypecode: creditTypeCode })) {
|
||||
passedByCourseCodes.get(code)!.add(studentNumber)
|
||||
}
|
||||
if (creditTypeCode === CreditTypeCode.FAILED && !passedByCourseCodes.get(code)!.has(studentNumber)) {
|
||||
if (creditTypeCode === CreditTypeCode.FAILED && !studentHasPassedCourse(studentNumber, code)) {
|
||||
notCompletedByCourseCodes.get(code)!.add(studentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// If student has enrollments, but no attainment for a particular course, they have no credit info.
|
||||
programmeCourses.forEach(courseCode => {
|
||||
allEnrollments.get(courseCode)?.forEach(studentnumber => {
|
||||
if (passedByCourseCodes.get(courseCode)?.has(studentnumber)) {
|
||||
notCompletedByCourseCodes.get(courseCode)?.add(studentnumber)
|
||||
}
|
||||
})
|
||||
})
|
||||
// Add course details to courseMap if there are only enrollments
|
||||
for (const { studentnumber: studentNumber, course_code: courseCode, course } of enrollmentsCourses) {
|
||||
if (!courses.has(courseCode)) {
|
||||
courses.set(courseCode, {
|
||||
isStudyModule: course.is_study_module,
|
||||
name: courseCodeToName.get(courseCode)!,
|
||||
})
|
||||
passedByCourseCodes.set(courseCode, new Set())
|
||||
notCompletedByCourseCodes.set(courseCode, new Set())
|
||||
}
|
||||
|
||||
return [...courses.entries()].map(([code, { isStudyModule, name }]) => ({
|
||||
code,
|
||||
name,
|
||||
isStudyModule,
|
||||
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
|
||||
}))
|
||||
if (!studentHasPassedCourse(studentNumber, courseCode)) {
|
||||
notCompletedByCourseCodes.get(courseCode)!.add(studentNumber)
|
||||
}
|
||||
}
|
||||
return [...courses.entries()].map(([code, { isStudyModule, name }]) => {
|
||||
return {
|
||||
code,
|
||||
name,
|
||||
isStudyModule,
|
||||
allNotPassed: notCompletedByCourseCodes.get(code)?.size ?? 0,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`getNotCompletedForProgrammeCourses failed ${error}`)
|
||||
return []
|
||||
|
||||
@@ -95,7 +95,7 @@ export const getProgrammeCourseAggregates = async (params: {
|
||||
WHERE
|
||||
cr.course_code IN (:courseCodes)
|
||||
AND cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.credittypecode = :creditType
|
||||
AND cr.credittypecode IN (:creditTypes)
|
||||
)
|
||||
SELECT
|
||||
fc."courseCode",
|
||||
@@ -176,7 +176,7 @@ export const getProgrammeCourseAggregates = async (params: {
|
||||
courseCodes,
|
||||
from,
|
||||
to,
|
||||
creditType: CreditTypeCode.PASSED,
|
||||
creditTypes: [CreditTypeCode.PASSED, CreditTypeCode.APPROVED],
|
||||
degreeExtentCodes,
|
||||
otherUniversityExtentCodes,
|
||||
separateStudyExtentCodes,
|
||||
|
||||
@@ -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 '.'
|
||||
@@ -28,8 +26,9 @@ const getAllStudyProgrammeCourses = async (studyProgramme: string) => {
|
||||
|
||||
for (const course of normalCourses) {
|
||||
courseCodes.add(course.code)
|
||||
if (course.substitutions?.includes(`AY${course.code}`)) {
|
||||
courseCodes.add(`AY${course.code}`)
|
||||
// We want all AY course codes variants, even from partially completed susbtitution groups
|
||||
for (const code of course?.substitution_groups?.flat() ?? []) {
|
||||
if (code === 'AY' + course.code) courseCodes.add(code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const getStudyProgrammeStatsForColorizedCoursesTable = async (studyProgra
|
||||
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'state'],
|
||||
where: {
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
semestercode: { [Op.gte]: autumnSemester2017 },
|
||||
semestercode: { [Op.gte]: autumnSemester2017 }, // It is ok to use enrollments before OK sisu-migration, ColorizedTableData doesn't need it
|
||||
state: { [Op.in]: [EnrollmentState.ENROLLED, EnrollmentState.REJECTED] },
|
||||
},
|
||||
raw: true,
|
||||
|
||||
@@ -3,7 +3,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import { indexOf, orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
GraduationStatistics,
|
||||
GraduationTimeCategories,
|
||||
Name,
|
||||
SemesterEnrollment,
|
||||
StudyTrack,
|
||||
@@ -118,7 +118,7 @@ export type GraduationTimes = {
|
||||
y: number
|
||||
amount: number
|
||||
name: string | number
|
||||
statistics: GraduationStatistics
|
||||
statistics: GraduationTimeCategories
|
||||
times: number[]
|
||||
}>
|
||||
goal: number
|
||||
|
||||
@@ -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 (
|
||||
@@ -65,7 +65,7 @@ export const getStudyRightsInProgramme = async (
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(studyRight => studyRight.toJSON())
|
||||
).map(studyRight => studyRight.toJSON<SISStudyRight>())
|
||||
}
|
||||
|
||||
export const getStudyTracksForProgramme = async (studyProgramme: string) => {
|
||||
@@ -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,41 @@ 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)
|
||||
|
||||
/* Stats for "Students of the degree programme by starting year" */
|
||||
updateCounts(
|
||||
startYear,
|
||||
studyProgramme,
|
||||
@@ -380,18 +388,78 @@ const getMainStatsByTrackAndYear = async (
|
||||
)
|
||||
}
|
||||
|
||||
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
|
||||
// TODO: FIXME: This could be written better
|
||||
/* Stats for "Monthly credit accumulation of the students by starting year" */
|
||||
/* DILEMMA: if viewing a master's programme, students with bs+ms study right, but bs start date before 2017-2018
|
||||
do not belong in any category. Other option would be to show bs+ms based on their start date in master's studies,
|
||||
but then we would have (worst case) 50-60 years of bachelors studies in prior to show for some old study rights.
|
||||
*/
|
||||
if (doCombo && !years.includes(bachelorsStartYear)) continue
|
||||
|
||||
if (doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER) {
|
||||
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]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats for Progress of students of the degree programme by starting year" */
|
||||
/* the DILEMMA from above also applies */
|
||||
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) {
|
||||
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 +484,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 +495,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 +518,19 @@ const getMainStatsByTrackAndYear = async (
|
||||
)
|
||||
|
||||
return {
|
||||
monthlyCreditsByStartingYear,
|
||||
monthlyCreditsByStartingYearByTrack,
|
||||
monthlyCreditsByStartingYearCombo,
|
||||
monthlyCreditsByStartingYearComboByTrack,
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
otherCountriesCount,
|
||||
creditCounts,
|
||||
creditCountsByTrack,
|
||||
creditCountsCombo,
|
||||
creditCountsComboByTrack,
|
||||
graduatedCount,
|
||||
graduatedCountByTrack,
|
||||
graduationTimes: finalGraduationTimes,
|
||||
graduationTimesSecondProg: finalCombinedGraduationTimes,
|
||||
}
|
||||
@@ -468,12 +549,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 +564,72 @@ export const getStudyTrackStatsForStudyProgramme = async ({
|
||||
combinedProgramme
|
||||
)
|
||||
|
||||
const getCorrectCombinedTitles = () => {
|
||||
if (combinedProgramme === 'MH90_001') return tableTitles.studytracksCombined.licentiate
|
||||
return tableTitles.studytracksCombined.master
|
||||
}
|
||||
const graduatedTitles = combinedProgramme ? getCorrectCombinedTitles() : tableTitles.studytracksBasic
|
||||
const getClassSize = (accumulatedCredits: typeof stats.monthlyCreditsByStartingYear) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(accumulatedCredits).map(([year, values]) => [year, Object.values(values).at(0)?.length ?? 0])
|
||||
)
|
||||
|
||||
const classSizes: StudyTrackStats['classSizes'] = {
|
||||
main: getClassSize(stats.monthlyCreditsByStartingYear),
|
||||
combo: doCombo ? getClassSize(stats.monthlyCreditsByStartingYearCombo) : undefined,
|
||||
byTrack: hasStudyTracks
|
||||
? Object.fromEntries(
|
||||
Object.keys(stats.monthlyCreditsByStartingYearByTrack).map(track => [
|
||||
track,
|
||||
getClassSize(stats.monthlyCreditsByStartingYearByTrack[track]),
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
comboByTrack:
|
||||
doCombo && hasStudyTracks
|
||||
? Object.fromEntries(
|
||||
Object.keys(stats.monthlyCreditsByStartingYearComboByTrack).map(track => [
|
||||
track,
|
||||
getClassSize(stats.monthlyCreditsByStartingYearComboByTrack[track]),
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
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
|
||||
? 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,
|
||||
classSizes,
|
||||
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 (
|
||||
@@ -145,7 +152,7 @@ const updateAccessGroups = async (
|
||||
|
||||
userFromDb: UserModel
|
||||
) => {
|
||||
const { jory, superAdmin, openUni, fullSisuAccess } = specialGroup
|
||||
const { jory, superAdmin, fullSisuAccess } = specialGroup
|
||||
const currentAccessGroups = userFromDb?.roles ?? []
|
||||
|
||||
const newAccessGroups = [
|
||||
@@ -156,11 +163,10 @@ const updateAccessGroups = async (
|
||||
...(jory || iamGroups.includes(facultyStatisticsGroup) ? ['facultyStatistics'] : []),
|
||||
...(currentAccessGroups.includes('teachers') ? ['teachers'] : []),
|
||||
...(superAdmin || currentAccessGroups.includes('admin') ? ['admin'] : []),
|
||||
...(openUni ? ['openUniSearch'] : []),
|
||||
...(fullSisuAccess ? ['fullSisuAccess'] : []),
|
||||
] as Role[]
|
||||
|
||||
if (!isEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
|
||||
if (!arraysAreEqual(newAccessGroups.sort(), currentAccessGroups.sort())) {
|
||||
userFromDb.roles = newAccessGroups
|
||||
await userFromDb.save()
|
||||
}
|
||||
@@ -173,7 +179,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 +200,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 +264,7 @@ export const getUser = async ({
|
||||
sisuPersonId: sisId,
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
|
||||
await updateAccessGroups(iamGroups, specialGroup, sisId, upsertResult)
|
||||
|
||||
const userJSON = upsertResult.toJSON()
|
||||
@@ -272,38 +275,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 requestField ? (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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user