Compare commits

..
26 Commits
Author SHA1 Message Date
Santeri Rajamäki aa53398ed4 [META] Fix Fetchios lint error 2026-04-10 11:42:30 +03:00
Santeri Rajamäki fd52176556 [META] Update Fetchios return value handling 2026-04-10 11:31:45 +03:00
Santeri Rajamäki d2f985e7bf [META] Convert Fetchios to use Result 2026-04-10 10:40:49 +03:00
Santeri Rajamäki 5317d2fa7b [META] Add relative path support for browsers 2026-04-09 15:51:19 +03:00
Aaro Koivukangas f1c981733f [Class stats][Courses of class] Show Master-level modules by fixing incorrect nullish coalesing 2026-04-09 12:02:28 +03:00
Aaro Koivukangas babd14753a [E2E] More page title e2e test fixing 2026-04-08 15:48:57 +03:00
Aaro KoivukangasandSanteri Rajamäki 86a4b5ee17 [E2E] Fix changed titles in tests 2026-04-07 18:00:44 +03:00
Aaro Koivukangas 1c9f5a3d61 [E2E] Fix tests 2026-04-07 13:06:18 +03:00
Aaro Koivukangas 0098f3a488 [Class stats][Courses of class] Change empty fields to be 0, not null 2026-04-07 13:00:52 +03:00
Aaro Koivukangas 0d5f62aeca [Class stats] Improve page title 2026-04-07 13:00:52 +03:00
Santeri Rajamäki 8591e248a2 [META] Update @types/pg 2026-04-07 12:43:10 +03:00
Santeri Rajamäki a6855a3f21 [META] Update lodash-es 2026-04-07 12:42:07 +03:00
Santeri Rajamäki 9845875b36 [META] Update cypress 2026-04-07 12:34:29 +03:00
Santeri Rajamäki 19f5e348aa [META] Update globals 2026-04-07 12:25:50 +03:00
Santeri Rajamäki b23f415024 [META] Update eslint-plugin-import 2026-04-07 12:19:19 +03:00
Santeri Rajamäki d6797fade5 [META] Update stylelint-config-standard 2026-04-07 12:15:37 +03:00
Santeri Rajamäki 45ef565292 [META] Update stylelint 2026-04-07 12:13:58 +03:00
Santeri Rajamäki dd8e0c93c9 [META] Rearrange fetchios url in one-shots 2026-04-07 11:06:26 +03:00
Santeri Rajamäki a4c54748f1 [META] Fix fetchios url appending 2026-04-02 17:42:48 +03:00
Santeri Rajamäki 8846a943fa [META] Add node types to tsconfig 2026-04-02 15:10:19 +03:00
Santeri Rajamäki 7d12e067c5 [Shared] Convert fetchios fetch to globalThis.fetch 2026-04-02 14:37:45 +03:00
Santeri Rajamäki 2ffeb50f30 [META] Change axios to fetchios 2026-04-02 14:29:36 +03:00
Santeri Rajamäki de8413ca68 [Backend] Convert oriProvider to use module exports instead of CJS module.exports 2026-04-02 14:22:51 +03:00
Santeri Rajamäki 96119587d6 [META] Introduce Fetchios 2026-04-02 14:22:51 +03:00
Santeri Rajamäki 4ee1daf24b [Frontend] Remove axios dependency 2026-04-01 15:05:16 +03:00
Santeri Rajamäki 234c8497da [Frontend] Swap api caller from axios to fetch 2026-04-01 15:01:46 +03:00
547 changed files with 11482 additions and 20280 deletions
-41
View File
@@ -1,41 +0,0 @@
name: 'Build backend'
description: 'Build backend in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build backend image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-backend
tags: ${{ inputs.environment }} ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
@@ -1,50 +0,0 @@
name: 'Build frontend'
description: 'Build frontend in staging or production versions'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
sentry_dns:
required: false
description: 'Sentry url to send error logs to (only production, enforced)'
default: ''
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- name: Get current time
id: get_current_time
shell: bash
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: ${{ inputs.environment }} ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_DSN=${{ case(inputs.environment == 'production', inputs.sentry_dns, '') }}
@@ -1,40 +0,0 @@
name: 'Build updater scheduler'
description: 'Build updater scheduler in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build scheduler image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: updater-scheduler
tags: ${{ inputs.environment }} ${{ github.sha }}
context: updater/sis-updater-scheduler
containerfiles: |
updater/sis-updater-scheduler/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
-40
View File
@@ -1,40 +0,0 @@
name: 'Build updater worker'
description: 'Build updater worker in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
outputs:
image:
description: 'Image name provided by buildah action'
value: ${{ steps.build-image-action.outputs.image }}
tags:
description: 'Tag names provided by buildah action'
value: ${{ steps.build-image-action.outputs.tags }}
runs:
using: composite
steps:
- uses: actions/checkout@v6
- name: Build worker image
id: build-image-action
uses: redhat-actions/buildah-build@v2
with:
image: updater-worker
tags: ${{ inputs.environment }} ${{ github.sha }}
context: updater/sis-updater-worker
containerfiles: |
updater/sis-updater-worker/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
@@ -32,7 +32,7 @@ runs:
- name: 'Run cypress'
uses: cypress-io/github-action@v7
with:
browser: firefox
browser: chrome
spec: ${{ inputs.spec }}
env:
CYPRESS_baseUrl: http://localhost:1337
@@ -43,9 +43,8 @@ runs:
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
path: ./cypress/videos/*
retention-days: 7
- name: 'Print logs on failure'
shell: bash
if: failure()
run: npm run logs:ci && sleep 15
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
-37
View File
@@ -1,37 +0,0 @@
name: 'Run api tests'
description: 'Runs api tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run api-tests
shell: bash
run: npm run test:api --prefix ./services/backend
@@ -1,31 +0,0 @@
name: 'Run component tests'
description: 'Runs frontend component tests'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/frontend
- name: Install Playwright Browsers
shell: bash
run: npm exec -- playwright install --with-deps
working-directory: ./services/frontend
- name: Run Playwright tests
shell: bash
run: npm run test:component --prefix ./services/frontend
- uses: actions/upload-artifact@v4
if: |
!cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
-37
View File
@@ -1,37 +0,0 @@
name: 'Run service tests'
description: 'Runs service tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run service-tests
shell: bash
run: npm run test:services --prefix ./services/backend
-22
View File
@@ -1,22 +0,0 @@
name: 'Run unit tests'
description: 'Runs unit tests in backend and frontend'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/backend && npm ci --prefix ./services/frontend
- name: Run frontend unit tests
shell: bash
run: npm run test:unit --prefix ./services/frontend
- name: Run backend unit tests
shell: bash
run: npm run test:unit --prefix ./services/backend
+49 -32
View File
@@ -5,6 +5,45 @@ on:
types: [published]
jobs:
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Get current time'
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
SENTRY_DSN=${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
@@ -16,40 +55,18 @@ jobs:
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: 'Build backend'
- name: Build backend image
id: build-image
uses: ./.github/actions/build/backend
uses: redhat-actions/buildah-build@v2
with:
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: 'Build frontend'
id: build-image
uses: ./.github/actions/build/frontend
with:
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
sentry_dns: ${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }} # Used only in production
image: oodikone-backend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
+31 -34
View File
@@ -5,60 +5,57 @@ on:
branches:
- master
paths:
- '.github/actions/**'
- '.github/workflows/pull_request.yaml'
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'docker-compose.ci.yml'
- 'package*.json'
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_unit:
name: 'Run unit tests'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/test/unit
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:
- uses: actions/checkout@v6
- uses: ./.github/actions/test/api
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- 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.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: Start oodikone detached
run: npm run oodikone:ci:setup
- name: Run api-tests
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'
@@ -81,19 +78,19 @@ 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: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v6
- name: 'Run tests'
uses: ./.github/actions/test/e2e
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
+37 -69
View File
@@ -8,13 +8,13 @@ on:
- '.github/actions/**'
- '.github/workflows/staging.yaml'
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'docker-compose.ci.yml'
- 'package*.json'
jobs:
test:
@@ -23,30 +23,6 @@ jobs:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: 'Build backend'
id: build-image
uses: ./.github/actions/build/backend
with:
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
@@ -55,12 +31,23 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: 'Build frontend'
- 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: ./.github/actions/build/frontend
uses: redhat-actions/buildah-build@v2
with:
environment: 'staging'
release_version: ${{ github.sha }}
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
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -71,44 +58,27 @@ jobs:
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_worker:
name: 'Build and release updater worker'
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: 'Build worker'
- name: Build backend image
id: build-image
uses: ./.github/actions/build/worker
uses: redhat-actions/buildah-build@v2
with:
environment: 'staging'
release_version: ${{ github.sha }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_scheduler:
name: 'Build and release updater scheduler'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: 'Build scheduler'
id: build-image
uses: ./.github/actions/build/scheduler
with:
environment: 'staging'
release_version: ${{ github.sha }}
image: oodikone-backend
tags: staging ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
BASE_PATH=/oodikone
GIT_SHA=${{ github.sha }}
STAGING=true
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -122,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
- release_updater_worker
- release_updater_scheduler
if: success()
steps:
- uses: actions/checkout@v6
@@ -142,12 +111,11 @@ jobs:
report_failure:
name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: ubuntu-latest
if: failure()
needs:
- release_frontend
- release_backend
- release_updater_worker
- release_updater_scheduler
if: failure()
steps:
- uses: actions/checkout@v6
+157 -205
View File
@@ -26,24 +26,48 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build backend
uses: ./.github/actions/build/backend
- uses: docker/setup-buildx-action@v4
- name: 'Build image and export it'
uses: docker/build-push-action@v7
with:
environment: 'staging'
release_version: ${{ github.sha }}
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=staging
context: services
file: services/backend/Dockerfile
push: false
tags: oodikone-backend:staging
outputs: type=docker,dest=/tmp/backend.tar
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
name: backend-image
path: /tmp/backend.tar
build_frontend:
name: 'Build frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build frontend
uses: ./.github/actions/build/frontend
- 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
with:
environment: 'staging'
release_version: ${{ github.sha }}
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
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
@@ -51,12 +75,11 @@ jobs:
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@v4
uses: dorny/paths-filter@v3
with:
filters: |
kone_db_migrations:
@@ -71,16 +94,9 @@ jobs:
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
needs: check_migration_files
runs-on: ubuntu-latest
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.sis_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
@@ -89,108 +105,153 @@ jobs:
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 --quiet-pull sis-updater-worker
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'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.kone_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- 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 --quiet-pull backend --wait
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: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.user_db_migrations == 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- 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 --quiet-pull backend --wait
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
# === Update sis-db with updater ===
check_updater_changed:
name: 'Check if updater has been updated'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
outputs:
has_updater_changed: ${{ steps.check_updater_changed.outputs.has_updater_changed }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: check_updater_changed
with:
filters: |
has_updater_changed:
- 'updater/**'
- '.github/workflows/test.yaml'
- '.github/workflows/staging.yaml'
- 'docker-compose.ci.yml'
run_updater_updates:
name: 'Run updater tests and updates'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure() &&
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
needs.check_migration_files.outputs.kone_db_migrations == 'true' ||
needs.check_migration_files.outputs.user_db_migrations == 'true' ||
needs.check_migration_files.outputs.sis_db_migrations == 'true')
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
needs:
- check_migration_files
- check_updater_changed
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, pull rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Check that exit statuses are not errors'
run: |
docker ps -a
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
echo 'Some process had nonzero exit code';
exit 1;
fi
- name: 'Save Docker images for downstream jobs'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
# === Backend api-tests ===
test_api:
needs:
- test_service_exit_statuses
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
strategy:
fail-fast: false
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
@@ -199,103 +260,18 @@ jobs:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: Start worker containers
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --quiet-pull --detach --wait
- name: Start oodikone (without frontend and backend)
run: npm run oodikone:ci:setup
- name: Run hourly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: Run weekly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: weekly
EXIT_AFTER_IMMEDIATES: yes
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
# - name: Push newly build db to toska hub
# if: success()
# run: ./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
- name: Dump logs on failure
if: failure()
run: npm run logs:ci
# === Backend api-tests ===
test_api:
name: 'Run backend api tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Run api tests
uses: ./.github/actions/test/api
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
#
# === Backend service-tests ===
test_services:
name: 'Run backend service-tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Run service-tests
uses: ./.github/actions/test/services
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
# === Unit testing ===
test_unit:
name: 'Run unit tests'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
steps:
- uses: actions/checkout@v6
- name: Run unit tests (frontend and backend)
uses: ./.github/actions/test/unit
# === Frontend component testing ===
test_components:
name: 'Test frontend components'
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
steps:
- uses: actions/checkout@v6
- name: Run frontend component tests
uses: ./.github/actions/test/component
- name: Run api-tests
run: npm run oodikone:ci:api
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
- test_service_exit_statuses
- test_api
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
strategy:
fail-fast: false
matrix:
@@ -320,17 +296,17 @@ 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: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
- name: 'Test access rights'
spec: cypress/e2e/Routes.js
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -340,43 +316,19 @@ jobs:
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Run tests'
uses: ./.github/actions/test/e2e
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
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: |
!cancelled() && !failure()
needs:
- run_updater_updates
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, build rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
- 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: npm run logs:ci && sleep 15
+27 -29
View File
@@ -5,7 +5,8 @@ on:
types: [published]
paths:
- '.github/workflows/updater.yaml'
- 'updater/**'
- 'updater/sis-updater-worker/**'
- 'updater/sis-updater-scheduler/**'
jobs:
lint_scheduler_and_worker:
@@ -16,7 +17,6 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 24
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
@@ -25,7 +25,6 @@ jobs:
build_worker:
name: 'Build Updater worker'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
@@ -38,18 +37,15 @@ jobs:
tags: toska/sis-updater-worker:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
name: updater-worker-image
path: /tmp/worker.tar
retention-days: 7
build_scheduler:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
@@ -62,13 +58,11 @@ jobs:
tags: toska/sis-updater-scheduler:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
retention-days: 7
test_updater:
name: 'Test Updater'
@@ -77,7 +71,6 @@ jobs:
- lint_scheduler_and_worker
- build_worker
- build_scheduler
steps:
- uses: actions/checkout@v6
- name: 'Login to toska docker registry'
@@ -86,41 +79,34 @@ jobs:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v8
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
- name: 'Load worker image'
run: docker load --input worker.tar
- name: 'Load scheduler image'
run: docker load --input scheduler.tar
- name: 'Start worker'
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull --no-recreate --wait sis-updater-worker
run: docker compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
- name: 'Run hourly jobs'
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run weekly jobs'
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
env:
SCHEDULE_IMMEDIATE: weekly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run prepurge'
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
env:
SCHEDULE_IMMEDIATE: prepurge
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run purge'
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
env:
SCHEDULE_IMMEDIATE: purge
EXIT_AFTER_IMMEDIATES: yes
@@ -132,12 +118,18 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: 'Build updater worker'
- name: Build worker image
id: build-image
uses: ./.github/actions/build/worker
uses: redhat-actions/buildah-build@v2
with:
environment: 'production'
release_version: ${{ github.sha }}
image: updater-worker
tags: staging production ${{ github.sha }}
context: updater/sis-updater-worker
containerfiles: |
updater/sis-updater-worker/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
@@ -155,12 +147,18 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: 'Build udpater scheduler'
- name: Build scheduler image
id: build-image
uses: ./.github/actions/build/scheduler
uses: redhat-actions/buildah-build@v2
with:
environment: 'production'
release_version: ${{ github.sha }}
image: updater-scheduler
tags: staging production ${{ github.sha }}
context: updater/sis-updater-scheduler
containerfiles: |
updater/sis-updater-scheduler/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
+1 -3
View File
@@ -56,7 +56,7 @@ cypress/videos/
coverage/
instrumented/
# Pycharm
# Pycharm
.idea
# VS Code
@@ -68,5 +68,3 @@ instrumented/
# Target of shared symlink
services/**/src/shared
!services/backend/.env.test
-3
View File
@@ -1,3 +0,0 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+6 -20
View File
@@ -1,5 +1,4 @@
# Oodikone
[![Maintainability](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone/maintainability.svg)](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
@@ -123,8 +122,6 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run oodikone`: starts Oodikone with anonymized data
- `npm run oodikone:real`: starts Oodikone with real data
- `npm run oodikone:test`: starts Oodikone with non-persistent database (wipes after every reset)
- `npm run oodikone:ci`: starts Oodikone in CI mode
- `npm run updater`: starts updater with anonymized data
- `npm run updater:real`: starts updater with real data
- `npm run both`: starts Oodikone and updater with anonymized data
@@ -132,6 +129,7 @@ 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`
@@ -151,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 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
- `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
### User types for development
@@ -189,11 +187,13 @@ 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
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Prefer `npm run ci:docker` or `npm run install:docker`, which also fix `node_modules` ownership for the non-root runtime user. If you run installs manually, use root inside the container and restore ownership afterwards (`chown -R 1001:0 node_modules`) to avoid permission issues.
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container or `npm run install:docker` to install modules inside all packages (backend, frontend, sis-updater-scheduler and sis-updater-worker).
### Study guidance groups don't work on my machine
@@ -240,20 +240,6 @@ 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
+1 -1
View File
@@ -43,6 +43,6 @@ fi
infomsg "Committing and pushing to registry"
database=${args[0]}
docker commit "oodikone-$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
docker commit "$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"
+5 -5
View File
@@ -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 "oodikone-$database" /bin/bash -c "pg_restore --username=postgres \
retry docker-compose exec "$database" pg_isready --dbname="$database"
docker exec -i "$database" /bin/bash -c "pg_restore --username=postgres \
--format=custom --dbname=$database --no-owner" < "$database_dump"
successmsg "Database $database succesfully created"
+12 -29
View File
@@ -1,57 +1,40 @@
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
kone-db:
<<: *defaults
container_name: oodikone-kone-db
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
container_name: oodikone-sis-db
container_name: sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
shm_size: 2gb
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
container_name: oodikone-sis-importer-db
container_name: sis-importer-db
environment:
PGDATA: /data
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
-103
View File
@@ -1,103 +0,0 @@
#!/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
+6 -6
View File
@@ -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 ===
@@ -49,7 +49,7 @@ set_custom_select_prompt() {
options=(
"Set up Oodikone from scratch"
"Reset all data"
"Reset all real data"
"Reset single database"
"Restore data from dumps"
"Docker system prune"
@@ -68,8 +68,8 @@ while true; do
case $opt in
"Set up Oodikone from scratch")
set_up_oodikone_from_scratch;;
"Reset all data")
reset_all_data;;
"Reset all real data")
reset_all_real_data;;
"Reset single database")
reset_single_database
set_custom_select_prompt # Set prompt back to initial values
+280 -304
View File
@@ -92,17 +92,17 @@ const clickAway = () => {
const getCourseSearchHooksForSingleYear = code => {
cy.intercept({
pathname: '/api/coursesmulti',
pathname: '/api/v2/coursesmulti',
query: { code },
}).as('courses')
cy.intercept({
pathname: '/api/courseyearlystats',
pathname: '/api/v3/courseyearlystats',
query: { 'codes[]': code },
}).as('yearlystats')
cy.intercept({
pathname: '/api/populationstatisticsbycourse',
pathname: '/api/v3/populationstatisticsbycourse',
}).as('coursestats')
}
@@ -185,16 +185,13 @@ 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('Pro gradu -tutkielma tietojenkäsittelytieteessä')
cy.contains('Class of 2007-2020, 10 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Population of course Pro gradu -tutkielma tietojenkäsittelytieteessä 2007-2020 (open and normal)')
cy.contains('Showing 10 out of 10 students')
const gradesTableContents = [
@@ -217,15 +214,17 @@ 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('Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot')
cy.contains('Class of 2011-2018, 4 students')
cy.contains('Include substitutions, Open and normal')
cy.contains(
'Population of course Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
)
cy.contains('Showing 4 out of 4 students')
const gradesTableContents = [
@@ -264,17 +263,17 @@ describe('Basic user', () => {
it('Searching multiple courses having substitution mappings shows course statistics', () => {
cy.intercept({
pathname: '/api/coursesmulti',
pathname: '/api/v2/coursesmulti',
query: { code: 'TKT' },
}).as('courses')
cy.intercept({
pathname: '/api/courseyearlystats',
pathname: '/api/v3/courseyearlystats',
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/populationstatisticsbycourse',
pathname: '/api/v3/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
@@ -310,17 +309,17 @@ describe('Basic user', () => {
it('On searches with multiple courses, has correct links on the Course tab', () => {
cy.intercept({
pathname: '/api/coursesmulti',
pathname: '/api/v2/coursesmulti',
query: { code: 'BSCS' },
}).as('courses')
cy.intercept({
pathname: '/api/courseyearlystats',
pathname: '/api/v3/courseyearlystats',
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
}).as('yearlystats')
cy.intercept({
pathname: '/api/populationstatisticsbycourse',
pathname: '/api/v3/populationstatisticsbycourse',
}).as('coursestats')
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
@@ -339,9 +338,7 @@ 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('Introduction to Programming')
cy.contains('Class of 2023-2024')
cy.contains('Include substitutions, Open and normal')
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
// NOTE: These should also have `cy.intercept`s.
// The calls should be either cached or cheap so this is only
@@ -352,9 +349,7 @@ 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('Data Structures and Algorithms')
cy.contains('Class of 2022-2023')
cy.contains('Include substitutions, Open and normal')
cy.contains('Population of course Data Structures and Algorithms 2022-2023 (open and normal)')
})
it('Searching course by name displays right courses', () => {
@@ -366,11 +361,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.')
})
@@ -382,20 +377,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', () => {
@@ -415,11 +410,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')
@@ -443,14 +438,10 @@ 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('Käyttöjärjestelmät')
cy.contains('Class of 2020-2021, 19 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Population of course Käyttöjärjestelmät 2020-2021 (open and normal')
cy.contains('TKT20003')
cy.contains('Students (19)').click()
@@ -461,17 +452,13 @@ 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('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 32 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (32)').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (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')
})
@@ -479,17 +466,13 @@ 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('Tietorakenteet ja algoritmit')
cy.contains('Class of 2019-2020, 32 students')
cy.contains('Include substitutions, Open and normal')
cy.contains('Students (32)').click()
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
cy.contains('Students (33)').click()
cy.get('table tbody td').filter(':contains("Hidden")').should('have.length', 9)
})
})
@@ -497,14 +480,10 @@ 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('Käyttöjärjestelmät')
cy.contains('Class of 2021-2022')
cy.contains('Include substitutions, Open and normal')
cy.contains('Population of course Käyttöjärjestelmät 2021-2022 (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')
@@ -520,7 +499,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', () => {
@@ -537,8 +516,7 @@ describe('Basic user', () => {
cy.contains('Show population').should('be.enabled')
})
// TODO: Writing these as backend api tests
describe.skip('Students tab', () => {
describe('Students tab', () => {
describe('Info boxes', () => {
it('Student statistics table', () => {
cy.cs('StudentStatistics-info-box-button').click()
@@ -560,14 +538,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', 183, 140, 6, 37, '76.50 %', '23.50 %'],
['Total', 187, 140, 11, 36, '74.87 %', '25.13 %'],
['2023-2024', 7, 1, 0, 6, '14.29 %', '85.71 %'],
['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 %'],
['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 %'],
['2019-2020', 28, 28, 0, null, '100.00 %', '0.00 %'],
['2018-2019', 29, 27, 2, null, '93.10 %', '6.90 %'],
['2017-2018', 26, 22, 4, null, '84.62 %', '15.38 %'],
['2018-2019', 30, 26, 4, null, '86.67 %', '13.33 %'],
['2017-2018', 26, 19, 7, null, '73.08 %', '26.92 %'],
['2016-2017', 1, 1, 0, null, '100.00 %', '0.00 %'],
]
checkTableContents(tableContents)
@@ -576,21 +554,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', 185, 140, 6, 39, '75.68 %', '24.32 %'],
['Total', 191, 140, 13, 38, '73.30 %', '26.70 %'],
['Syksy 2023', 7, 1, 0, 6, '14.29 %', '85.71 %'],
['Kevät 2023', 10, 9, 0, 1, '90.00 %', '10.00 %'],
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
['Kevät 2022', 26, 9, 0, 17, '34.62 %', '65.38 %'],
['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 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 2016', 1, 1, 0, null, '100.00 %', '0.00 %'],
]
toggleSeparateBySemesters()
@@ -600,14 +578,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', 183, 6, 3, 8, 5, 24, 99, 1, 37, '76.50 %', '23.50 %'],
['Total', 187, 11, 3, 8, 5, 24, 99, 1, 36, '74.87 %', '25.13 %'],
['2023-2024', 7, 0, 0, 0, 0, 0, 1, 0, 6, '14.29 %', '85.71 %'],
['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 %'],
['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 %'],
['2019-2020', 28, 0, 1, 4, 1, 5, 17, 0, null, '100.00 %', '0.00 %'],
['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 %'],
['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 %'],
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -617,21 +595,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', 185, 6, 3, 8, 5, 24, 99, 1, 39, '75.68 %', '24.32 %'],
['Total', 191, 13, 3, 8, 5, 24, 99, 1, 38, '73.30 %', '26.70 %'],
['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', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
['Kevät 2022', 26, 0, 0, 0, 0, 2, 7, 0, 17, '34.62 %', '65.38 %'],
['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 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 2016', 1, 0, 0, 0, 0, 1, 0, 0, null, '100.00 %', '0.00 %'],
]
toggleShowGrades()
@@ -640,8 +618,7 @@ describe('Basic user', () => {
})
})
// TODO: Writing these as backend api tests
describe.skip('Attempts tab', () => {
describe('Attempts tab', () => {
beforeEach(() => {
openAttemptsTab()
})
@@ -667,15 +644,15 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['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],
['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],
]
checkTableContents(tableContents)
})
@@ -683,20 +660,20 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 178, 140, 6, '95.89 %', 73],
['Total', 186, 140, 16, '89.74 %', 73],
['Syksy 2023', 6, 1, 0, '16.67 %', 6],
['Kevät 2023', 11, 9, 0, '81.82 %', 11],
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
['Kevät 2022', 29, 9, 0, '31.03 %', 29],
['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],
['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],
['Kevät 2017', 0, 0, 0, '–', 0],
['Syksy 2016', 1, 1, 0, '100.00 %', null],
]
@@ -707,14 +684,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', 178, 6, 3, 8, 5, 24, 99, 1],
['Total', 186, 16, 3, 8, 5, 24, 99, 1],
['2023-2024', 6, 0, 0, 0, 0, 0, 1, 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],
['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],
['2016-2017', 1, 0, 0, 0, 0, 1, 0, 0],
]
toggleShowGrades()
@@ -724,20 +701,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', 178, 6, 3, 8, 5, 24, 99, 1],
['Total', 186, 16, 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, 14, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
['Kevät 2022', 29, 0, 0, 0, 0, 2, 7, 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],
['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],
['Kevät 2017', 0, 0, 0, 0, 0, 0, 0, 0],
['Syksy 2016', 1, 0, 0, 0, 0, 1, 0, 0],
]
@@ -755,11 +732,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', () => {
@@ -776,23 +753,22 @@ describe('Basic user', () => {
cy.contains('Show population').should('be.enabled')
})
// TODO: Writing these as backend api tests
describe.skip('Students tab', () => {
describe('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', 277, 237, 8, 32, '85.56 %', '14.44 %'],
['Total', 284, 237, 16, 31, '83.45 %', '16.55 %'],
['2023-2024', 8, 2, 0, 6, '25.00 %', '75.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 %'],
['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 %'],
['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 %'],
@@ -814,28 +790,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', 282, 237, 9, 36, '84.04 %', '15.96 %'],
['Total', 293, 239, 19, 35, '81.57 %', '18.43 %'],
['Syksy 2023', 8, 2, 0, 6, '25.00 %', '75.00 %'],
['Kevät 2023', 12, 10, 0, 2, '83.33 %', '16.67 %'],
['Syksy 2022', 22, 17, 0, 5, '77.27 %', '22.73 %'],
['Syksy 2022', 23, 18, 0, 5, '78.26 %', '21.74 %'],
['Kevät 2022', 28, 11, 0, 17, '39.29 %', '60.71 %'],
['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 %'],
['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 %'],
['Kevät 2017', 4, 3, 1, null, '75.00 %', '25.00 %'],
['Syksy 2016', 2, 2, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2016', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Kevät 2016', 3, 2, 1, null, '66.67 %', '33.33 %'],
['Syksy 2015', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Kevät 2015', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 2, 2, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 3, 3, 0, null, '100.00 %', '0.00 %'],
['Kevät 2014', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', 1, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2013', 1, 0, 1, null, '0.00 %', '100.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 %'],
@@ -872,18 +848,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', 277, 8, 4, 14, 9, 37, 168, 5, 32, '85.56 %', '14.44 %'],
['Total', 284, 16, 4, 14, 9, 37, 168, 5, 31, '83.45 %', '16.55 %'],
['2023-2024', 8, 0, 0, 0, 0, 0, 2, 0, 6, '25.00 %', '75.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 %'],
['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 %'],
['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 %'],
@@ -906,28 +882,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', 282, 9, 4, 14, 9, 37, 168, 5, 36, '84.04 %', '15.96 %'],
['Total', 293, 19, 5, 15, 9, 37, 168, 5, 35, '81.57 %', '18.43 %'],
['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', 22, 0, 0, 0, 0, 3, 14, 0, 5, '77.27 %', '22.73 %'],
['Syksy 2022', 23, 0, 0, 0, 0, 3, 15, 0, 5, '78.26 %', '21.74 %'],
['Kevät 2022', 28, 0, 0, 0, 0, 2, 9, 0, 17, '39.29 %', '60.71 %'],
['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 %'],
['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 %'],
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0, null, '75.00 %', '25.00 %'],
['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 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 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', 2, 0, 0, 1, 0, 0, 1, 0, null, '100.00 %', '0.00 %'],
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 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, 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 %'],
['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 %'],
@@ -963,8 +939,7 @@ describe('Basic user', () => {
})
})
// TODO: Writing these as backend api tests
describe.skip('Attempts tab', () => {
describe('Attempts tab', () => {
beforeEach(() => {
openAttemptsTab()
})
@@ -972,18 +947,18 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters off', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 270, 237, 9, '96.34 %', 79],
['Total', 288, 241, 25, '90.60 %', 79],
['2023-2024', 6, 2, 0, '33.33 %', 6],
['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],
['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],
['2012-2013', 4, 4, 0, '100.00 %', null],
['2011-2012', 1, 1, 0, '100.00 %', null],
['2010-2011', 1, 1, 0, '100.00 %', null],
@@ -1005,55 +980,55 @@ describe('Basic user', () => {
it('Show grades off, Separate by semesters on', () => {
const tableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total', 270, 237, 9, '96.34 %', 79],
['Total', 288, 241, 25, '90.60 %', 79],
['Syksy 2023', 6, 2, 0, '33.33 %', 6],
['Kevät 2023', 13, 10, 0, '76.92 %', 13],
['Syksy 2022', 15, 17, 0, '100.00 %', 15],
['Syksy 2022', 15, 18, 0, '100.00 %', 15],
['Kevät 2022', 30, 11, 0, '36.67 %', 30],
['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],
['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],
['Kevät 2017', 4, 3, 1, '75.00 %', null],
['Syksy 2016', 2, 2, 0, '100.00 %', null],
['Kevät 2016', 3, 3, 0, '100.00 %', null],
['Syksy 2016', 3, 3, 0, '100.00 %', null],
['Kevät 2016', 4, 2, 2, '50.00 %', null],
['Syksy 2015', 1, 1, 0, '100.00 %', null],
['Kevät 2015', 3, 3, 0, '100.00 %', null],
['Syksy 2014', 2, 2, 0, '100.00 %', null],
['Kevät 2015', 4, 3, 1, '75.00 %', null],
['Syksy 2014', 3, 3, 0, '100.00 %', null],
['Kevät 2014', 1, 1, 0, '100.00 %', null],
['Syksy 2013', 1, 1, 0, '100.00 %', null],
['Kevät 2013', 0, 0, 0, '–', null],
['Syksy 2013', 1, 0, 1, '0.00 %', null],
['Kevät 2013', 0, 0, 0, '–', 0],
['Syksy 2012', 4, 4, 0, '100.00 %', null],
['Kevät 2012', 1, 1, 0, '100.00 %', null],
['Syksy 2011', 0, 0, 0, '–', null],
['Syksy 2011', 0, 0, 0, '–', 0],
['Kevät 2011', 1, 1, 0, '100.00 %', null],
['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 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 2008', 1, 0, 1, '0.00 %', null],
['Kevät 2008', 1, 1, 0, '100.00 %', null],
['Syksy 2007', 0, 0, 0, '–', null],
['Syksy 2007', 0, 0, 0, '–', 0],
['Kevät 2007', 1, 1, 0, '100.00 %', null],
['Syksy 2006', 0, 0, 0, '–', null],
['Kevät 2006', 0, 0, 0, '–', null],
['Syksy 2006', 0, 0, 0, '–', 0],
['Kevät 2006', 0, 0, 0, '–', 0],
['Syksy 2005', 1, 1, 0, '100.00 %', null],
['Kevät 2005', 0, 0, 0, '–', null],
['Syksy 2004', 0, 0, 0, '–', null],
['Kevät 2005', 0, 0, 0, '–', 0],
['Syksy 2004', 0, 0, 0, '–', 0],
['Kevät 2004', 1, 1, 0, '100.00 %', null],
['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 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 1999', 2, 2, 0, '100.00 %', null],
]
toggleSeparateBySemesters()
@@ -1063,18 +1038,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', 270, 9, 4, 14, 9, 37, 168, 5],
['Total', 288, 25, 6, 15, 9, 37, 169, 5],
['2023-2024', 6, 0, 0, 0, 0, 0, 2, 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],
['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],
['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],
@@ -1097,28 +1072,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', 270, 9, 4, 14, 9, 37, 168, 5],
['Total', 288, 25, 6, 15, 9, 37, 169, 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, 14, 0],
['Syksy 2022', 15, 0, 0, 0, 0, 3, 15, 0],
['Kevät 2022', 30, 0, 0, 0, 0, 2, 9, 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],
['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],
['Kevät 2017', 4, 1, 0, 0, 1, 0, 2, 0],
['Syksy 2016', 2, 0, 0, 0, 0, 1, 1, 0],
['Kevät 2016', 3, 0, 0, 1, 0, 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 2015', 1, 0, 0, 0, 0, 0, 0, 1],
['Kevät 2015', 3, 0, 0, 0, 0, 0, 0, 3],
['Syksy 2014', 2, 0, 0, 1, 0, 0, 1, 0],
['Kevät 2015', 4, 1, 0, 0, 0, 0, 0, 3],
['Syksy 2014', 3, 0, 0, 1, 0, 0, 2, 0],
['Kevät 2014', 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2013', 1, 0, 0, 0, 0, 0, 1, 0],
['Syksy 2013', 1, 1, 0, 0, 0, 0, 0, 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],
@@ -1181,9 +1156,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()
@@ -1205,13 +1180,14 @@ 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('Students (29)').click()
cy.contains('509781')
cy.contains('529866')
})
})
})
// TODO: Writing these as backend api tests
describe.skip('Only course statistics', () => {
describe('Only course statistics', () => {
beforeEach(() => {
cy.init('/coursestatistics', 'onlycoursestatistics')
cy.url().should('include', '/coursestatistics')
@@ -1237,15 +1213,15 @@ describe.skip('Only course statistics', () => {
const attemptsTableContents = [
// [Time, Total attempts, Passed, Failed, Pass rate, Enrollments]
['Total *', 240, 212, 8, '96.36 %', 73],
['Total *', 255, 217, 20, '91.56 %', 73],
emptyYear('2023-2024'),
['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 %'],
['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 %'],
emptyYear('2015-2016'),
emptyYear('2014-2015'),
emptyYear('2013-2014'),
+100 -109
View File
@@ -32,21 +32,36 @@ const deleteTag = tagName => {
describe('Degree programme overview', () => {
describe('Degree programme selector', () => {
it('Degree programme search filter', () => {
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.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() // NOTE: Tests are written for calendar years
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
@@ -64,11 +79,11 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 0, 0],
[2022, 25, 26, 47, 1, 3],
[2021, 29, 32, 48, 0, 2],
[2022, 25, 26, 48, 1, 4],
[2021, 29, 32, 47, 0, 2],
[2020, 26, 27, 12, 1, 3],
[2019, 28, 34, 1, 0, 1],
[2018, 40, 45, 0, 0, 1],
[2018, 40, 45, 0, 0, 0],
[2017, 41, 47, 0, 0, 0],
]
@@ -97,8 +112,8 @@ describe('Degree programme overview', () => {
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 47, 13],
[2021, 48, 76],
[2022, 48, 13],
[2021, 47, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
@@ -115,8 +130,8 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated]
...years.map(year => [year, 0, 0, 0]),
[2023, 8, 8, 24],
[2022, 25, 26, 43],
[2021, 29, 32, 47],
[2022, 25, 26, 44],
[2021, 29, 32, 46],
[2020, 26, 27, 11],
[2019, 28, 34, 1],
[2018, 40, 45, 0],
@@ -129,8 +144,8 @@ describe('Degree programme overview', () => {
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 43, 12],
[2021, 47, 69],
[2022, 44, 12],
[2021, 46, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
@@ -148,11 +163,11 @@ describe('Degree programme overview', () => {
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 0, 0],
['2022 - 2023', 25, 26, 35, 0, 0],
['2021 - 2022', 29, 32, 58, 1, 5],
['2022 - 2023', 25, 26, 36, 0, 0],
['2021 - 2022', 29, 32, 57, 1, 6],
['2020 - 2021', 26, 27, 30, 0, 3],
['2019 - 2020', 28, 34, 7, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 1],
['2018 - 2019', 40, 45, 0, 0, 0],
['2017 - 2018', 41, 47, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
@@ -192,20 +207,18 @@ describe('Degree programme overview', () => {
.should('contain', 47)
.should('contain', 76)
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
cy.cs('bachelor-breakdown-bar-chart-section')
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('47 graduated').should('be.visible')
cy.contains('47 graduated').hover()
})
cy.get('.grad-vals').contains('47 students graduated in year 2022').should('be.visible')
cy.contains('median study time: 6 semesters')
cy.contains('24 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('11 graduated over year late')
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
@@ -218,43 +231,37 @@ describe('Degree programme overview', () => {
})
describe('Graduation times of master programmes', () => {
it('are split into three graphs', () => {
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('unset-breakdown-bar-chart')
cy.cs('bachelor-breakdown-bar-chart-section')
cy.cs('master-breakdown-bar-chart-section')
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
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.get('.grad-vals').contains('2 students graduated in year 2021').should('be.visible')
cy.contains('average 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('average study time: 11.18 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
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')
})
})
})
@@ -262,7 +269,7 @@ describe('Degree programme overview', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
@@ -295,15 +302,15 @@ describe('Degree programme overview', () => {
it('Students of the degree programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 0, 19, 7, 0, 25, 1],
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 29, 8, 0, 35, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 22, 13, 0, 35, 2],
['2018 - 2019', 46, 40, 0, 0, 6, 40, 0, 26, 20, 0, 45, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 0, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 0, 147, 82, 0, 224, 12],
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
@@ -378,10 +385,10 @@ describe('Degree programme overview', () => {
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
['2021 - 2022', 38, 8, 4, 9, 8, 4, 0, 0, 5],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
@@ -398,30 +405,19 @@ 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%').hover()
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
cy.contains('Graduated: 134')
})
// CI contains different data than locally
it.skip('Average graduation times section', () => {
cy.cs('average-graduation-times-section').scrollIntoView()
it('Average graduation times section', () => {
cy.cs('average-graduation-times-section').within(() => {
cy.cs('unset-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
})
cy.get('text').contains('16').hover()
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
cy.contains('On time: 16')
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("Click a bar to view that year's study track level breakdown")
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
cy.contains('Year 2019 - 2020 by start year')
cy.get('text').contains('13').hover()
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 13')
@@ -431,7 +427,7 @@ describe('Degree programme overview', () => {
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').hover()
cy.contains('19 graduated').trigger('mouseover')
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')
@@ -454,14 +450,14 @@ describe('Degree programme overview', () => {
it('Students of the study track are shown correctly', () => {
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 0, 2, 1, 0, 3, 0],
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 3, 1, 0, 4, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 10, 7, 0, 17, 1],
['2018 - 2019', 24, 21, 0, 0, 2, 22, 0, 11, 13, 0, 24, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 0, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 0, 48, 41, 0, 88, 4],
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
@@ -475,8 +471,7 @@ describe('Degree programme overview', () => {
cy.contains('Showing 10 out of 30 students')
})
// 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', () => {
it('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.')
})
@@ -486,7 +481,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."]').hover()
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
cy.contains('Graduated On time: 9 students')
})
@@ -494,7 +489,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').hover()
cy.contains('12 graduated').trigger('mouseover')
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')
@@ -518,8 +513,6 @@ 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)
@@ -534,7 +527,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('1240')
cy.get('td').eq(3).contains('1170')
})
})
})
@@ -558,7 +551,7 @@ describe('Degree programme overview', () => {
})
it('year toggle works', () => {
cy.cs('year-toggle').click() // Default view now Academic years
cy.cs('year-toggle').click()
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2017').click()
@@ -572,7 +565,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('1265')
cy.get('td').eq(3).contains('1220')
})
})
})
@@ -631,8 +624,6 @@ 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(() => {
@@ -707,7 +698,7 @@ describe('Degree programme overview', () => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('272')
cy.get('td').eq(3).contains('375')
})
})
})
+5 -5
View File
@@ -110,15 +110,15 @@ describe('Faculty statistics', () => {
})
it('Toggle years works', () => {
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')
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')
})
it('Toggle programmes works', () => {
+4 -4
View File
@@ -19,7 +19,7 @@ describe("Population statistics with a master's programme", { testIsolation: fal
it('Study track filter works', () => {
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)', true)
cy.selectFromDropdown('studyTrackFilter', 0, true)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
@@ -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(6)
checkFilteredStudentCount(5)
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(6)
checkFilteredStudentCount(5)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(6)
checkFilteredStudentCount(2)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(6)
+3 -3
View File
@@ -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, 34, 759, 15, 40, 6, 51, 1, 24, 1059, 42, 0]
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 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([66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([1184, 28, 6, 28, 499, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
})
})
-122
View File
@@ -1,122 +0,0 @@
/// <reference types="cypress" />
const selectCourseStatus = (courseCode, status) => {
cy.cs(`courseFilter-${courseCode}-selector`).click()
cy.get(`[role="listbox"][aria-labelledby=courseFilter-${courseCode}]`).within(() => {
cy.get('li').contains(status).click()
})
}
describe('Numbers should match between', () => {
describe('Programme courses, Course statistics and Course population (no substitutions)', () => {
const test = [
// Code, name, total students, passed, not completed, failed*, enrolled no grade* (not used by Programme courses)
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', '272', '253', (1 + 18).toString(), '1', '18'],
['Course', 'MAT11003', 'Raja-arvot', '270', '249', (1 + 20).toString(), '1', '20'],
['Course', 'MAT11004', 'Differentiaalilaskenta', '262', '248', (1 + 13).toString(), '1', '13'],
['Course', 'MAT21002', 'Sarjat', '256', '250', (0 + 6).toString(), '0', '6'],
['Course', 'MAT11005', 'Integraalilaskenta', '251', '234', (0 + 17).toString(), '0', '17'],
['Course', 'MAT11002', 'Lineaarialgebra ja matriisilaskenta I', '247', '242', (1 + 4).toString(), '1', '4'],
['Course', 'MAT12003', 'Todennäköisyyslaskenta I', '234', '227', (3 + 4).toString(), '3', '4'],
['Course', 'MAT21001', 'Lineaarialgebra ja matriisilaskenta II', '230', '216', (4 + 10).toString(), '4', '10'],
['Course', 'MAT21003', 'Vektorianalyysi I', '228', '202', (3 + 23).toString(), '3', '23'],
['Course', 'MAT20005', 'Akateemiset taidot', '201', '178', (0 + 23).toString(), '0', '23'],
['Module', 'MAT110', 'Matematiikka, perusopinnot', '195', '195', (0 + 0).toString(), '0', '0'],
['Course', 'MAT21014', 'Johdatus logiikkaan I', '188', '170', (0 + 18).toString(), '0', '18'],
]
it('in Programme courses', () => {
// Move to Math bachelor programme's programme courses
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('ProgrammeCoursesTab').click()
// Sort by Total students
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').eq(3).click()
})
})
cy.get('tbody').within(() => {
test.forEach((course, index) => {
cy.get('tr')
.eq(index)
.within(() => {
// Check a course's fields
course.forEach((field, fieldIndex) => {
if (fieldIndex <= 5) {
// Failed, Enrolled no grade values not used
cy.get('td').eq(fieldIndex).contains(field)
}
})
// Course link button, (name is second entry)
// cy.cs(`${course.at(1).toLocaleLowerCase()}-course-statistics-link-button`).click()
})
})
})
})
it('in Course statistics', () => {
test.forEach(course => {
// Move to Course statistics
cy.init(`/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D&combineSubstitutions=false`, 'basic')
cy.url().should('include', `/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D`)
cy.contains('Course statistics')
cy.contains(course.at(2)) // course name
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption2017-2018').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(1).contains(course.at(3))
cy.get('td').eq(2).contains(course.at(4))
cy.get('td').eq(3).contains(course.at(6)) // jump over combined Not completed
cy.get('td').eq(4).contains(course.at(7))
})
})
})
})
it('in Course population', () => {
test.forEach(course => {
// Academic years between 2017-2023
cy.init(
`/coursepopulation?from=68&to=74&coursecodes=%5B%22${course.at(1)}%22%5D&includeSubstitutions=false`,
'basic'
)
cy.contains(course.at(2)) // Course name
// Select course from Course-filter
cy.cs('courseFilter-filter-card').click()
cy.cs('courseFilter-selector').click()
cy.cs('courseFilter-popper').within(() => {
cy.contains('li', `${course.at(1)} - ${course.at(2)}`).click() // "code - name"
})
selectCourseStatus(course.at(1), 'All')
cy.contains(`Students (${course.at(3)})`)
selectCourseStatus(course.at(1), 'Passed')
cy.contains(`Students (${course.at(4)})`)
selectCourseStatus(course.at(1), 'Failed')
cy.contains(`Students (${course.at(6)})`)
selectCourseStatus(course.at(1), 'Enrolled, No Grade')
cy.contains(`Students (${course.at(7)})`)
})
})
})
describe('Course statistics and Course population', () => {
it.skip('when selecting one year')
it.skip('when selecting the all years')
})
})
+126
View File
@@ -0,0 +1,126 @@
/// <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')
})
})
})
+8 -8
View File
@@ -27,14 +27,14 @@ describe('Population statistics tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v2/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').hover()
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
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')
@@ -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/courseyearlystats**').as('coursePage')
cy.intercept('/api/v3/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', () => {
-85
View File
@@ -1,85 +0,0 @@
import { userHeaders } from '../support/commands.js'
const getBaseRoutes = [
['/', {}],
['/changelog', {}],
['/completedcoursessearch', {}],
['/university', {}],
['/feedback', {}],
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: ['KH50_001'] }],
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/users', { roles: ['admin'] }],
['/users/334', { roles: ['admin'] }],
['/updater', { roles: ['admin'] }],
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/teachers', { roles: ['teachers'] }],
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
]
for (const user of userHeaders) {
if (user.uid === 'admin') continue
describe(`${user.displayname} has access only to views that were intended`, () => {
let userInfo
before(() => {
cy.request({ method: 'GET', url: '/api/login', headers: user }).then(({ body }) => (userInfo = body?.user))
})
beforeEach(() => cy.wrap(userInfo).should('not.be.undefined'))
beforeEach(() => {
cy.intercept('**/api/**', req => {
req.on('response', res => {
expect(res.statusCode, `${req.method} ${req.url}`).to.be.lessThan(500)
})
})
})
for (const [route, requirements] of getBaseRoutes) {
it(`Checking access ${route}`, () => {
cy.init(route, user.uid)
const requiredUserId = !requirements.userId || requirements.userId === userInfo.userId
const requiredRoles = !requirements.roles || requirements.roles?.some(role => userInfo.roles?.includes(role))
const requiredRights =
!requirements.requireProgrammeRights ||
userInfo.roles?.includes('fullSisuAccess') ||
requirements.programmeRights.every(role => userInfo.programmeRights?.includes(role))
// This is defined in frontend ProtectedRoute component.
const specialNeedsRoutes = [
'populations',
'study-programme',
'coursepopulation',
'coursestatistics',
'students',
'custompopulation',
]
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
? requiredRoles || requiredRights
: requiredRoles && requiredRights
const shouldHaveAccess = mankeli && requiredUserId
if (shouldHaveAccess) {
cy.contains('Something broke').should('not.exist')
cy.contains('Access denied').should('not.exist')
} else {
cy.contains('Access denied')
}
})
}
})
}
+26 -15
View File
@@ -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', () => {
@@ -174,13 +174,18 @@ 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', () => {
@@ -259,10 +264,8 @@ describe('Students tests', () => {
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
cy.visit('/students/550789')
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.get('td').eq(1).click()
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
@@ -271,23 +274,31 @@ 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", () => {
cy.visit('/students/550789')
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td')
.eq(4)
.eq(-1)
.contains(/^185 cr$/)
})
})
it("If there's a study plan corresponding to the degree programme and the student hasn't graduated, percentage of completion is also displayed", () => {
cy.visit('/students/458723')
cy.contains('Study rights')
cy.get('[data-cy=KH20_001-0]').contains('Oikeusnotaarin koulutusohjelma')
cy.get('[data-cy=KH20_001-0]').within(() => {
cy.get('td').eq(4).contains('69% (125 cr)')
cy.contains('table tbody tr', 'Oikeusnotaarin koulutusohjelma (01.08.2020–31.07.2027)').within(() => {
cy.get('td').eq(-1).contains('69% (125 cr)')
})
})
})
+1 -1
View File
@@ -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(2).contains('td', teacher1)
cy.get('table tbody tr').eq(3).contains('td', teacher1)
})
it('Can check teacher page', () => {
+18 -29
View File
@@ -10,18 +10,21 @@ const checkProgressTables = () => {
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
const checkGraduationCharts = mode => {
const checkAverageGraduationTimesBreakdownBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-${mode}-bar-chart`).within(() => {
// Check that bar has loaded
cy.contains('Loading content').should('not.exist')
})
cy.cs(`${level}-breakdown-bar-chart`)
})
}
describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
const checkAverageGraduationTimesMedianBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-median-bar-chart`)
})
}
describe('University view', () => {
beforeEach(() => {
cy.init('/university')
cy.contains('University')
@@ -46,7 +49,7 @@ describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
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%').hover()
cy.contains('29.5%').trigger('mouseover', { force: true })
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
cy.contains('0 Credits: 0')
@@ -70,31 +73,17 @@ describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
cy.cs('faculty-graduations-tab').click()
})
describe('Different modes work', () => {
it('Breakdown', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-breakdown').click()
})
checkGraduationCharts('breakdown')
})
it('has all the correct median time bar charts', () => {
checkAverageGraduationTimesBreakdownBarCharts()
})
it('Median', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
})
checkGraduationCharts('median')
})
it('Average', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
})
checkGraduationCharts('average')
})
it("'Breakdown/Median times' toggle works", () => {
cy.cs('graduation-time-toggle').click()
checkAverageGraduationTimesMedianBarCharts()
})
it('info boxes contain correct information', () => {
cy.cs('average-graduation-times-info-box-button').hover()
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Users tests', () => {
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
+12 -18
View File
@@ -58,7 +58,18 @@ const onlyStudyGuidanceGroupsUser = {
hypersonsisuid: 'hy-hlo-2222222',
}
export const userHeaders = [
// 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,
onlycoursestatisticsUserHeaders,
@@ -179,20 +190,3 @@ 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,
})
})
+62 -127
View File
@@ -1,30 +1,15 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
backend:
<<: *defaults
container_name: oodikone-backend
image: oodikone-backend:staging
build:
context: services
dockerfile: backend/Dockerfile
container_name: backend
depends_on:
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
- kone-db
- redis
- sis-db
- user-db
environment:
CI: 'true'
DB_URL_USER: postgres://postgres@user-db:5432/user-db
@@ -37,152 +22,130 @@ services:
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
JAMI_URL: 'http://jami:3003/'
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
ports:
- 8080:8080
build:
context: services
dockerfile: backend/Dockerfile
profiles:
- oodikone
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health'] # In CI/prod the routing happens at a different level, hence no /api/ needed
<<: *healthcheck-defaults
frontend:
<<: *defaults
container_name: oodikone-frontend
image: oodikone-frontend:staging
container_name: frontend
build:
context: services
dockerfile: frontend/Dockerfile
depends_on:
backend:
condition: service_healthy
ports:
- 5000:5000
profiles:
- oodikone
kone-db:
<<: *defaults
container_name: oodikone-kone-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
container_name: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 4321:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 4320:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
redis:
<<: *defaults
container_name: oodikone-redis
image: redis:7.2.6
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:7.2.6
container_name: redis
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
container_name: oodikone-sis-db
container_name: sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-importer-db:
user-db:
<<: *defaults
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
container_name: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
depends_on:
jami-db:
condition: service_healthy
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=test # previously was development which borks CI
- NODE_ENV=dev
- 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
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
nginx:
container_name: oodikone-nginx
image: nginx:latest
container_name: nginx
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
- frontend
- backend
ports:
- 1337:80
profiles:
- oodikone
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
profiles:
- oodikone
# 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
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
sis-updater-worker:
<<: *defaults
build:
context: updater/sis-updater-worker
container_name: sis-updater-worker
depends_on:
- redis
- sis-db
- sis-importer-db
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
# Following services + sis-importer-db 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-updater-scheduler:
restart: no
container_name: oodikone-updater-scheduler
<<: *defaults
build:
context: updater/sis-updater-scheduler
dockerfile: ./Dockerfile
container_name: sis-updater-scheduler
depends_on:
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
- redis
- sis-importer-db
environment:
CI: 'true'
REDIS_HOST: redis
@@ -197,31 +160,3 @@ services:
- 8082:8082
profiles:
- updater
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
sis-updater-worker:
<<: *defaults
container_name: oodikone-updater-worker
build:
context: updater/sis-updater-worker
dockerfile: ./Dockerfile
depends_on:
redis:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
-3
View File
@@ -1,5 +1,3 @@
name: oodikone
services:
backend:
environment:
@@ -22,4 +20,3 @@ services:
volumes:
redis-data-real:
name: oodikone-redis-data-real
+24 -25
View File
@@ -1,27 +1,26 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
sis-db:
<<: *defaults
container_name: oodikone-sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
pull_policy: always
volumes: !reset [] # Remove previous volume definitions
api-tests:
volumes:
- backend-tests-nodemod:/opt/app-root/backend/node_modules
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
CI: 'true'
NODE_ENV: 'test'
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
DB_URL_USER: postgres://postgres@user-db:5432/user-db
FRONT_URL: http://localhost:3000
REDIS: redis
SECRET_TOKEN: test
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
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:
+72 -138
View File
@@ -1,20 +1,13 @@
name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
dockerfile: dev.Dockerfile
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
services:
adminer:
<<: *defaults
container_name: oodikone-adminer
container_name: adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
@@ -25,19 +18,15 @@ services:
backend:
<<: *defaults
container_name: oodikone-backend
build:
dockerfile: backend/dev.Dockerfile
context: services
<<: *build_defaults
context: services/backend
container_name: backend
depends_on:
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
- kone-db
- redis
- sis-db
- user-db
env_file:
- .env
tty: true
@@ -56,90 +45,57 @@ 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
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health']
<<: *healthcheck-defaults
frontend:
<<: *defaults
container_name: oodikone-frontend
build:
dockerfile: frontend/dev.Dockerfile
context: services
depends_on:
backend:
condition: service_healthy
<<: *build_defaults
context: services/frontend
container_name: frontend
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: oodikone-kone-db
container_name: kone-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
profiles:
- oodikone
ports:
- 4321:5432
volumes:
- kone-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
profiles:
- oodikone
ports:
- 4320:5432
volumes:
- user-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
redis:
<<: *defaults
container_name: oodikone-redis
command: 'redis-server --appendonly yes'
container_name: redis
image: redis:7.2.6
ports:
- 6379:6379
- '6379:6379'
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
container_name: oodikone-sis-db
container_name: sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
volumes:
- sis-db-data:/data
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
container_name: oodikone-sis-importer-db
container_name: sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
@@ -147,55 +103,41 @@ services:
- sis-importer-db-data:/data
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
jami:
sis-updater-scheduler:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
depends_on:
jami-db:
condition: service_healthy
env_file:
- .env
- redis
- sis-importer-db
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
ports:
- 3003:3003
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
ports:
- 5433:5432
- 8082:8082
profiles:
- updater
volumes:
- jami-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-scheduler/:/opt/app-root/src
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:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
- redis
- sis-db
- sis-importer-db
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
@@ -207,60 +149,52 @@ services:
profiles:
- updater
volumes:
- ./updater/sis-updater-worker/:/opt/app-root/src
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-worker/:/opt/app-root/src
sis-updater-scheduler:
user-db:
<<: *defaults
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
depends_on:
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
ports:
- 8082:8082
container_name: user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
profiles:
- updater
- oodikone
volumes:
- ./updater/sis-updater-scheduler/:/opt/app-root/src
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
- user-db-data:/data
jami:
<<: *defaults
image: quay.io/toska/jami:production
env_file:
- .env
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
container_name: jami
jami-db:
<<: *defaults
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
container_name: jami-db
ports:
- 5433:5432
volumes:
- jami-data:/data
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
+4 -1
View File
@@ -8,10 +8,11 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
- courseStatistics
- facultyStatistics
- fullSisuAccess
- openUniSearch
- studyGuidanceGroups
- teachers
The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain views (_Faculties_ and _Guidance groups_ respectively).
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
### courseStatistics
@@ -42,6 +43,7 @@ The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain vi
| 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
@@ -61,6 +63,7 @@ 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)
+1 -1
View File
@@ -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 `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
- Backend: student data is fetched by bachelor degree programme code in `/v3/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.
+3 -3
View File
@@ -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:**
+63 -77
View File
@@ -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,42 +75,31 @@ 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).',
},
],
},
'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 }
],
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': [
@@ -123,7 +112,9 @@ 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',
@@ -143,12 +134,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'],
}),
})
],
},
},
@@ -156,22 +147,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,
@@ -182,15 +173,14 @@ 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',
@@ -198,7 +188,9 @@ 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
@@ -206,25 +198,29 @@ 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
@@ -232,7 +228,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',
@@ -243,9 +239,20 @@ 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',
@@ -263,26 +270,5 @@ export default tseslint.config(
'no-promise-executor-return': 'off',
},
},
// Backend tests
{
files: ["services/backend/tests/**/*.ts", "services/backend/vitest.config.ts"],
rules: {
'import-x/no-extraneous-dependencies': 'off',
}
},
// 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
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
)
+13 -12
View File
@@ -6,32 +6,33 @@
},
"scripts": {
"cypress": "cypress",
"cypress:run": "cypress run --browser firefox",
"cypress:open": "cypress open",
"cypress:run": "cypress run --browser chrome",
"instrument": "./scripts/instrument.sh",
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"stylelint": "stylelint",
"prettier": "prettier",
"cli": "./cli.sh",
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami -f",
"logs:all": "docker compose --file docker-compose.yml --profile oodikone --profile updater logs -f",
"logs:ci": "docker compose --file docker-compose.ci.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami",
"oodikone": "npm run docker:down && ./run.sh oodikone anon up --detach",
"oodikone": "./run.sh oodikone anon up --detach",
"oodikone:real": "./run.sh oodikone real up --detach",
"oodikone:test": "npm run docker:down && docker compose --profile oodikone up --detach && docker compose --file docker-compose.test.yml up --detach",
"oodikone:ci": "npm run docker:down && docker compose --file docker-compose.ci.yml --profile oodikone up",
"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",
"updater": "./run.sh updater anon up --detach",
"updater:real": "./run.sh updater real up --detach",
"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 --wait sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker compose --file docker-compose.ci.yml up sis-updater-scheduler --exit-code-from sis-updater-scheduler",
"flushredis": "docker exec -it oodikone-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 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",
"install:docker": "docker exec --user 0 oodikone-frontend sh -lc \"npm i --prefix /opt/app-root/shared && npm i --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm i --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
"install:both": "npm run install:local && npm run install:docker",
"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",
"ci:docker": "docker exec --user 0 oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm ci --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
"install:both": "npm run install:local && npm run install:docker",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
+1 -1
View File
@@ -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
}
+5 -6
View File
@@ -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="oodikone-$database"
local database_container="$database"
local database_name="$database-real"
infomsg "Attempting to create database $database_name from dump $database_dump inside container $database_container"
@@ -138,7 +138,7 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_all_data() {
reset_all_real_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
@@ -215,7 +215,6 @@ 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"
)
@@ -224,7 +223,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"
+7 -116
View File
@@ -1,116 +1,7 @@
# disallow directory named "private", it can be used to store things that should not end up in version control or images
# NOTE: needs to be at the root of the project
private/
gitignored/
# disallow version control, package managment, IDEs, tools etc
**/node_modules
**/.venv
**/.git
**/.github
**/.vscode
**/.idea
**/.zed
**/coverage/
**/.eslintcache
**/.DS_Store
# do not include any data by default (except json)
**/*.csv
**/*.tsv
**/*.db
**/*.sqlite
**/*.sql
**/*.psql
**/*.sqlz
**/*.sqz
**/*.sql.gz
**/*.bson
**/*.jsonl
**/*.bin
**/*.dat
**/*.data
**/*.swp
**/*.tmp
**/.~lock*
**/*.pid
**/*.seed
**/*.lock
**/*.gz
**/*.tgz
**/*.xz
**/*.zip
**/*.gzip
**/*.tar
**/*.xz
**/*.7z
**/*.rar
**/*.docx
**/*.doc
**/*.xlsx
**/*.xls
**/*.pptx
**/*.ppt
**/*.odt
**/*.odp
**/*.ods
**/*.pdf
# disallow logs
**/*.log
**/*.log*
**/logs/
**/log/
**/*.cpuprofile
**/*.heapprofile
**/*.heapsnapshot
# disallow configuration
**/*.yml
**/*.yaml
**/*.toml
**/*.conf
**/*.ini
**/toska_login/
**/toska_login/*
**/cypress.env.json
cypress/
# disallow secrects
**/.env
**/.env*
**/*.env.*
**/.secrects
**/*.s3cfg
# disallow backups
**/backups/*
**/backups
**/backup/*
**/backup
**/dump/
**/dump
**/dumps/
**/dumps
**/databasedumps/
**/privateData
**/temp
**/minio-data/*
# additional inclusions here
# exclusions here
**/.dockerignore
**/.gitignore
**/.eslintrc.json
**/*Dockerfile
**/build
**/dist
*/.dockerignore
*/.eslintrc.json
*/.gitignore
*/*Dockerfile
*/build
*/dist
*/node_modules
-12
View File
@@ -1,12 +0,0 @@
CI='true'
NODE_ENV='test'
DB_URL_KONE=postgres://postgres@localhost:4321/kone-db
DB_URL_USER=postgres://postgres@localhost:4320/user-db
FRONT_URL=http://localhost:3000
REDIS=localhost
SECRET_TOKEN=test
SIS_DB_URL=postgres://postgres@localhost:12345/sis-db
SIS_UPDATER_URL=http://localhost:8082
JAMI_URL='http://localhost:3003/'
IMPORTER_DB_API_URL='https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
CRYPT_KEY='e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
-3
View File
@@ -1,3 +0,0 @@
ignore-scripts=true
min-release-age=7
save-prefix=
+3 -7
View File
@@ -1,4 +1,4 @@
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal AS build
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal AS build
ENV TZ="Europe/Helsinki"
@@ -29,7 +29,7 @@ RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backen
RUN npm run build --prefix shared && npm run build --prefix backend
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
@@ -50,13 +50,9 @@ 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/.npmrc .
COPY --from=build /opt/app-root/backend/package*.json ./
COPY --from=build /opt/app-root/backend/package*.json .
COPY --from=build /opt/app-root/backend/dist ./dist
RUN npm ci --omit=dev
+3 -11
View File
@@ -1,21 +1,13 @@
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
FROM node:24-alpine
ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
USER 0
WORKDIR /opt/app-root/shared
COPY --chown=1001:0 ./shared .
RUN npm ci && chown -R 1001:0 /opt/app-root/shared
WORKDIR /opt/app-root/backend
COPY --chown=1001:0 ./backend .
RUN npm ci && chown -R 1001:0 /opt/app-root/backend
COPY . .
RUN npm ci
EXPOSE 8080
USER 1001
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
+407 -1655
View File
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -2,41 +2,39 @@
"name": "backend",
"private": true,
"scripts": {
"build": "tsc",
"test:all": "vitest",
"test:api": "vitest ./tests/api",
"test:services": "vitest ./tests/services",
"test:unit": "vitest ./tests/unit",
"test:silent": "SILENT_TESTING=true vitest"
"build": "tsc -p .",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.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": "^5.2.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.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": "^5.0.6",
"@types/express": "^4.17.23",
"@types/lodash-es": "^4.17.12",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
@@ -45,7 +43,11 @@
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2",
"vitest": "4.1.9"
"typescript": "^5.9.2"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.4.0"
}
}
}
+1
View File
@@ -1,3 +1,4 @@
import 'express-async-errors'
import express from 'express'
import { baseUrl, backendPort } from './config'
+6 -3
View File
@@ -6,10 +6,10 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
export const silentTesting = process.env.SILENT_TESTING === 'true'
// IAM group
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
export const requiredGroup = isStaging
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
// Pate
export const pateToken = process.env.PATE_API_TOKEN ?? ''
@@ -38,6 +38,9 @@ export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_U
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
+1
View File
@@ -5,6 +5,7 @@ export const roles = [
'courseStatistics',
'facultyStatistics',
'fullSisuAccess',
'openUniSearch',
'studyGuidanceGroups',
'teachers',
] as const
@@ -24,7 +24,6 @@ import {
TeacherModel,
} from '../models'
import {
BannerModel,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -108,7 +107,6 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
logging: false,
password: conf.KONE_PASSWORD,
models: [
BannerModel,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -1,46 +0,0 @@
const { INTEGER, STRING, DATE } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.createTable('banners', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
text: {
type: STRING,
allowNull: false,
},
color: {
type: STRING,
allowNull: false,
},
lightness: {
type: STRING,
allowNull: false,
},
start_date: {
type: DATE,
allowNull: false,
},
end_date: {
type: DATE,
allowNull: false,
},
last_modified_by: {
type: STRING,
allowNull: false,
},
created_at: {
type: DATE,
},
updated_at: {
type: DATE,
},
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.dropTable('banners')
},
}
+9 -11
View File
@@ -1,6 +1,6 @@
import { CronJob } from 'cron'
import { isProduction, runningInCI, CRON_SCHEDULE } from './config'
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
import { getFaculties } from './services/faculty/facultyHelpers'
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
@@ -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 { jobQueue } from './worker/queue'
import { jobMaker, addToFlow } 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) {
jobQueue.refreshFaculty(faculty.code)
jobMaker.faculty(faculty.code)
}
}
@@ -53,13 +53,12 @@ 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 jobQueue.addToFlow(faculty, programmeCodes)
await addToFlow(faculty, programmeCodes)
}
}
@@ -83,7 +82,7 @@ export const refreshProgrammes = async () => {
}
for (const code of programmeCodes) {
// If combined programme is given, this updates only the bachelor programme
jobQueue.refreshProgramme(code)
jobMaker.programme(code)
}
}
@@ -97,9 +96,8 @@ const dailyJobs = async () => {
try {
await refreshTeacherLeaderboard()
await refreshProgrammesAndFaculties()
jobQueue.refreshLanguagecenter()
jobQueue.refreshCloseToGraduation()
if (languageCenterViewEnabled) jobMaker.languagecenter()
jobMaker.closeToGraduation()
} catch (error) {
logger.error('Daily jobs failed', error)
}
@@ -120,11 +118,11 @@ export const startCron = () => {
})
schedule('0 19 * * 1', () => {
logger.info('Updating students whose studyplans have not been updated recently')
jobQueue.refreshStudyplans(4)
jobMaker.studyplansUpdate(4)
})
schedule('0 10 * * 2', () => {
logger.info('Updating students whose studyplans have not been updated recently')
jobQueue.refreshStudyplans(5)
jobMaker.studyplansUpdate(5)
})
}
}
@@ -1,8 +1,8 @@
import { Request, Response } from 'express'
import { omit } from 'lodash-es'
import morgan from 'morgan'
import { FormattedUser } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { FormattedUser } from '../types'
import { getFullStudyProgrammeRights } from '../util'
import logger from '../util/logger'
@@ -38,11 +38,6 @@ 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, ' '),
@@ -57,7 +52,7 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
const onlyIamRights = !user.isAdmin && fullStudyProgrammeRights.length === 0
logger.info(message, {
...omitKeys(meta, ['studentsUserCanAccess']), // Don't log student list which might be huge
...omit(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
})
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node'
import { NextFunction, Request, Response } from 'express'
import { intersection } from 'lodash-es'
import { requiredGroup, baseUrl } from '../config'
import { requiredGroup } from '../config'
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
import { ApplicationError } from '../util/customErrors'
import logger from '../util/logger'
@@ -36,11 +36,6 @@ export default async (req: Request, _: Response, next: NextFunction) => {
uid: username,
} = req.headers as Headers
if (req.path === `${baseUrl}/health`) {
next()
return
}
if (!username || !sessionId)
throw new ApplicationError(
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
@@ -1,33 +0,0 @@
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement } from 'sequelize-typescript'
import type { Banner } from '@oodikone/shared/models/kone'
@Table({
underscored: true,
modelName: 'banner',
tableName: 'banners',
})
export class BannerModel extends Model implements Banner {
@PrimaryKey
@AutoIncrement
@Column(DataType.INTEGER)
declare id: number
@Column(DataType.DATE)
declare startDate: Date
@Column(DataType.DATE)
declare endDate: Date
@Column(DataType.STRING)
declare text: string
@Column(DataType.STRING)
declare color: string
@Column(DataType.STRING)
declare lightness: 'light' | 'main' | 'dark'
@Column(DataType.STRING)
declare lastModifiedBy: string
}
@@ -1,4 +1,3 @@
export { BannerModel } from './banner'
export { CustomPopulationSearchModel } from './customPopulationSearch'
export { ExcludedCourseModel } from './excludedCourse'
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
+2
View File
@@ -0,0 +1,2 @@
*.csv
*.csv#
+22
View File
@@ -0,0 +1,22 @@
# 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
@@ -0,0 +1,188 @@
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 }
+36
View File
@@ -0,0 +1,36 @@
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 }
+31
View File
@@ -0,0 +1,31 @@
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))
+10 -10
View File
@@ -3,23 +3,22 @@ import compression from 'compression'
import cors from 'cors'
import { Express, json as jsonExpress } from 'express'
import { frontUrl } from './config'
import { frontUrl, languageCenterViewEnabled } from './config'
import accessLogger from './middleware/accessLogger'
import * as auth from './middleware/auth'
import currentUserMiddleware from './middleware/currentUser'
import errorHandler from './middleware/errorHandler'
import headersMiddleware from './middleware/headers'
import banners from './routes/banners'
import changelog from './routes/changelog'
import closeToGraduation from './routes/closeToGraduation'
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'
import health from './routes/health'
import languageCenterData from './routes/languageCenterData'
import login from './routes/login'
import population from './routes/population'
@@ -43,10 +42,9 @@ const routes = (app: Express, url: string) => {
app.use(compression())
app.use(headersMiddleware)
app.use(currentUserMiddleware)
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
app.use(accessLogger)
app.use(`${url}/login`, login)
app.use(`${url}/banners`, banners)
app.use(url, courses)
app.use(`${url}/students`, students)
app.use(url, population)
@@ -54,12 +52,15 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/semesters/codes`, semesters)
app.use(url, tags)
app.use(url, programmeModules)
app.use(`${url}/course-exclusions`, courseExclusions)
app.use(`${url}/studyprogrammes`, studyProgramme)
app.use(`${url}/v3/course-exclusions`, courseExclusions)
app.use(`${url}/v2/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)
app.use(`${url}/languagecenterdata`, languageCenterData)
if (languageCenterViewEnabled) {
app.use(`${url}/languagecenterdata`, languageCenterData)
}
app.use(`${url}/faculties`, faculties)
app.use(`${url}/university`, university)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
@@ -71,8 +72,7 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
app.use(`${url}/study-programme-pins`, studyProgrammePins)
app.use(`${url}/curriculum-periods`, curriculumPeriods)
app.use(`${url}/health`, health)
app.get('', (_, res) => {
app.get('*', (_, res) => {
res.status(404).json({ error: 'unknown endpoint' })
})
Sentry.setupExpressErrorHandler(app)
-53
View File
@@ -1,53 +0,0 @@
import { Request, Response, Router } from 'express'
import { Banner } from '@oodikone/shared/models/kone'
import { saveBanner, getActiveBanners, getAllBanners } from '../services/banners'
const router = Router()
router.get<never, Banner[]>('/', async (_req: Request, res: Response) => {
const banners = await getActiveBanners()
res.json(banners).end()
})
router.get<never, Banner[]>('/all', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const banners = await getAllBanners()
res.json(banners)
})
router.post('/new', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const payload = {
...req.body,
lastModifiedBy: user.username,
}
await saveBanner(payload)
res.status(201).end()
})
router.put('/update', async (req: Request, res: Response) => {
const { user } = req
if (!user.isAdmin || !user.iamGroups.includes('grp-toska')) {
return res.status(403).end()
}
const payload = {
...req.body,
lastModifiedBy: user.username,
}
await saveBanner(payload)
res.status(201).end()
})
export default router
+18 -10
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isDev, isTest } from '../config'
const router = Router()
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
return res.status(200).json(fakeRelease)
}
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
const { data } = await Fetchios.get<Record<string, any>[]>(
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
{}
)
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
if (data) {
const releasesFromAPI: Release[] = data.map(release => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
}
res.status(500).send()
})
export default router
@@ -14,7 +14,7 @@ router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
const result = await getCloseToGraduationData(studentNumbers)
return res.json(result)
res.json(result)
}
const result = await getCloseToGraduationData()
@@ -2,14 +2,13 @@ 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 { getCompletedCourses } from '../services/completedCoursesSearch'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import {
getUserSearches,
createNewUserSearch,
deleteUserSearch,
updateUserSearch,
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
@@ -20,14 +19,11 @@ const router = Router()
const importerClient = getImporterClient()
type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & {
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
courses: CompletedCoursesCourse[]
}
export type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
>
type SearchReqBody = never
type SearchQuery = {
export type SearchReqBody = never
export type SearchQuery = {
studentlist: string
courselist: string
}
@@ -50,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
: { data: null, error: null }
if (error) {
@@ -76,7 +72,7 @@ export type GetSearchResBody = FoundSearch[]
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getUserSearches(userId)
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
@@ -102,7 +98,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 createNewUserSearch(userId, name, courseCodes)
const createdSearch = await createNewSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create courselist' })
}
@@ -134,7 +130,7 @@ router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searc
return res.status(422).end()
}
const updatedSearch = await updateUserSearch(userId, id, courseCodes)
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
@@ -159,7 +155,7 @@ router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (r
return res.status(422).end()
}
const deletedSearch = await deleteUserSearch(userId, id)
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
+10 -27
View File
@@ -9,20 +9,15 @@ import {
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import {
getFullStudyProgrammeRights,
handleQueryArrays,
hasFullAccessToStudentData,
validateParamLength,
} from '../util'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/coursesmulti',
'/v2/coursesmulti',
async (req, res) => {
const { name, code, includeSpecial } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
@@ -31,7 +26,7 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
const mergedCourses = new Map<string, CourseWithSubsDetails>()
const mergedCourses = new Map<string, CourseWithSubsId>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
@@ -51,16 +46,14 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
}
)
export type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/courseyearlystats',
'/v3/courseyearlystats',
async (req, res) => {
const { codes, combineSubstitutions, separate } = req.query
const courseCodes = handleQueryArrays(codes)
if (!courseCodes?.length) {
if (!codes) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
@@ -82,19 +75,9 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
return res.json(results)
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
res.json(results)
}
)
router.get<never, CanError<unknown>, never, { codes: string | string[] }>('/coursedetails', async (req, res) => {
const { codes: coursecodes } = req.query
const codes = handleQueryArrays(coursecodes)
if (!codes?.length) return res.status(500).end()
const details = await getCourseDetails(codes)
return res.json(details)
})
export default router
@@ -0,0 +1,148 @@
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
+8 -10
View File
@@ -2,7 +2,6 @@ import { Router } from 'express'
import type { CanError, GenericApplicationError } from '@oodikone/shared/routes'
import type {
FacultyGraduationStatistics,
Graduated,
Name,
NameWithCode,
@@ -23,6 +22,7 @@ import {
setBasicStats,
getThesisWritersStats,
setThesisWritersStats,
getGraduationStats,
setGraduationStats,
getFacultyStudentStats,
setFacultyStudentStats,
@@ -30,13 +30,12 @@ import {
setFacultyProgressStats,
type BasicData,
type ThesisWriterData,
getGraduationStats,
GraduationData,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress } from '../services/faculty/facultyStudentProgress'
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
import { objectKeyAnnihilator } from '../util'
import { ApplicationError } from '../util/customErrors'
// Faculty uses a lot of tools designed for Degree programme.
@@ -136,13 +135,14 @@ router.get<
return res.json(await getFacultyCredits(faculty.code, yearType === 'ACADEMIC_YEAR'))
})
type GetGraduationStatsResBody = GraduationData
type GetGraduationStatsQuery = {
programme_filter: ProgrammeFilter
}
router.get<
GetStatsParams,
CanError<FacultyGraduationStatistics, GenericApplicationError>,
CanError<GetGraduationStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetGraduationStatsQuery
>('/:id/graduationtimes', auth.roles(['facultyStatistics']), async (req, res) => {
@@ -153,9 +153,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)
// Times were needed to compute medians/averages serverside, but are not needed in frontend.
// They are still kept in redis as university view needs them for the same purpose.
if (data) return res.json(objectKeyAnnihilator(data, 'times', []))
if (data) return res.json(data)
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
@@ -163,7 +161,7 @@ router.get<
const updatedStats = await countGraduationTimes(faculty.code, programmes)
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
return res.json(objectKeyAnnihilator(updatedStats, 'times', []))
return res.json(updatedStats)
})
type GetProgressStatsResBody = {
@@ -214,7 +212,7 @@ export type DegreeProgramme = Pick<
'code' | 'name' | 'degreeProgrammeType' | 'progId'
>
type GetStudentStatsResBody = {
type GetStudetStatsResBody = {
id: string
years: string[]
facultyTableStats: Record<string, (number | string)[]>
@@ -231,7 +229,7 @@ type GetStudentStatsResBody = {
router.get<
GetStatsParams,
CanError<GetStudentStatsResBody, GenericApplicationError>,
CanError<GetStudetStatsResBody, GenericApplicationError>,
GetStatsReqBody,
GetProgressStatsQuery
>('/:id/studentstats', auth.roles(['facultyStatistics']), async (req, res) => {
-10
View File
@@ -1,10 +0,0 @@
import { Router } from 'express'
const router = Router()
/** Used for checking if backend is up eg. Docker healthchecks */
router.get<never, { status: string }>('/', (_, res) => {
return res.status(200).json({ status: 'healthy' })
})
export default router
+1 -1
View File
@@ -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()
+21 -30
View File
@@ -18,11 +18,7 @@ import {
import { Unification } from '@oodikone/shared/types'
import { keyBy, mapToProviders } from '@oodikone/shared/util'
import { rootOrgId } from '../config'
import {
getCourseProvidersForCourses,
maxYearsToCreatePopulationFrom,
searchAndCombineSubstitutionGroupsToCodes,
} from '../services/courses'
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
import { encrypt } from '../services/encrypt'
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
import { getStudentTagMap } from '../services/populations/getStudentData'
@@ -30,7 +26,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, handleQueryArrays, hasFullAccessToStudentData } from '../util'
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
const router = Router()
@@ -102,17 +98,21 @@ const obfuscateStuff = ({
}
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
'/populationstatistics',
'/v3/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.length)) {
if (requiredFields.some(field => !field)) {
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
}
@@ -172,15 +172,14 @@ router.get<
CanError<PopulationstatisticsbycourseResBody>,
PopulationstatisticsbycourseReqBody,
PopulationstatisticsbycourseParams
>('/populationstatisticsbycourse', async (req, res) => {
>('/v3/populationstatisticsbycourse', async (req, res) => {
const {
id: userId,
roles: userRoles,
programmeRights: userProgrammeRights,
studentsUserCanAccess: allStudentsUserCanAccess,
} = req.user
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses, ...restParams } = req.query
const includeSubstitutions = restParams.includeSubstitutions === 'true'
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
if (!coursecodeJSON || !from || !to) {
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
@@ -188,17 +187,16 @@ 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(codes, Unification.REGULAR)
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
}
const studentNumbers = await findByCourseAndSemesters(codes, Number(from), Number(to), isSeparate, unifyCourses)
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
@@ -219,25 +217,18 @@ router.get<
studentsUserCanAccess,
})
return res.json({ ...processed, mainCourseCodes: coursecodes, allCourseCodes: codes })
return res.json(processed)
})
// Used in custom population and single study guidance groups
router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
'/populationstatisticsbystudentnumbers',
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
'/v3/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body ?? { studentNumbers: [] }
const { studentNumbers, tags } = req.body
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)
? students
: intersection(students, studentsUserCanAccess)
? studentNumbers
: intersection(studentNumbers, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
@@ -251,14 +242,14 @@ router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/populationstatistics/studyprogrammes',
'/v3/populationstatistics/studyprogrammes',
async (req, res) => {
const { roles, programmeRights } = req.user
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
@@ -284,7 +275,7 @@ router.get<
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
never,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
>('/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
>('/v3/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,21 +1,16 @@
import { Router } from 'express'
import { Request, Response, Router } from 'express'
import { CurriculumOption } from '@oodikone/shared/types'
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
const router = Router()
type CurriculumOptionParams = { code: string }
router.get<CurriculumOptionParams, CurriculumOption[] | null>('/curriculum-options/:code', async (req, res) => {
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
const { code } = req.params
const result = await getCurriculumOptions(code)
res.json(result)
})
type CurriculumPeriodParams = { code: string; periodIds: string }
router.get<CurriculumPeriodParams>('/curriculum/:code/:periodIds', async (req, res) => {
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
const { code, periodIds } = req.params
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
+66 -63
View File
@@ -1,4 +1,4 @@
import { Router } from 'express'
import { Request, Response, 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()
type StudyProgrammeParams = { id: string }
type UpdateStudyProgrammeQuery = { combined_programme: string }
type CreditStatsQuery = {
codes: string
specialGroups: SpecialGroups
yearType: YearType
interface GetCreditStatsRequest extends Request {
query: {
codes: string
specialGroups: SpecialGroups
yearType: YearType
}
}
router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats', async (req, res) => {
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
const { codes, specialGroups, yearType } = req.query
const stats: CreditStatsPayload = {}
for (const code of JSON.parse(codes)) {
@@ -40,13 +40,15 @@ router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats
return res.json(stats)
})
type StatsRequestQuery = {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
interface GetStatsRequest extends Request {
query: {
year_type: YearType
special_groups: SpecialGroups
combined_programme: string
}
}
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basicstats', async (req, res) => {
router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -73,7 +75,7 @@ router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basics
return res.json(updatedStats)
})
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/graduationstats', async (req, res) => {
router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) => {
const code = req.params.id
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -100,12 +102,14 @@ router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/gradua
return res.json(updatedStats)
})
type CourseStatsQuery = {
combinedProgramme: string
yearType: YearType
interface GetCourseStatsRequest extends Request {
query: {
combinedProgramme: string
yearType: YearType
}
}
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
const code = req.params.id
const { combinedProgramme, yearType } = req.query
const date = new Date()
@@ -119,13 +123,15 @@ router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/courses
}
})
type StudyTrackStatsQuery = {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
interface GetStudyTrackStatsRequest extends Request {
query: {
graduated: Graduated
special_groups: SpecialGroups
combined_programme: string
}
}
router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/studytrackstats', async (req, res) => {
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
const code = req.params.id
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
if (!code) {
@@ -147,16 +153,13 @@ router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/stu
},
studyRightsOfProgramme,
})
if (updated) {
await setStudyTrackStats(updated, specialGroups)
return res.json(updated)
}
return res.status(500).end()
return res.json(updated)
})
router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, res) => {
router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response) => {
const code = req.params.id
try {
const data = await getStudyProgrammeStatsForColorizedCoursesTable(code)
@@ -166,7 +169,7 @@ router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, r
}
})
router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
router.get('/:id/studytracks', async (req: Request, res: Response) => {
const code = req.params.id
if (!code) {
return res.status(422).end()
@@ -179,42 +182,42 @@ router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
}
})
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 })
}
interface GetUpdateViewRequest extends Request {
query: {
combined_programme: string
}
)
}
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 })
}
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' })
}
)
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
+3 -4
View File
@@ -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, handleQueryArrays, validateParamLength } from '../util'
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
const router = Router()
@@ -91,12 +91,11 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
const { roles, programmeRights, iamGroups } = req.user
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
const { providers: providerStack, semesterStart, semesterEnd } = req.query
if (!providerStack || !semesterStart) {
const { providers, semesterStart, semesterEnd } = req.query
if (!providers || !semesterStart) {
return res.status(422).send('Missing required query parameters')
}
const providers = handleQueryArrays(providerStack)
const providerRights = mapToProviders(fullStudyProgrammeRights)
if (
!(
+74 -82
View File
@@ -1,12 +1,11 @@
import { Request, Response, Router } from 'express'
import { cloneDeep } from 'lodash-es'
import { FacultyGraduationStatistics, Graduated, NameWithCode } from '@oodikone/shared/types'
import { MediansByCategory, UniversityGraduationStatistics } from '@oodikone/shared/types/graduations'
import { Graduated, NameWithCode } from '@oodikone/shared/types'
import { magicFacultyCode } from '../config/organizationConstants'
import { OrganizationModel } from '../models'
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
import { countGraduationTimes, LevelGraduationStats } from '../services/faculty/facultyGraduationTimes'
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
import {
getFacultyProgressStats,
@@ -15,21 +14,20 @@ import {
setFacultyProgressStats,
} from '../services/faculty/facultyService'
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
import { GraduationTarget } from '../services/graduationHelpers'
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
import { objectKeyAnnihilator } from '../util'
import logger from '../util/logger'
const router = Router()
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
const getProgrammeNames = (faculties: OrganizationModel[]) =>
faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
const getProgrammeNames = (faculties: OrganizationModel[]) => {
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
const { name, code } = faculty.toJSON()
obj[faculty.code] = { code, ...name }
return obj
}, {})
}
interface GetProgressStatsRequest extends Request {
query: {
@@ -121,20 +119,16 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
res.status(200).json(universityData)
})
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
router.get('/allgraduationstats', async (_req: Request, res: Response) => {
const degreeNames = ['bachelor', 'bcMsCombo', 'master', 'doctor'] as const
const allFaculties = await getSortedFaculties()
const facultyCodes = allFaculties.map(faculty => faculty.code)
const facultyData: Record<string, FacultyGraduationStatistics> = {}
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
const programmeFilter = 'NEW_DEGREE_PROGRAMMES'
for (const facultyCode of facultyCodes) {
let data = await getGraduationStats(facultyCode, programmeFilter, true)
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
if (!data) {
logger.info(`Data missing from redis: Refreshing graduation faculty data for faculty ${facultyCode}`)
logger.info(`Data missing from server: Refreshing graduation faculty data for faculty ${facultyCode}`)
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, true)
data = await countGraduationTimes(facultyCode, programmes)
await setGraduationStats(data, programmeFilter)
@@ -142,50 +136,43 @@ router.get('/allgraduationstats', async (_req: Request, res: Response<University
facultyData[facultyCode] = data
}
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: [],
},
programmes: {
medians: {
bachelor: {},
bcMsCombo: {},
master: {},
doctor: {},
const universityData = {
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
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 }> }>
>,
},
},
}
const classSizes: UniversityGraduationStatistics['classSizes'] = {
programmes: {},
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>>>,
},
}
/** 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)
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)
if (!universityYearStats) {
mediansForUniversity[degreeType].push(facultyYearStats)
universityStats.push(yearStats)
} else {
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
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
if (isLast) {
universityYearStats.median = getMedian(universityYearStats.times)
}
@@ -194,28 +181,29 @@ router.get('/allgraduationstats', async (_req: Request, res: Response<University
}
}
/** 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]) {
mediansForUniversity[degreeType][facultyYearStats.name] ??= { programmes: [], data: [] }
const uniYearStats = mediansForUniversity[degreeType][facultyYearStats.name]
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: [] }
}
const uniYearStats = mediansForUniversity[degree][yearData.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(facultyYearStats), name: facultyCode, code: facultyCode })
uniYearStats.data.push({ ...cloneDeep(yearData), name: facultyCode, code: facultyCode })
} else {
uniYearFacultyStats.times.push(...uniYearFacultyStats.times)
uniYearFacultyStats.median = getMedian(uniYearFacultyStats.times)
uniYearFacultyStats.amount += facultyYearStats.amount
uniYearFacultyStats.statistics.onTime += facultyYearStats.statistics.onTime
uniYearFacultyStats.statistics.yearOver += facultyYearStats.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += facultyYearStats.statistics.wayOver
uniYearFacultyStats.amount += yearData.amount
uniYearFacultyStats.statistics.onTime += yearData.statistics.onTime
uniYearFacultyStats.statistics.yearOver += yearData.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += yearData.statistics.wayOver
}
}
}
@@ -224,23 +212,27 @@ router.get('/allgraduationstats', async (_req: Request, res: Response<University
for (let i = 0; i < facultyCodes.length; i++) {
const facultyCode = facultyCodes[i]
const data = facultyData[facultyCode]
unifyTotals(data.byGradYear.medians, i === facultyCodes.length - 1)
unifyFacultyStats(data.byGradYear.medians, facultyCode)
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
classSizes.programmes[facultyCode] = rest
const { programmes, ...rest } = data.classSizes
universityData.classSizes.programmes[facultyCode] = rest as Record<string, Record<string, number>>
}
// Individual times are no longer needed as medians/averages were already computed.
objectKeyAnnihilator(byGradYear, 'times', [])
res.status(200).json({
goals,
programmeNames,
byGradYear,
classSizes,
})
res.status(200).json(universityData)
})
export default router
+18 -21
View File
@@ -1,9 +1,9 @@
import { Request, Response, Router } from 'express'
import { languageCenterViewEnabled } from '../config'
import { refreshFaculties, refreshProgrammes } from '../events'
import {
abortUpdate,
nukeRedis,
updateSISCoursesByCourseCode,
updateSISMetadata,
updateSISProgrammes,
@@ -12,28 +12,28 @@ import {
updateSISStudentsByStudentNumber,
} from '../services/sisUpdaterService'
import logger from '../util/logger'
import { jobQueue } from '../worker/queue'
import { getJobs, jobMaker, removeWaitingJobs } from '../worker/queue'
const router = Router()
const refreshFacultiesByList = (facultyCodes: string[]) => {
facultyCodes.forEach(code => jobQueue.refreshFaculty(code))
facultyCodes.forEach(code => jobMaker.faculty(code))
return 'Added jobs for refreshing faculties'
}
const refreshProgrammesByList = (programmeCodes: string[]) => {
programmeCodes.forEach(code => jobQueue.refreshProgramme(code))
programmeCodes.forEach(code => jobMaker.programme(code))
return 'Added jobs for refreshing programme'
}
router.get('/update/meta', async (_req: Request, res: Response) => {
router.get('/update/v2/meta', async (_req: Request, res: Response) => {
const response = await updateSISMetadata()
if (response) {
res.status(200).json('Update SIS meta scheduled')
}
})
router.get('/update/students', async (_req: Request, res: Response) => {
router.get('/update/v2/students', async (_req: Request, res: Response) => {
const response = await updateSISStudents()
if (response) {
res.status(200).json('Update SIS students scheduled')
@@ -47,7 +47,7 @@ interface PostCustomListRequest extends Request {
}
}
router.post('/update/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
const { type } = req.params
const list = req.body
const typeToJob = {
@@ -57,7 +57,7 @@ router.post('/update/customlist/:type', async (req: PostCustomListRequest, res:
programmes: refreshProgrammesByList,
}
if (typeof typeToJob[type] !== 'function') {
return res.status(400).json('Bad job type')
return res.status(400).json('Updater does not recognize this job type: ' + type)
}
const response = await typeToJob[type](list)
if (response) {
@@ -65,7 +65,7 @@ router.post('/update/customlist/:type', async (req: PostCustomListRequest, res:
}
})
router.get('/update/programmes', async (_req: Request, res: Response) => {
router.get('/update/v2/programmes', async (_req: Request, res: Response) => {
const response = await updateSISProgrammes()
if (response) {
res.status(200).json('Update SIS programmes scheduled')
@@ -80,17 +80,11 @@ 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`
)
jobQueue.refreshTeacherLeaderboard()
jobMaker.teacherLeaderboard()
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
})
@@ -108,13 +102,15 @@ router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
router.post('/refresh_language_center_data', (req: Request, res: Response) => {
logger.info(`${req.user.username} requested refresh of language center data`)
jobQueue.refreshLanguagecenter()
if (!languageCenterViewEnabled)
res.status(418).json({ error: 'The language center functionality is not activated in your environment.' })
jobMaker.languagecenter()
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`)
jobQueue.refreshCloseToGraduation()
jobMaker.closeToGraduation()
res.status(200).json('Added job for refreshing close to graduation data')
})
@@ -124,12 +120,13 @@ router.get('/abort', async (_req: Request, res: Response) => {
})
router.get('/jobs', async (_req: Request, res: Response) => {
const jobs = await jobQueue.getJobs()
res.status(200).json(jobs)
const waiting = await getJobs('waiting')
const active = await getJobs('active')
res.status(200).json({ waiting, active })
})
router.delete('/jobs', async (_req: Request, res: Response) => {
await jobQueue.clearWaitingJobs()
await removeWaitingJobs()
res.status(200).end()
})
+57 -27
View File
@@ -1,34 +1,33 @@
import { Router } from 'express'
import { LANGUAGE_CODES } from '@oodikone/shared/language'
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
import { CanError } from '@oodikone/shared/routes'
import {
ChangeLanguageReqBody,
GetUserByIdParams,
GetUserByIdResBody,
GetUserRolesResBody,
GetUsersResBody,
ModifyUserRolesReqBody,
PostUserEmailReqBody,
UserEmailPreviewResBody,
UserIDElementsParams,
UserIDElementsReqBody,
} from '@oodikone/shared/routes/users'
import { Role } from '@oodikone/shared/types'
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)
})
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
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, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
'/:uid',
@@ -42,35 +41,61 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
}
)
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
type ModifyUserRolesReqBody = {
username: string
roles: Record<Role, boolean>
}
type ModifyUserRolesResBody = never
router.post<never, CanError<ModifyUserRolesResBody, 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 })
})
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' })
type PostUserEmailReqBody = { email: string }
type PostUserEmailResBody = never
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
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' })
logger.info('Succesfully sent message about Oodikone access to user')
return res.status(200).end()
})
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
if (error) return res.status(500).json({ error: error.message })
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
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>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -81,7 +106,9 @@ router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
}
)
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
type DeleteUserUIDElementsResBody = void
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
'/:uid/elements',
auth.roles(['admin']),
async (req, res) => {
@@ -92,7 +119,10 @@ router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams
}
)
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
type ChangeLanguageReqBody = { language: Language }
type PostChangeLanguageResBody = void
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
const { language } = req.body
if (!LANGUAGE_CODES.includes(language)) {
-28
View File
@@ -1,28 +0,0 @@
import { Op } from 'sequelize'
import { Banner } from '@oodikone/shared/models/kone'
import { BannerModel } from '../models/kone/banner'
import { SetOptional } from '../types'
export const getActiveBanners = async () =>
await BannerModel.findAll({
attributes: ['id', 'text', 'lightness', 'color', 'startDate', 'endDate'],
where: {
endDate: {
[Op.gt]: new Date(),
},
startDate: {
[Op.lt]: new Date(),
},
},
raw: true,
})
export const getAllBanners = async () => await BannerModel.findAll({ raw: true })
export const saveBanner = async (payload: SetOptional<Banner, 'id'>) => {
if (!payload.id) {
await BannerModel.create(payload)
} else {
await BannerModel.upsert(payload)
}
}
@@ -1,30 +1,64 @@
import { col, fn, Op, where } from 'sequelize'
import {
CompletedCoursesCourse,
CompletedCoursesStudent,
CreditTypeCode,
EnrollmentState,
StudentCredits,
} from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold, omitKeys } from '@oodikone/shared/util'
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
type StudentWithStudyplanNested = Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> & {
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'
> {
studyplans: { included_courses: string[] }[]
}
type StudentWithCourses = Omit<StudentWithStudyplanNested, 'studyplans'> & {
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
coursesInStudyPlan: string[]
}
const getCourses = async (courseCodes: string[]) => {
const courses: CompletedCoursesCourse[] = (
const courses: Courses = (
await CourseModel.findAll({
attributes: ['code', 'name', 'substitution_groups'],
attributes: ['code', 'name', 'substitutions'],
where: where(fn('LOWER', col('code')), {
[Op.in]: courseCodes.map(code => code.toLowerCase()),
}),
@@ -33,76 +67,44 @@ const getCourses = async (courseCodes: string[]) => {
return courses
}
const getPassedCredits = async (
courses: CompletedCoursesCourse[],
const getCredits = async (
courses: Courses,
fullCourseCodes: string[],
courseCodes: string[],
studentNumbers: string[]
) => {
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,
},
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 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
})
}
}
}
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,
}
})
return formattedCredits
}
const getEnrollments = async (
courses: CompletedCoursesCourse[],
fullCourseCodes: string[],
studentNumbers: string[]
) => {
const getEnrollments = async (courses: Courses, 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,
@@ -113,45 +115,17 @@ const getEnrollments = async (
state: {
[Op.eq]: EnrollmentState.ENROLLED,
},
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
},
})
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
})
}
}
}
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,
}
})
return formattedEnrollments
}
@@ -182,23 +156,18 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
export const getCompletedCourses = async (
studentNumbers: string[],
courseCodes: string[]
): Promise<{ students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]; courses: CompletedCoursesCourse[] }> => {
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
const courses = await getCourses(courseCodes)
const courseCodesSet = new Set(courseCodes)
// Get *ALL* courses including any courses in any substitution groups
for (const course of courses) {
courses.forEach(course => {
courseCodesSet.add(course.code)
for (const group of course.substitution_groups) {
for (const code of group) {
courseCodesSet.add(code)
}
}
}
course.substitutions.forEach(substitution => {
courseCodesSet.add(substitution)
})
})
const fullCourseCodes = Array.from(courseCodesSet)
const credits = await getPassedCredits(courses, fullCourseCodes, studentNumbers)
const credits = await getCredits(courses, fullCourseCodes, courseCodes, studentNumbers)
const enrollments = await getEnrollments(courses, fullCourseCodes, studentNumbers)
const studentInfo = await getStudents(studentNumbers)
@@ -254,30 +223,27 @@ export const getCompletedCourses = async (
})
})
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
},
[]
)
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[])
students.forEach(student => {
courseCodes.forEach(courseCode => {
const [latestEnrollment] = student.allEnrollments
.filter(enrollment => enrollment.courseCode === courseCode)
.filter(enrollment => enrollment.courseCode === courseCode || enrollment.substitution === courseCode)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
student.enrollments[courseCode] = latestEnrollment
})
@@ -1,6 +1,8 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { CourseWithSubsId } from '@oodikone/shared/types/course'
import { getSortRank } from '../../../src/util/sortRank'
import { CourseModel } from '../../models'
const nameLikeTerm = (name: string) => {
@@ -38,8 +40,8 @@ const codeLikeTerm = (code: string) => {
}
}
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
const courses = await CourseModel.findAll({
const getRawCourses = async (name: string, code: string, includeSpecial: boolean) =>
await CourseModel.findAll({
where: {
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
...nameLikeTerm(name),
@@ -48,23 +50,42 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string, incl
raw: true,
})
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,
})
})
)
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[]
}
return { ...course, substitution_groups: groupDetails }
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 coursesWithSubstitutionGroupDetails
courses
.filter(course => !visitedCourses.has(course.id) && !visitedCourses.has(course.code))
.forEach((course, index) => assignSubstitutionGroup(course, index))
return courses
}
export const getCoursesByCodes = (codes: string[]) => {
@@ -124,7 +124,6 @@ export class CourseYearlyStatsCounter {
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
}
/** NOTE: Duplicates are counted by year eg. you can be both in 2022 and 2023 enrollments */
public markEnrollmentToGroup(
studentNumber: string,
enrollmentDateTime: Date,
@@ -1,10 +1,7 @@
import { Op } from 'sequelize'
import { Credit, Enrollment } from '@oodikone/shared/models'
import { CreditTypeCode, EnrollmentState, Unification } from '@oodikone/shared/types'
import { enrollmentTimeDateThreshold } from '@oodikone/shared/util'
import { EnrollmentState, Unification } from '@oodikone/shared/types'
import {
StudentModel,
CreditModel,
SemesterModel,
OrganizationModel,
@@ -14,85 +11,37 @@ import {
} from '../../models'
import { getIsOpen } from './helpers'
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'],
export const getCreditsForCourses = async (codes: string[], unification: Unification) =>
CreditModel.findAll({
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
include: [
{
model: CreditModel,
attributes: [
'grade',
'course_code',
'credits',
'attainment_date',
'student_studentnumber',
'studyright_id',
'credittypecode',
],
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
course_code: {
[Op.in]: allCourseCodes,
startdate: {
[Op.lte]: new Date(),
},
is_open: getIsOpen(unification),
credittypecode: { [Op.not]: CreditTypeCode.IMPROVED }, // We do not care about improved grades
},
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'],
@@ -134,65 +83,26 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
}, {})
}
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'],
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) =>
EnrollmentModel.findAll({
attributes: ['studentnumber', 'enrollment_date_time', 'course_code'],
include: [
{
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(),
},
},
},
],
model: SemesterModel,
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
where: {
course_code: {
[Op.in]: allCourseCodes,
startdate: {
[Op.lte]: new Date(),
},
// 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
}
+63 -208
View File
@@ -1,14 +1,11 @@
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 { isOpenUniCourseCode } from '../../util'
import { getSemestersAndYears, SemestersAndYears } from '../semesters'
import { getOpenUniCourseCode } from '../../util'
import { getSortRank } from '../../util/sortRank'
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
import {
getCreditsForCourses,
@@ -17,6 +14,13 @@ 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,
@@ -43,37 +47,20 @@ 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 = (
creditGroup: Credit[],
credit: CreditModel,
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,
} = attainment
} = credit
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
const programmeOfCredit: SISStudyRightElementModel | undefined =
@@ -100,7 +87,7 @@ const parseCredit = (
attainmentDate,
courseCode,
grade,
passed: singleCourse ? !CreditModel.failed(attainment) : true,
passed: !CreditModel.failed(credit),
studentNumber: anonymizationSalt ? anonymizeStudentNumber(studentNumber, anonymizationSalt) : studentNumber,
programme,
credits,
@@ -108,77 +95,36 @@ const parseCredit = (
}
type FormattedEnrollment = {
yearCode: number
yearName: string
semesterCode: number
semesterName: Name
courseCode: string
enrollmentDateTime: Date
programme: FormattedProgramme
studentNumber: string
}
const parseEnrollment = (
enrollment: Enrollment,
anonymizationSalt: string | null,
studyRightElements: Array<SISStudyRightElementModel>
): FormattedEnrollment => {
const parseEnrollment = (enrollment: EnrollmentModel, anonymizationSalt: string | null): FormattedEnrollment => {
const {
studentnumber: studentNumber,
semester,
enrollment_date_time: enrollmentDateTime,
course_code: courseCode,
studyright_id: studyRightId,
} = enrollment
const programmeOfEnrollment: 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 = programmeOfEnrollment
? formatStudyRightElement(programmeOfEnrollment)
: {
code: 'OTHER',
name: { en: 'Other', fi: 'Muu', sv: 'Andra' },
facultyCode: 'OTHER',
organization: { name: { en: 'Other', fi: 'Muu', sv: 'Andra' } },
}
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
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,
@@ -186,38 +132,27 @@ const getYearlyStatsOfNew = async (
combineSubstitutions: boolean,
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
) => {
// Includes main course code and substitutions (if enabled)
const creditGroupCodes =
combineSubstitutions && course?.substitution_groups
? [[courseCode]].concat(course.substitution_groups)
: [[courseCode]]
const course: Pick<CourseModel, 'code' | 'name' | 'substitutions'> | null = await CourseModel.findOne({
attributes: ['code', 'name', 'substitutions'],
where: { code: 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 substitutedCodes =
combineSubstitutions && course?.substitutions ? sortMainCode([...course.substitutions, courseCode]) : [courseCode]
const { semesters, years } = await getSemestersAndYears()
const codes =
unification === Unification.REGULAR
? substitutedCodes.filter(course => !getOpenUniCourseCode(course))
: substitutedCodes
const [creditGroups, enrollmentGroups] = await Promise.all([
getCreditsForCourses(filteredCreditGroupCodes, unification),
getEnrollmentsForCourses(filteredCreditGroupCodes, unification),
const [credits, enrollments] = await Promise.all([
getCreditsForCourses(codes, unification),
getEnrollmentsForCourses(codes, unification),
])
const counter = new CourseYearlyStatsCounter()
for (const creditGroup of creditGroups) {
if (!creditGroup?.length) continue
for (const credit of credits) {
const {
studentNumber,
grade,
@@ -228,14 +163,9 @@ const getYearlyStatsOfNew = async (
yearName,
attainmentDate,
programme,
courseCode: creditCourseCode,
credits,
} = parseCredit(
creditGroup,
anonymizationSalt,
courseCode,
studentNumberToSrElementsMap[creditGroup.at(0)?.student_studentnumber ?? 0] ?? []
)
credits,
} = parseCredit(credit, anonymizationSalt, studentNumberToSrElementsMap[credit.student_studentnumber] ?? [])
counter.markStudyProgramme(
studentNumber,
@@ -248,77 +178,42 @@ 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, creditCourseCode, yearCode)
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, courseCode, yearCode)
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
}
for (const enrollments of enrollmentGroups) {
if (!enrollments?.length) continue
for (const enrollment of enrollments) {
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, enrollmentDateTime } =
parseEnrollment(enrollment, anonymizationSalt)
for (const enrollment of enrollments) {
const {
studentNumber,
courseCode: enrollmentCourseCode,
enrollmentDateTime,
programme,
} = parseEnrollment(
enrollment,
anonymizationSalt,
studentNumberToSrElementsMap[enrollment.studentnumber ?? 0] ?? []
)
const groupCode = separate ? semesterCode : yearCode
const groupName = separate ? semesterName : yearName
// 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
)
}
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
}
const statistics = await counter.getFinalStatistics(anonymizationSalt)
const substitutionGroups: Pick<CourseModel, 'code' | 'name'>[][] =
combineSubstitutions && course?.substitution_groups?.length
? await getSubstitutionGroupDetails(course.substitution_groups)
: [[{ code: course!.code, name: course!.name }]]
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 }]
return {
...statistics,
coursecode: courseCode,
alternatives: substitutionGroups,
alternatives: substitutionCourses,
name: course?.name,
}
}
/**
Mostly-legacy function that prevents user from opening too big of a populations.
At the moment the threshold to prevent a population is so high it will never trigger.
*/
export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unification: Unification) => {
const lastAttainmentDate = (await CourseModel.findOne({
attributes: [[dbFn('MAX', dbCol('max_attainment_date')), 'date']],
@@ -328,7 +223,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
raw: true,
})) as { date: Date } | null
if (lastAttainmentDate?.date == null) return 0
if (lastAttainmentDate === null) return 0
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
@@ -342,7 +237,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
})
// MAGIC NUMBER
const maxAllowedAttainments = 1_000_000 // * Lower this value to get a smaller result if necessary
const maxAllowedAttainments = 1000000 // * Lower this value to get a smaller result if necessary
return Math.max(1, maxAllowedAttainments / attainmentsWithinThreshold)
}
@@ -364,7 +259,6 @@ export const getCourseYearlyStats = async (
[Op.in]: courseCodes,
},
state: EnrollmentState.ENROLLED,
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
},
}),
])
@@ -383,20 +277,8 @@ 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,
@@ -405,7 +287,6 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.REGULAR,
@@ -414,7 +295,6 @@ export const getCourseYearlyStats = async (
studentNumberToSrElementsMap
),
getYearlyStatsOfNew(
course,
courseCode,
separate,
Unification.UNIFY,
@@ -431,8 +311,8 @@ export const getCourseYearlyStats = async (
return statsRegular
}
export const getCourseProvidersForCourses = async (codes: string[]) =>
(
export const getCourseProvidersForCourses = async (codes: string[]) => {
return (
await OrganizationModel.findAll({
attributes: ['code'],
include: {
@@ -446,29 +326,4 @@ export const getCourseProvidersForCourses = async (codes: string[]) =>
raw: true,
})
).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,77 +1,35 @@
import { Name, DegreeProgrammeType, FacultyGraduationStatistics, ClassSizes } from '@oodikone/shared/types'
import type {
GraduationStatistics,
GraduationStats,
MediansByCategory,
MediansByProgrammes,
ProgrammeGraduationStats,
ProgrammeMedians,
} from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { cloneDeep, omit } from 'lodash-es'
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
import { GraduationTarget } from '../graduationHelpers'
import { getGraduationStatsForStudyTrack } from '../studyProgramme/studyProgrammeGraduations'
import { getAverage, getMedian } from '../studyProgramme/studyProgrammeHelpers'
import { getGraduationStatsForStudyTrack, GraduationTimes } 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 setYearlyBreakdown = (code: string, name: string, level: ProgrammeMedians, year: GraduationStats) => {
if (!year.times.length) return
level[year.name] ??= { data: [], programmes: [] }
level[year.name].programmes.push(code)
level[year.name].data.push(omitKeys({ ...structuredClone(year), name, code }, ['classSize']))
}
const mergeStats = (
medians: MediansByCategory,
level: keyof MediansByCategory,
statsForYear: GraduationStats,
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(structuredClone(statsForYear))
} else {
gradStatsForYear.times.push(...statsForYear.times)
gradStatsForYear.median = getMedian(gradStatsForYear.times)
gradStatsForYear.average = getAverage(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: {},
},
}
const getStatsByGraduationYear = async (facultyProgrammes: ProgrammesOfOrganization) => {
const newStats: ProgrammeGraduationStats[] = []
const medians: Record<string, LevelGraduationStats[]> = {}
const programmes: { medians: Record<string, Record<string, ProgrammeStats>> } = { medians: {} }
for (const programme of facultyProgrammes) {
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
continue
}
const statsFromRedis = await getGraduationStats(programme.code, '', 'CALENDAR_YEAR', 'SPECIAL_INCLUDED')
@@ -92,44 +50,92 @@ const getStatsByGraduationYear = async (
}
for (const { id: programmeCode, graduationTimes, comboTimes } of newStats) {
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const { degreeProgrammeType, progId, code } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const level = programmeTypes[degreeProgrammeType as keyof typeof programmeTypes]
for (const year of graduationTimes.medians) {
mergeStats(medians, level, year)
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], year)
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)
}
if (level === 'master' && comboTimes.medians.length) {
if (!programmes.medians.bcMsCombo) {
programmes.medians.bcMsCombo = {}
}
for (const year of comboTimes.medians) {
mergeStats(medians, 'bcMsCombo', year)
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, year)
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)
}
}
}
return { medians, 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: {},
},
}
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: {} }
for (const programme of facultyProgrammes) {
if (!programme.degreeProgrammeType || !(programme.degreeProgrammeType in programmeTypes)) {
if (programme.degreeProgrammeType == null || !(programme.degreeProgrammeType in programmeTypes)) {
continue
}
const statsFromRedis = await getStudyTrackStats(programme.code, null, 'SPECIAL_EXCLUDED')
const statsFromRedis = await getStudyTrackStats(programme.code, '', 'SPECIAL_EXCLUDED')
if (statsFromRedis) {
newStats.push(statsFromRedis)
continue
@@ -149,13 +155,9 @@ const getStatsByStartYear = async (
}
for (const { id: programmeCode, graduationTimes } of newStats) {
const { degreeProgrammeType, progId } = facultyProgrammes.find(({ code }) => code === programmeCode)!
const { degreeProgrammeType, progId, code } = 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
@@ -163,31 +165,88 @@ const getStatsByStartYear = async (
(acc, year) => ({ ...acc, [year.name]: year.classSize ?? 0 }),
{}
)
classSizes.programmes[programmeCode] = classSizesByYear
classSizes.programmes[code] = classSizesByYear
if (!medians[level]) {
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
}
classSizes[level] ??= {}
for (const statsForYear of basicStats) {
mergeStats(medians, level, statsForYear, classSizes)
setYearlyBreakdown(programmeCode, progId, programmes.medians[level], statsForYear)
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
}
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) {
mergeStats(medians, 'bcMsCombo', statsForYear, classSizes)
setYearlyBreakdown(programmeCode, progId, programmes.medians.bcMsCombo, statsForYear)
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)
}
}
}
return { medians, programmes, classSizes }
}
export const countGraduationTimes = async (
facultyCode: string,
programmesOfFaculty: ProgrammesOfOrganization
): Promise<FacultyGraduationStatistics> => {
export const countGraduationTimes = async (faculty: string, programmesOfFaculty: ProgrammesOfOrganization) => {
const { medians: mediansByGraduationYear, programmes: programmesByGraduationYear } =
await getStatsByGraduationYear(programmesOfFaculty)
const {
medians: mediansByStartYear,
programmes: programmesByStartYear,
@@ -196,7 +255,7 @@ export const countGraduationTimes = async (
const goals = {
bachelor: GraduationTarget.THREE_YEARS,
bcMsCombo: facultyCode === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
master: GraduationTarget.TWO_YEARS,
doctor: GraduationTarget.FOUR_YEARS,
exceptions: {
@@ -220,7 +279,7 @@ export const countGraduationTimes = async (
}, {})
return {
id: facultyCode,
id: faculty,
goals,
byGradYear: {
medians: mediansByGraduationYear,
@@ -1,4 +1,4 @@
import { FacultyGraduationStatistics, Name, ProgrammeFilter, SpecialGroups, YearType } from '@oodikone/shared/types'
import { 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 (in backend).
university-level evaluation overview can count median-times by itself.
Faculties, however, don't need this, and it isn't needed in frontend
for university view either. This clears the array.
for universityview either. This removes them from the object.
*/
const removeGraduationTimes = (data: FacultyGraduationStatistics) => {
const removeGraduationTimes = (data: GraduationData) => {
Object.values(data.byGradYear.medians).forEach(array =>
array.forEach(yearStat => {
yearStat.times = []
yearStat.times = null
})
)
}
@@ -158,7 +158,7 @@ export type GraduationData = {
classSizes: Record<string, Record<string, number | Record<string, number>>>
}
export const setGraduationStats = async (data: FacultyGraduationStatistics, programmeFilter: ProgrammeFilter) => {
export const setGraduationStats = async (data: GraduationData, 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 FacultyGraduationStatistics
const data = JSON.parse(dataFromRedis) as GraduationData
if (!keepGraduationTimes) {
removeGraduationTimes(data)
}
@@ -48,7 +48,9 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
if (!programmeOtherCountriesCount) continue
for (const [year, countryStats] of Object.entries(programmeOtherCountriesCount)) {
facultyTableStatsExtra[year] ??= {}
if (!(year in facultyTableStatsExtra)) {
facultyTableStatsExtra[year] = {}
}
facultyTableStatsExtra[year][programmeCode] = countryStats
}
}
@@ -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) => {
@@ -1,18 +1,16 @@
import { orderBy } from 'lodash-es'
import { Op } from 'sequelize'
import { EnrollmentState } from '@oodikone/shared/types'
import { CourseModel, CreditModel, EnrollmentModel, SISStudyRightModel } from '../models'
import { redisClient } from './redis'
export const LANGUAGE_CENTER_REDIS_KEY = 'LANGUAGE_CENTER_DATA'
const isBetween = (start: Date, date: Date, end: Date) => {
const isBetween = (start, date, end) => {
return new Date(start).getTime() <= new Date(date).getTime() && new Date(date).getTime() <= new Date(end).getTime()
}
/** Assumes studyrights are in correct order */
const findStudyRight = (studyRights: SISStudyRightModel[], date: Date) => {
const findStudyRight = (studyRights, date) => {
return studyRights?.find(studyRight => isBetween(studyRight.startDate, date, studyRight.endDate))
}
@@ -25,7 +23,7 @@ const getDifference = stats => {
}
const getLanguageCenterCourses = async () => {
const courses: Pick<CourseModel, 'code' | 'name'>[] = await CourseModel.findAll({
const courses = await CourseModel.findAll({
attributes: ['code', 'name'],
where: {
[Op.or]: [{ code: { [Op.like]: 'KK%' } }, { code: { [Op.like]: 'AYKK%' } }],
@@ -35,37 +33,29 @@ const getLanguageCenterCourses = async () => {
return courses
}
export const createArrayOfCourses = (attempts: Attempt[], courses: Pick<CourseModel, 'code' | 'name'>[]) => {
export const createArrayOfCourses = async (attempts, courses) => {
const fields = { completions: 0, enrollments: 0, difference: 0, rejected: 0 }
const semesters: Record<Attempt['semestercode'], boolean> = {}
// Remove undefined as a possibility for type
const faculties: Record<Exclude<Attempt['faculty'], undefined>, boolean> = {}
// TODO: Type this properly. Good luck :)
const semesterStatsMap = attempts.reduce((acc, cur) => {
const semesters = {}
const faculties = {}
const semesterStatsMap = attempts.reduce((obj, cur) => {
const semester = cur.semestercode
if (cur.faculty) {
faculties[cur.faculty] = true
}
faculties[cur.faculty] = true
semesters[semester] = true
acc[cur.courseCode] ??= {}
// NOTE: This has to be spread because otherwise a ref to fields is saved to
// acc[cur.courseCode][semester] which would in the end result in a massive circular object
acc[cur.courseCode][semester] ??= { ...fields }
if (cur.faculty) {
acc[cur.courseCode][semester][cur.faculty] ??= { ...fields }
if (!obj[cur.courseCode]) {
obj[cur.courseCode] = {}
}
const allFacultiesTotal = acc[cur.courseCode][semester]
if (!obj[cur.courseCode][semester]) {
obj[cur.courseCode][semester] = { ...fields }
}
if (cur.faculty && !obj[cur.courseCode][semester][cur.faculty]) {
obj[cur.courseCode][semester][cur.faculty] = { ...fields }
}
const allFacultiesTotal = obj[cur.courseCode][semester]
const allStats = [allFacultiesTotal]
if (cur.faculty) {
const semesterFacultyStats = acc[cur.courseCode][semester][cur.faculty]
const semesterFacultyStats = obj[cur.courseCode][semester][cur.faculty]
allStats.push(semesterFacultyStats)
}
for (const stats of allStats) {
if (cur.completed) {
stats.completions += 1
@@ -75,7 +65,7 @@ export const createArrayOfCourses = (attempts: Attempt[], courses: Pick<CourseMo
stats.rejected += 1
}
}
return acc
return obj
}, {})
const courseList = courses
@@ -98,60 +88,55 @@ export const createArrayOfCourses = (attempts: Attempt[], courses: Pick<CourseMo
return courseList
}
type Attempt = {
studentNumber: string
courseCode: string
completed: boolean
date: Date
semestercode: number
faculty?: string
enrolled?: boolean
}
export const computeLanguageCenterData = async () => {
const courses = await getLanguageCenterCourses()
const autumnSemester2017 = 135
const credits = await CreditModel.findAll({
raw: true,
attributes: ['course_code', 'student_studentnumber', 'semestercode', 'attainment_date', 'studyright_id'],
where: {
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
semestercode: { [Op.gte]: autumnSemester2017 },
credittypecode: 4,
},
raw: true,
})
const enrollments = await EnrollmentModel.findAll({
raw: true,
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'] },
// EnrollmentDateTimeThreshold need not be used here as we are not counting failed courses
},
})
const studyRights = await SISStudyRightModel.findAll({
raw: true,
order: [['end_date', 'DESC NULLS LAST']],
})
const attemptStudyRightToFacultyMap = studyRights.reduce<
Record<SISStudyRightModel['id'], SISStudyRightModel['facultyCode']>
>((acc, cur) => {
if (!cur.id) return acc
acc[cur.id] = cur.facultyCode
return acc
const studyRights = await SISStudyRightModel.findAll({ raw: true })
const attemptStudyRightToFacultyMap = studyRights.reduce((obj, cur) => {
if (!cur.id) return obj
obj[cur.id] = cur.facultyCode
return obj
}, {})
const studentNumbers = new Set<string>()
const attemptsByStudents: Record<string, Attempt[]> = {}
credits.forEach(credit => {
credit.faculty = attemptStudyRightToFacultyMap[credit.studyright_id]
})
enrollments.forEach(enrollment => {
enrollment.faculty = attemptStudyRightToFacultyMap[enrollment.studyright_id]
})
const studentNumbers = new Set()
const attemptsByStudents = {}
credits.forEach(credit => {
const studentNumber = credit.student_studentnumber
studentNumbers.add(studentNumber)
attemptsByStudents[studentNumber] ??= []
if (!attemptsByStudents[studentNumber]) {
attemptsByStudents[studentNumber] = []
}
attemptsByStudents[studentNumber].push({
studentNumber,
courseCode: credit.course_code,
@@ -164,8 +149,10 @@ export const computeLanguageCenterData = async () => {
enrollments.forEach(enrollment => {
const studentNumber = enrollment.studentnumber
if (!attemptsByStudents[studentNumber]) {
attemptsByStudents[studentNumber] = []
}
studentNumbers.add(studentNumber)
attemptsByStudents[studentNumber] ??= []
if (
attemptsByStudents[studentNumber].find(
attempt =>
@@ -176,7 +163,6 @@ export const computeLanguageCenterData = async () => {
) {
return
}
attemptsByStudents[studentNumber].push({
studentNumber,
courseCode: enrollment.course_code,
@@ -184,25 +170,23 @@ export const computeLanguageCenterData = async () => {
date: enrollment.enrollment_date_time,
faculty: attemptStudyRightToFacultyMap[enrollment.studyright_id],
semestercode: enrollment.semestercode,
enrolled: enrollment.state === EnrollmentState.ENROLLED,
enrolled: enrollment.state === 'ENROLLED',
})
})
const studentNumberToStudyRightsMap = studyRights.reduce<
Record<SISStudyRightModel['studentNumber'], SISStudyRightModel[]>
>((acc, cur) => {
acc[cur.studentNumber] ??= []
acc[cur.studentNumber].push(cur)
return acc
const studentNumberToStudyRightsMap = studyRights.reduce((obj, cur) => {
if (!obj[cur.studentNumber]) obj[cur.studentNumber] = []
obj[cur.studentNumber].push(cur)
return obj
}, {})
const attempts: Attempt[] = []
const attempts = []
studentNumbers.forEach(studentNumber => attempts.push(...attemptsByStudents[studentNumber]))
// 93033 Avoin yliopisto, yhteistyöoppilaitokset
// 93034 Kesäyliopistot
const isOpenUni = (facultyCode: string) => ['9301', 'H930', '93033', '93034'].includes(facultyCode)
const isMisc = (facultyCode: string) => ['H906', 'H401'].includes(facultyCode) // Language center, Alexander institute (very few numbers)
const isHyFaculty = (facultyCode: string) => /^H\d/.exec(facultyCode.substring(0, 3))
const isOpenUni = facultyCode => ['9301', 'H930', '93033', '93034'].includes(facultyCode)
const isMisc = facultyCode => ['H906', 'H401'].includes(facultyCode) // Language center, Alexander institute (very few numbers)
const isHyFaculty = facultyCode => facultyCode.substring(0, 3).match('^H\\d')
attempts.forEach(attempt => {
if (attempt.faculty) {
@@ -226,7 +210,7 @@ export const computeLanguageCenterData = async () => {
}
})
const unorderedTableData = createArrayOfCourses(attempts, courses)
const unorderedTableData = await createArrayOfCourses(attempts, courses)
const tableData = orderBy(unorderedTableData, 'code')
const faculties = [...new Set(attempts.map(({ faculty }) => faculty))]
+5 -6
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { FormattedUser } from '@oodikone/shared/types'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
const pateClient = Fetchios.create({
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
params: {
token: pateToken,
},
@@ -24,7 +23,7 @@ const baseSettings = {
const sendEmail = async (options = {}) => {
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
return await pateClient.post('', options, {})
}
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
@@ -0,0 +1,33 @@
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 getUserSearches = async (userId: string) => {
export const getOpenUniSearches = async (userId: string) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches === undefined) {
return []
@@ -34,7 +34,7 @@ export const getUserSearches = async (userId: string) => {
return foundSearches
}
export const createNewUserSearch = async (userId: string, name: string, courseCodes: string[]) => {
export const createNewSearch = 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 createNewUserSearch = async (userId: string, name: string, courseCo
}
}
export const deleteUserSearch = async (userId: string, id: string) => {
export const deleteSearch = async (userId: string, id: string) => {
try {
const deleted = await deleteOpenUniSearch(userId, id)
if (!deleted) {
@@ -64,7 +64,7 @@ export const deleteUserSearch = async (userId: string, id: string) => {
}
}
export const updateUserSearch = async (userId: string, id: string, courseCodes: string[]) => {
export const updateSearch = async (userId: string, id: string, courseCodes: string[]) => {
try {
const updated = await updateOpenUniPopulationSearch(userId, id, courseCodes)
if (!updated) {

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