Compare commits

...
77 Commits
Author SHA1 Message Date
Miko Keskimäki 2fb270a070 Remove test failure report, deployment failure message is enough 2023-05-05 08:44:58 +03:00
Miko Keskimäki c894f858ca Bring back slack test failure, deployment notifications 2023-05-05 08:25:59 +03:00
Miko Keskimäki f5b04beda0 Fix wrong base path in prod deploment 2023-05-05 08:25:16 +03:00
outisa c20a4e4727 Fix bug 2023-05-04 19:11:05 +03:00
ShootingStar91 3081752d8f [Degree Courses] Fixed exploded modal UI 2023-05-04 18:49:11 +03:00
Miko Keskimäki 19fe7fd817 Remove target: production for updated Dockerfiles 2023-05-04 18:01:55 +03:00
ShootingStar91 99b3f7756c [Progress tab] Infobox margin issue fixed 2023-05-04 17:47:02 +03:00
ShootingStar91 51475be0db [Class statistics] Credits gained: info improved 2023-05-04 17:29:32 +03:00
ShootingStar91 a8eb68e317 Next academic year only shown after 1.8.: affects many views 2023-05-04 16:23:35 +03:00
ShootingStar91 8281564fef [Student statistics] Transfer text made nicer 2023-05-04 14:25:58 +03:00
ShootingStar91 4373065fdd [Studyprogramme Overview] Fixed title for doctors 2023-05-04 14:24:58 +03:00
Miko Keskimäki 8dbbf00b51 Remove openshift branch from staging action 2023-05-04 13:42:08 +03:00
Outi SavolainenandGitHub 584a1b474a Merge pull request #4195 from UniversityOfHelsinkiCS/openshift
Update configuration to work with OpenShift
2023-05-04 13:40:09 +03:00
ShootingStar91 8151c4f463 [General tab] Emails in CSV fixes 2023-05-04 12:44:05 +03:00
Miko Keskimäki bb818ee9ac Run workflow when files updated 2023-05-04 12:16:25 +03:00
Miko Keskimäki cd3b9bb843 Test new staging workflow 2023-05-04 12:14:25 +03:00
Miko Keskimäki aaa012894c Don't run sentry in staging, update staging workflow 2023-05-04 12:12:59 +03:00
ShootingStar91 4d06eb7558 Fix typo lenght to length 2023-05-04 11:17:59 +03:00
ShootingStar91 b563b29a36 [Credit accumulation] Graduations are shown as diamond-icon 2023-05-04 10:48:36 +03:00
outisa 617e234798 [class stats] Small fixes 2023-05-03 18:44:08 +03:00
Miko Keskimäki 754d525f87 Update environment configuration 2023-05-03 15:28:04 +03:00
Miko Keskimäki a0e6d342b9 Configurable Jami url 2023-05-03 14:37:27 +03:00
Miko Keskimäki 60f500c9e7 Configurable importer url 2023-05-03 14:12:17 +03:00
Miko Keskimäki dc9ce89aa7 More env variables 2023-05-03 13:37:24 +03:00
Miko Keskimäki 8a1fb2725b Pass built_at to frontend, update staging 2023-05-03 13:33:19 +03:00
Miko Keskimäki 0ca6bca243 Maybe now 2023-05-03 12:50:10 +03:00
Miko Keskimäki 7457bc412b Again 2023-05-03 12:48:13 +03:00
Miko Keskimäki 3d2e8fb1ca Try again 2023-05-03 12:47:41 +03:00
Miko Keskimäki 1d980795c6 Update image build and release 2023-05-03 12:27:38 +03:00
Miko Keskimäki 6bc6a28127 Define secrets as inputs 2023-05-03 12:11:50 +03:00
Miko Keskimäki a2f84931cd Pass toska registry secrets to workflow 2023-05-03 12:04:32 +03:00
Miko Keskimäki 1f1bae8557 Add missing build jobs 2023-05-03 11:59:15 +03:00
Miko Keskimäki c714c61269 Workflow refactor 2023-05-03 11:53:30 +03:00
Miko Keskimäki e85deff947 Delete unnecessary steps, update image names 2023-05-03 11:37:45 +03:00
Miko Keskimäki 8c87135f58 Update openshift workflow 2023-05-03 11:09:56 +03:00
Miko Keskimäki 7eac6c686e Remove extra backend package installs 2023-05-03 10:11:04 +03:00
Miko Keskimäki 11172f7d77 Update temporary workflow 2023-05-03 10:07:34 +03:00
Miko Keskimäki be29486cdd Update frontend, backend Dockerfiles to work with openshift 2023-05-03 10:04:49 +03:00
Miko Keskimäki 04cbec19a5 Separate backend and frontend dev setups to separate Dockerfiles 2023-05-03 09:50:48 +03:00
Miko Keskimäki 7c3cfb56b5 Fix iams not being saved to Jami 2023-05-03 09:10:04 +03:00
outisa 090305be3b [Class stats] Unify bachelor and master (eläinlääkkis) 2023-05-02 18:39:26 +03:00
ShootingStar91 c1e0d34670 [Programme courses] Move help texts to infobox 2023-05-02 13:51:58 +03:00
ShootingStar91 9cfc51382d [Pass/fail table] Help texts moved to infobox 2023-05-02 12:33:25 +03:00
ShootingStar91 3f179d3460 [Study Programmes] Admins have toggle to filter out old programmes 2023-05-02 12:09:40 +03:00
outisa 0ad0d73d18 [Progress tab] Add StudyGuidanceGroups betterments 2023-05-02 11:38:29 +03:00
outisa 7952b3703f Fix stupidness 2023-05-02 11:36:19 +03:00
outisa e9902a0bbe [studyGuidangeGroups]Remove tags tab from the view 2023-05-02 10:50:26 +03:00
ShootingStar91 b225e03bd5 Fix test 2023-04-28 18:54:37 +03:00
ShootingStar91 374cef4a1a [Study Programme] Programme courses tab: Shorten long names 2023-04-28 17:44:38 +03:00
ShootingStar91 6dff16f683 [Workflow] Add completed courses tests to workflow 2023-04-28 16:57:13 +03:00
ShootingStar91 73a1d50ec1 [Study Programme] Credit distribution development: target credits adjusted to age of class 2023-04-28 16:49:11 +03:00
outisa b473b393f0 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2023-04-27 21:46:34 +03:00
outisa 218f09ea65 [StudyGuidanceGroups] Add moar tabs in general view 2023-04-27 21:44:46 +03:00
outisa 5948a34c6e Adjust message box rows 2023-04-27 21:36:08 +03:00
outisa 6db0e982d9 [Stats-progress tab] Make view look better 2023-04-27 21:21:40 +03:00
ShootingStar91 205637c4db [Completed courses] Minor fix 2023-04-27 12:59:01 +03:00
ShootingStar91 7ac397969a [CustomPopulation] Invalid or forbidden studentnumbers explicitly mentioned 2023-04-27 12:59:01 +03:00
outisa 9fc927daff [Student stats-progress tab] Make semester enrollment column readable 2023-04-27 12:54:11 +03:00
outisa 72635b53ac [Openuni] Add minor impovement 2023-04-27 12:32:06 +03:00
outisa 980a7a6703 [Student stats] Change passive to inactive 2023-04-27 12:31:03 +03:00
outisa 80709a129c [courses of pop/class tab] Add limit number of students filter 2023-04-25 23:07:40 +03:00
outisa 26c7f3e00b [Courses of Pop] Remove old pass/fail table 2023-04-25 20:41:27 +03:00
outisa cc60c9f415 [Courses of class/pop] Add info box betterments 2023-04-25 20:38:50 +03:00
ShootingStar91 7f8f40a193 [Completed courses] Added tests & minor improvements 2023-04-25 16:40:49 +03:00
ShootingStar91 1749503aa4 [Completed courses] Also invalid student numbers are mentioned 2023-04-25 16:40:49 +03:00
Laura Sinikallio da55bfefac Open evalution overview for wider access 2023-04-25 12:48:07 +03:00
outisa 0c2f197460 [StudyGuidanceGroups] Add credit filter 2023-04-24 23:05:35 +03:00
ShootingStar91 6503971915 Fix broken tests 2023-04-20 15:26:31 +03:00
ShootingStar91 c30624d319 [FilterView] Fixed semester sort order 2023-04-20 14:41:31 +03:00
outisa a4c798ea25 [OpenUni] Remove dissemination_info_allowed related functionality 2023-04-20 12:41:09 +03:00
outisa bf175e9140 [Updater] Remove dissemination_info_allowed column related code 2023-04-20 12:39:12 +03:00
outisa 70f4440974 [Student stats -progress tab] Add betterments to colors 2023-04-20 12:29:49 +03:00
ShootingStar91 cde5dd5ced [Completed courses] Emails shown only if names are, all shown in csv 2023-04-20 12:05:23 +03:00
ShootingStar91 35777287b2 [SortableTable] Fixed export column selection refresh 2023-04-20 11:43:02 +03:00
ShootingStar91 3ec010f347 [Completed courses] Removed 'No completion' from CSV 2023-04-19 14:54:50 +03:00
ShootingStar91 36792ac667 [NavitagionBar] Fix minor rights bug 2023-04-19 14:30:19 +03:00
ShootingStar91 d4f187ce92 [Completed courses] Fix rights & CSV columns 2023-04-19 13:02:18 +03:00
75 changed files with 1604 additions and 1201 deletions
+1 -3
View File
@@ -35,7 +35,6 @@ jobs:
context: services/backend
push: true
tags: toska/oodikone2-backend:latest
target: production
build_and_release_frontend:
name: 'Build and release frontend'
@@ -71,11 +70,10 @@ jobs:
context: services/frontend
push: true
tags: toska/oodikone2-frontend:latest
target: production
- name: 'Create a container from built image '
run: docker create --name frontend toska/oodikone2-frontend:latest
- name: 'Copy assets from the container'
run: docker cp frontend:/usr/src/app/build ./build
run: docker cp frontend:/opt/app-root/src/build ./build
- name: 'Create Sentry release'
uses: getsentry/action-release@v1
env:
+124
View File
@@ -0,0 +1,124 @@
name: 'Test oodikone and release to staging'
on:
push:
branches:
- master
paths:
- '.github/workflows/test-oodikone.yaml'
- '.github/**'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff.js'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'nginx.conf'
- 'package*.json'
- 'updater/sis-updater-worker/src/db/migrations/**'
jobs:
test:
uses: ./.github/workflows/test.yaml
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v3
- name: Build frontend image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: staging ${{ github.sha }}
context: services/frontend
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
BASE_PATH=/oodikone
GIT_SHA=${{ github.sha }}
STAGING=true
- 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
needs: test
steps:
- uses: actions/checkout@v3
- name: Build backend image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-backend
tags: staging ${{ github.sha }}
context: services/backend
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
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 }}
report_success:
name: 'Report deployment success to oodikone notifications'
runs-on: ubuntu-latest
needs:
- release_frontend
- release_backend
if: success()
steps:
- uses: actions/checkout@v3
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment
deployment-target: staging
report_failure:
name: 'Report deployment failure to oodikone notifications'
runs-on: ubuntu-latest
needs:
- release_frontend
- release_backend
if: failure()
steps:
- uses: actions/checkout@v3
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment-failure
deployment-target: staging
@@ -1,25 +1,17 @@
name: 'Test oodikone and release to staging'
name: Lint and test
on:
push:
branches:
- master
paths:
- '.github/workflows/test-oodikone.yaml'
- '.github/actions/**'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- '!services/backend/src/rapodiff.js'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- 'nginx.conf'
- 'package*.json'
- 'updater/sis-updater-worker/src/db/migrations/**'
workflow_call:
secrets:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
jobs:
# === Static tests / linting ===
lint_backend:
name: 'Lint backend'
runs-on: ubuntu-latest
@@ -62,13 +54,11 @@ jobs:
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=staging
cache-from: |
type=registry,ref=toska/oodikone2-backend:staging
type=registry,ref=toska/oodikone2-backend:latest
type=registry,ref=quay.io/toska/oodikone-backend:staging
cache-to: type=inline
context: services/backend
push: false
tags: toska/oodikone2-backend:staging
target: production
tags: oodikone-backend:staging
outputs: type=docker,dest=/tmp/backend.tar
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v3
@@ -92,13 +82,11 @@ jobs:
SENTRY_ENVIRONMENT=staging
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
cache-from: |
type=registry,ref=toska/oodikone2-frontend:staging
type=registry,ref=toska/oodikone2-frontend:latest
type=registry,ref=toska/oodikone-frontend:staging
cache-to: type=inline
context: services/frontend
push: false
tags: toska/oodikone2-frontend:staging
target: production
tags: oodikone-frontend:staging
outputs: type=docker,dest=/tmp/frontend.tar
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v3
@@ -121,8 +109,8 @@ jobs:
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
@@ -154,8 +142,8 @@ jobs:
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
@@ -187,8 +175,8 @@ jobs:
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
if: steps.check_migrations.outputs.migrations == 'true'
run: |
@@ -211,8 +199,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Course_statistics.js
test_custom_population:
@@ -229,8 +217,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Custom_population.js
test_population_statistics:
@@ -247,8 +235,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Population_statistics.js
test_filters:
@@ -265,8 +253,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Filters.js
test_student_statistics:
@@ -283,10 +271,28 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Student_statistics.js
test_completed_courses:
name: 'Test completed courses'
needs:
- build_backend
- build_frontend
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Completed_courses.js
test_studyprogramme_overview:
name: 'Test studyprogramme overview'
needs:
@@ -301,8 +307,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Studyprogramme_overview.js
test_teachers_and_users:
@@ -319,8 +325,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: |
cypress/e2e/Teachers.js
cypress/e2e/Users.js
@@ -339,8 +345,8 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Faculty_statistics.js
test_open_uni_search:
@@ -357,12 +363,11 @@ jobs:
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Openuni.js
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
needs:
@@ -386,8 +391,8 @@ jobs:
uses: docker/login-action@v2
with:
registry: registry.toska.cs.helsinki.fi
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Run services, pull rest of the needed images'
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate
- name: 'Check that exit statuses are not errors'
@@ -400,98 +405,3 @@ jobs:
- name: 'Print logs on failure'
if: failure()
run: docker-compose --file docker-compose.ci.yml logs
# === Release ===
release_images:
name: 'Release built images'
runs-on: ubuntu-latest
needs:
- lint_backend
- lint_frontend
- test_course_statistics
- test_custom_population
- test_filters
- test_population_statistics
- test_student_statistics
- test_studyprogramme_overview
- test_teachers_and_users
- test_faculty_statistics
- test_open_uni_search
- test_service_exit_statuses
steps:
- uses: actions/checkout@v3
- name: 'Download images'
uses: actions/download-artifact@v3
with:
path: /tmp/
- name: 'Load built images'
run: |
docker load --input /tmp/artifact/backend.tar
docker load --input /tmp/artifact/frontend.tar
- name: 'Login to DockerHub'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 'Create a container from frontend'
run: docker create --name frontend toska/oodikone2-frontend:staging
- name: 'Copy assets from the container'
run: docker cp frontend:/usr/src/app/build ./build
- name: 'Create Sentry release'
uses: getsentry/action-release@v1
env:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: toska
SENTRY_PROJECT: oodikone
with:
environment: staging
set_commits: 'skip'
sourcemaps: './build/static/js'
url_prefix: '~/static/js'
version: ${{ github.sha }}
- name: 'Push to docker hub'
run: |
docker push toska/oodikone2-backend:staging
docker push toska/oodikone2-frontend:staging
- name: 'Send deployment success notification to slack'
if: success()
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment
softa-url: https://oodikone-staging.cs.helsinki.fi/
deployment-target: staging
- name: 'Send deployment failure notification to slack'
if: failure()
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment-failure
deployment-target: staging
report_test_failure:
runs-on: ubuntu-latest
name: 'Report test failure to oodikone notifications'
needs:
- lint_backend
- lint_frontend
- test_course_statistics
- test_custom_population
- test_filters
- test_population_statistics
- test_student_statistics
- test_studyprogramme_overview
- test_teachers_and_users
- test_faculty_statistics
- test_open_uni_search
- test_service_exit_statuses
if: failure()
steps:
- uses: actions/checkout@v3
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: test-failure
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
'{services,updater}/**/*.{js,jsx}': files => `eslint --fix ${files.join(' ')}`,
'*.{js,json,md,yml,yaml,html}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0 hadolint ${relativeFilePaths(files)}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
'docker-compose*': files => {
+152
View File
@@ -0,0 +1,152 @@
/// <reference types="Cypress" />
const openCompletedCoursesModal = () => {
cy.get('button').contains('Search completed courses of students').click()
}
const openSearch = () => {
cy.init('/completedcoursessearch')
hasLanded()
}
const selectSavedCourselist = name => {
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type('{enter}')
}
const hasLanded = () => {
cy.contains('Search completed courses')
cy.contains(
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
)
}
const deleteAllSearches = () => {
cy.contains('Saved courselists')
.get('.dropdown')
.then(d => {
const searchItems = d.find('div[role=option] > span[class=text]')
for (let i = 0; i < searchItems.length; i++) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
cy.get('button').contains('Delete').click()
}
}
})
}
describe('When search modal is opened', () => {
after(() => {
cy.init('/completedcoursessearch')
hasLanded()
cy.url().should('include', '/completedcoursessearch')
cy.contains('Search completed courses of students')
cy.get('[data-cy="open-completed-courses-modal-button"]').click()
deleteAllSearches()
})
it('Modal opens correctly', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.contains('Search completed courses of students')
cy.contains('Insert one or more student numbers, separated by a space, a newline, or a comma.')
cy.contains('Insert one or more courses, separated by a space, a newline, or a comma.')
cy.contains('Insert name for this course list if you wish to save it')
})
describe('When a search is executed with invalid input', () => {
it('Returns empty table and notification', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.get('[data-cy="student-no-input"]').type('1')
cy.get('[data-cy="course-list-input"]').type('1')
cy.get('[data-cy="completed-courses-search-button"]').click()
cy.get('[data-cy="rights-notification"]').should(
'contain.text',
'The following students information could not be displayed'
)
cy.get('[data-cy="rights-notification"]').should('contain.text', '1')
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Completed courses search')
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Student Number')
cy.get('[data-cy="completed-courses-table-div"]').should('not.contain', '1')
})
})
describe('When a search with correct data is executed', () => {
before(() => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy="student-no-input"]').type(studentSet1.join('\n'))
cy.get('[data-cy="course-list-input"]').type(coursesSet1.join('\n'))
cy.get('[data-cy="completed-courses-search-button"]').click()
})
})
it('Pushes the query to url', () => {
cy.url().should(
'include',
'/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&courseList=TKT10003&courseList=TKT10004&studentList=010450938&studentList=010589388&studentList=010614509'
)
})
it('Finds correct students and courses', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[0])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[1])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[2])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[0])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[1])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[2])
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[3])
})
})
})
})
const courseListNames = ['TEST-courselist1', 'TEST-courselist2']
describe('Courselist saving-related functions work', () => {
beforeEach(() => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy="course-list-input"]').type('CSM14204\nTKT10004')
cy.get('[data-cy="search-name"]').type(courseListNames[0])
cy.get('button').contains('Save').click()
})
it('Course list can be saved', () => {
selectSavedCourselist(courseListNames[0])
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').contains('TKT10004').contains('CSM14204')
})
it('Course list can be deleted', () => {
openSearch()
openCompletedCoursesModal()
cy.get('[data-cy="search-name"]').type(courseListNames[1])
cy.get('button').contains('Save').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[1])
cy.get('button').contains('Delete').click()
cy.get('[data-cy="history-search"]')
.type(courseListNames[1])
.type('{enter}')
.should('not.contain', courseListNames[1])
})
it('Course list can be updated', () => {
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').type(`,TKT10001`)
cy.get('button').contains('Save').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseListNames[0])
cy.get('[data-cy="course-list-input"]').contains(`TKT10001`)
})
})
+2 -2
View File
@@ -126,8 +126,8 @@ describe('Population Statistics', () => {
it('Enrollment filter works', () => {
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(0)
checkFilteringResult(148)
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
checkFilteringResult(147)
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
})
})
+6 -3
View File
@@ -8,15 +8,18 @@ const deleteTag = name => {
}
const getEmptyYears = isAcademicYear => {
const thisYear = new Date().getFullYear()
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = thisYear; year > 2021; year--) {
for (let year = latestYear; year > 2021; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
console.log({ years, latestYear, isAcademicYear })
return years
}
@@ -353,7 +356,7 @@ describe('Studyprogramme overview', () => {
cy.get('[data-cy=CoursesSortableTable]').within(() => {
// Test sorting by different columns
cy.get('th').eq(0).click()
cy.get('tr').eq(2).contains('Tietojenkäsittelytieteen kisälliopetus: Java-ohjelmointi')
cy.get('tr').eq(2).contains('Tietojenkäsittelytieteen kisälliopetus')
cy.get('th').eq(1).click()
cy.get('tr').eq(1).contains('Äidinkielinen viestintä')
cy.get('th').eq(2).click()
@@ -0,0 +1,4 @@
{
"studentSet1": ["010450938", "010589388", "010614509"],
"coursesSet1": ["TKT10001", "TKT10002", "TKT10003", "TKT10004"]
}
+2
View File
@@ -19,6 +19,8 @@ services:
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://importer.cs.helsinki.fi/api/importer'
JAMI_URL: 'http://jami:3003/'
image: toska/oodikone2-backend:staging
frontend:
+9 -7
View File
@@ -4,7 +4,7 @@ x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
target: development
dockerfile: dev.Dockerfile
services:
adminer:
@@ -39,13 +39,15 @@ services:
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://importer.cs.helsinki.fi/api/importer'
JAMI_URL: 'http://jami:3003/'
ports:
- 8080:8080
profiles:
- oodikone
volumes:
- backend-nodemod:/usr/src/app/node_modules
- ./services/backend/:/usr/src/app
- backend-nodemod:/opt/app-root/src/node_modules
- ./services/backend/:/opt/app-root/src
frontend:
<<: *defaults
@@ -58,8 +60,8 @@ services:
profiles:
- oodikone
volumes:
- frontend-nodemod:/usr/src/app/node_modules
- ./services/frontend/:/usr/src/app
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/:/opt/app-root/src
kone-db:
<<: *defaults
@@ -108,7 +110,7 @@ services:
sis-updater-scheduler:
<<: *defaults
build:
<<: *build_defaults
target: development
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
depends_on:
@@ -137,7 +139,7 @@ services:
sis-updater-worker:
<<: *defaults
build:
<<: *build_defaults
target: development
context: updater/sis-updater-worker
depends_on:
- redis
+16 -18
View File
@@ -1,23 +1,21 @@
FROM node:14.17-alpine3.13 as base
FROM registry.access.redhat.com/ubi8/nodejs-14-minimal
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
WORKDIR /usr/src/app
EXPOSE 8080
FROM base as development
ENV NODE_ENV=development
COPY ./package* /usr/src/app/
RUN npm ci
COPY . /usr/src/app/
CMD ["npm", "run", "dev"]
FROM base as production
ENV NODE_ENV=production
ARG SENTRY_ENVIRONMENT
ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY --chown=node:node ./package* /usr/src/app/
RUN npm ci --production && apk add --no-cache vim curl nano
COPY --chown=node:node . /usr/src/app/
USER node
ARG BASE_PATH
ENV PUBLIC_URL=$BASE_PATH
ARG STAGING
ENV REACT_APP_STAGING=$STAGING
COPY ./package* ./
RUN npm ci --omit-dev
COPY . .
CMD ["npm", "start"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:14.17-alpine3.13
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=development
COPY ./package* ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
+5 -3
View File
@@ -1,7 +1,7 @@
// Node env to use
const isStaging = process.env.NODE_ENV === 'staging'
const isDev = process.env.NODE_ENV === 'development'
const isProduction = process.env.NODE_ENV === 'production'
const isStaging = process.env.REACT_APP_STAGING === 'true'
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
// Sentry
const sentryRelease = process.env.SENTRY_RELEASE || ''
@@ -17,9 +17,10 @@ const requiredGroup = isStaging
const pateToken = process.env.PATE_API_TOKEN || ''
// Jami
const jamiUrl = isDev || runningInCI ? 'http://jami:3003/' : 'https://importer.cs.helsinki.fi/api/auth'
const jamiUrl = process.env.JAMI_URL || ''
// Importer client
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
// Networking: Urls & ports
@@ -86,5 +87,6 @@ module.exports = {
baseUrl,
frontUrl,
backendPort,
importerUrl,
importerToken,
}
@@ -28,7 +28,7 @@ const currentUserMiddleware = async (req, _res, next) => {
}
const iamGroups = parseIamGroups(hygroupcn)
const { access = {}, specialGroup = {} } = await getOrganizationAccess({ iamGroups })
const { access = {}, specialGroup = {} } = await getOrganizationAccess({ id: sisId, iamGroups })
const iamRights = Object.keys(access)
if (!hasRequiredIamGroup(iamGroups, iamRights)) {
+1 -2
View File
@@ -1,4 +1,4 @@
const { Model, STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
const { Model, STRING, DATE, INTEGER } = require('sequelize')
const { dbConnections } = require('../database/connection')
class Student extends Model {}
@@ -27,7 +27,6 @@ Student.init(
home_country_en: { type: STRING },
gender_code: { type: STRING },
sis_person_id: { type: STRING },
dissemination_info_allowed: { type: BOOLEAN },
createdAt: {
type: DATE,
},
+1 -1
View File
@@ -60,7 +60,7 @@ module.exports = (app, url) => {
app.use(url, studyProgramme)
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/completedcoursessearch`, auth.roles(['studyGuidanceGroups']), completedCoursesSearch)
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
app.use(`${url}/faculties`, auth.roles(['facultyStatistics']), faculties)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
@@ -19,8 +19,11 @@ router.get('/', async (req, res) => {
const filteredStudentNumbers = isAdmin ? studentNumbers : _.intersection(studentNumbers, studentsUserCanAccess)
const completedCourses = await getCompletedCourses(filteredStudentNumbers, courseCodes)
const forbiddenStudents = studentNumbers.filter(num => !filteredStudentNumbers.includes(num))
return res.json({ forbiddenStudents, ...completedCourses })
const discardedStudentNumbers = _.difference(
studentNumbers,
completedCourses.students.map(s => s.studentNumber)
)
return res.json({ discardedStudentNumbers, ...completedCourses })
})
router.get('/searches', async (req, res) => {
@@ -34,12 +37,12 @@ router.post('/searches', async (req, res) => {
const userId = req.user.id
const name = req.body?.name
if (!name) return res.status(400).json({ error: 'Name missing' })
if (!name) return res.status(400).json({ error: 'Courselist 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: '' })
if (!createdSearch) return res.status(400).json({ error: 'Failed to create courselist' })
return res.status(201).json({
id: createdSearch.id,
userId: createdSearch.userId,
@@ -56,7 +59,7 @@ router.put('/searches/:id', async (req, res) => {
if (!id || !userId) return res.status(422).end()
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) return res.status(404).json({ error: `Courselist search could not be found` })
if (!updatedSearch) return res.status(404).json({ error: 'Courselist could not be found' })
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
@@ -72,7 +75,7 @@ router.delete('/searches/:id', async (req, res) => {
if (!id || !userId) return res.status(422).end()
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) return res.status(404).json({ error: `Courselist search could not be found` })
if (!deletedSearch) return res.status(404).json({ error: 'Courselist could not be found' })
return res.json(id)
})
+10 -5
View File
@@ -239,7 +239,13 @@ router.get('/v3/populationstatistics', async (req, res) => {
res.json(filterPersonalTags(result, user.id))
} else {
const result = await Population.optimizedStatisticsOf({ ...req.query, studyRights })
// If two studyprogrammes are given, query does not work just right
// This solution is temporal
const result = await Population.optimizedStatisticsOf({
...req.query,
studyRights: { programme: studyRights.programme },
})
if (result.error) {
Sentry.captureException(new Error(result.error))
res.status(400).end()
@@ -389,17 +395,16 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
})
router.post('/v3/populationstatisticsbystudentnumbers', async (req, res) => {
const { studentnumberlist } = req.body
const { studentnumberlist, tags } = req.body
const {
user: { isAdmin, id, studentsUserCanAccess },
} = req
const filteredStudentNumbers = isAdmin ? studentnumberlist : _.intersection(studentnumberlist, studentsUserCanAccess)
const result = await Population.optimizedStatisticsOf(
{
year: 1900,
studyRights: [],
year: tags?.year || 1900,
studyRights: tags?.studyProgramme ? [tags.studyProgramme] : [],
semesters: ['FALL', 'SPRING'],
months: 10000,
},
@@ -8,7 +8,7 @@ const router = require('express').Router()
router.get('/', async (req, res) => {
const studyProgramme = req.query?.programmecode
if (!studyProgramme) return res.status(422).end()
if (studyProgramme !== '' && !studyProgramme) return res.status(422).end()
const studyProgrammeCriteria = await getCriteria(studyProgramme)
return res.json(studyProgrammeCriteria)
})
@@ -9,12 +9,11 @@ const mapOpenCredits = credit => {
}
const mapStundentInfo = student => {
const { email, secondary_email, studentnumber, dissemination_info_allowed } = student
const { email, secondary_email, studentnumber } = student
return {
studentnumber,
email,
secondary_email,
dissemination_info_allowed,
}
}
const mapOpenEnrollments = enrollment => {
@@ -51,15 +51,17 @@ const getCustomOpenUniCourses = async (courseCodes, startdate, enddate) => {
}
}
for (const { studentnumber, email, secondary_email, dissemination_info_allowed } of studentInfo) {
for (const { studentnumber, email, secondary_email } of studentInfo) {
// Check if the student has existing studyright: if yes, then stop here
if (!uniqueStudentsWithCurrentStudyRight.includes(studentnumber)) {
if (!(studentnumber in studentStats)) {
studentStats[studentnumber] = {
courseInfo: courseCodes.reduce((acc, code) => ({ ...acc, [code]: getEmptyCourseInfo() }), {}),
courseInfo: courseCodes.reduce(
(acc, code) => ({ ...acc, [code.replace('AY', '')]: getEmptyCourseInfo() }),
{}
),
email: email,
secondaryEmail: secondary_email,
disseminationInfoAllowed: dissemination_info_allowed,
totals: { passed: 0, failed: 0, unfinished: 0 },
}
}
+4 -2
View File
@@ -196,6 +196,7 @@ const formatStudentForPopulationStatistics = (
studyrights = studyrights || []
const started = dateofuniversityenrollment
return {
firstnames,
lastname,
@@ -212,7 +213,9 @@ const formatStudentForPopulationStatistics = (
email,
secondaryEmail: secondary_email,
phoneNumber: phone_number,
semesterenrollments: semester_enrollments || [],
semesterenrollments: semester_enrollments
? semester_enrollments.sort((a, b) => a.semestercode - b.semestercode)
: [],
updatedAt: updatedAt || createdAt,
tags: tags || [],
studyrightStart: startDate,
@@ -522,7 +525,6 @@ const studentnumbersWithAllStudyrightElements = async (
graduatedStudents
) => {
// eslint-disable-line
// db startdate is formatted to utc so need to change it when querying
const formattedStartDate = new Date(moment.tz(startDate, 'Europe/Helsinki').format()).toUTCString()
@@ -2,12 +2,14 @@ const { ProgressCriteria } = require('../models/models_kone')
const { Course } = require('../models')
const logger = require('../util/logger')
const getCriteriaByStudyProgramme = code =>
ProgressCriteria.findOne({
const getCriteriaByStudyProgramme = async code => {
if (code === '') return null
return await ProgressCriteria.findOne({
where: {
code: code,
},
})
}
const getSubstitutions = async codes => {
if (codes === []) return []
@@ -123,8 +125,7 @@ const saveYearlyCourseCriteria = async (studyProgramme, courses, year) => {
if (year === 4) updatedData = await studyProgrammeToUpdate.update({ coursesYearFour: courses })
if (year === 5) updatedData = await studyProgrammeToUpdate.update({ coursesYearFive: courses })
if (year === 6) updatedData = await studyProgrammeToUpdate.update({ coursesYearSix: courses })
const criteria = formattedData(updatedData)
return criteria
return await formattedData(updatedData)
} catch (error) {
logger.error(`Update yearly credit criteria failed: ${error}`)
}
@@ -137,7 +138,7 @@ const getCriteria = async studyProgramme => {
allCourses: [],
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
}
return studyProgrammeCriteria ? formattedData(studyProgrammeCriteria) : criteria
return studyProgrammeCriteria ? await formattedData(studyProgrammeCriteria) : criteria
}
module.exports = {
@@ -103,7 +103,9 @@ const getYearsArray = (since, isAcademicYear, yearsCombined) => {
const years = []
const allYears = 'Total'
if (yearsCombined) years.push(allYears)
for (let i = since; i <= new Date().getFullYear(); i++) {
const today = new Date()
const until = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
for (let i = since; i <= until; i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
}
+2 -2
View File
@@ -1,12 +1,12 @@
const axios = require('axios')
const { importerToken } = require('../conf-backend')
const { importerUrl, importerToken } = require('../conf-backend')
const logger = require('./logger')
const importerClient = axios.create({
headers: {
token: importerToken,
},
baseURL: 'https://importer.cs.helsinki.fi/api/importer',
baseURL: importerUrl,
})
const getImporterClient = () => {
+2 -2
View File
@@ -1,10 +1,10 @@
const Sentry = require('@sentry/node')
const Tracing = require('@sentry/tracing')
const { sentryRelease, sentryEnvironment, runningInCI } = require('../conf-backend')
const { sentryRelease, sentryEnvironment, runningInCI, isStaging } = require('../conf-backend')
const initializeSentry = app => {
if (!sentryRelease || !sentryEnvironment || runningInCI) return
if (!sentryRelease || !sentryEnvironment || runningInCI || isStaging) return
Sentry.init({
dsn: 'https://020b79f0cbb14aad94cc9d69a1ea9d52@sentry.cs.helsinki.fi/2',
+20 -19
View File
@@ -1,25 +1,26 @@
FROM node:14.17-alpine3.13 as base
FROM registry.access.redhat.com/ubi8/nodejs-14-minimal
WORKDIR /usr/src/app
ENV TZ="Europe/Helsinki"
FROM base as development
COPY ./package* /usr/src/app/
RUN npm ci
COPY . /usr/src/app/
EXPOSE 3000
CMD ["npm", "start"]
WORKDIR /opt/app-root/src
EXPOSE 5000
ENV NODE_ENV=production
ARG BASE_PATH
ENV PUBLIC_URL=$BASE_PATH
FROM base as production
ARG SENTRY_ENVIRONMENT
ENV REACT_APP_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
ARG BUILT_AT
ENV REACT_APP_BUILT_AT=$BUILT_AT
COPY --chown=node:node ./package* /usr/src/app/
RUN npm ci --production
COPY --chown=node:node . /usr/src/app/
RUN npm run build && npm install -g serve@12.0.0 && chown -R node:node /usr/src/app/build
USER node
EXPOSE 5000
ARG STAGING
ENV REACT_APP_STAGING=$STAGING
COPY ./package* ./
RUN npm ci --omit-dev
COPY . .
RUN npm run build && npm install -g serve@12.0.0
CMD ["serve", "--single", "--listen", "5000", "build"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:14.17-alpine3.13
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 3000
COPY ./package* ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
@@ -17,6 +17,7 @@ export default {
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics” puolelle ja näkee valitun opiskelijan opintotiedot.
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
@@ -25,8 +26,10 @@ export default {
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Opintopisteet sisältävät opiskelijoiden **kaikki opintopisteet**, eli siis myös ennen valitun tutkinnon alkamista suoritetut opinnot.
Huom! Nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
@@ -49,27 +52,47 @@ export default {
**Cumulative**-valinnan ollessa käytössä kunkin opiskelijan kaikki aiemmat opintopisteet lasketaan mukaan opintopistemäärään. Muutoin vain pylvästä vastaavalla aikavälillä kirjatut opintopisteet otetaan huomioon.
`,
CoursesOfPopulation: `**Courses of class**
CoursesOfClass: `**Courses of class**
Tämä taulukko sisältää näkyvissä olevien opiskelijoiden kaikki kurssisuoritukset.
Opiskelijat on mahdollista suodattaa kurssin perusteella käyttämällä kurssirivin vasemmassa laidassa olevaa suodatinpainiketta.
Kurssisuodatin aukeaa ikkunan vasempaan laitaan ja tarjoaa lisää vaihtoehtoja suodatukseen. Voit myös valita monta kurssia kerralla suodattimeen.
Klikkaamalla kurssin nimen vieressä olevaa sinistä nuolta, siirryt ko. kurssin *Course Statistics* -näkymään.
Klikkaamalla kurssin nimen vieressä olevaa sinistä nuolta, siirryt ko. kurssin **Course Statistics** -näkymään.
Taulukon tietoja voi suodattaa antamalla opiskelijoiden vähimmäismäärän kurssilla kenttään *Limit to courses where student number at least*.
Lisäksi kurssin nimi- ja koodisarakkeissa on hakukentät *("filter here")* suodattamista varten.
Suodattimet saa tyhjennettyä painamalla kentän vieressä olevaa ruksia.
Lisäksi kurssin nimi **name** ja koodisarakkeissa **code** suodatinkuvaketta klikkaamalla tulee esiin hakukentät suodattamista varten.
Suodattimet saa tyhjennettyä painamalla kentän vieressä olevaa ruksia tai valitsemall valikon alaosasta **Reset column filter**.
**Huom! Nämä suodattimet vaikuttavat vain tähän taulukkoon, eivät koko populatioon.**
**Taulukot**
- **pass/fail** - näkymä sisältää kurssisuoritukset, hylätyt sekä yritykset sekä ilmoittautumistiedot. Huom.! ilmoittautumistiedot ovat saatavilla vain Sisun käyttöönoton jälkeiseltä ajalta.
- **pass/fail** - näkymä sisältää kurssisuoritukset, hylätyt, yritykset sekä ilmoittautumistiedot. Huom.! ilmoittautumistiedot ovat saatavilla vain Sisun käyttöönoton jälkeiseltä ajalta.
- **Total students** - sarake näyttää kaikkien opiskelijoiden määrän, mukaanlukien ilmoittautuneet ilman arvosanaa.
- **Enrolled, no grade** - sarake näyttää niiden opiskelijoiden määrän, joilla on ilmoittautuminen muttei arvosanaa eikä hylättyä suoritusta.
- **grades** - arvosanajakauma.
- **when passed** - kurssisuorituksen ajankohta. Huom.! *Passed*-sarakkeen kokonaissumma ei välttämättä täsmää puolivuosittaisten lukumäärien summan kanssa johtuen tuplakirjauksista Oodissa.
- **students** - opiskelijan arvosanat kursseittain.
`,
CoursesOfPopulation: `**Courses of Population**
Tämä taulukko sisältää näkyvissä olevien opiskelijoiden kaikki kurssisuoritukset.
Opiskelijat on mahdollista suodattaa kurssin perusteella käyttämällä kurssirivin vasemmassa laidassa olevaa suodatinpainiketta.
Kurssisuodatin aukeaa ikkunan vasempaan laitaan ja tarjoaa lisää vaihtoehtoja suodatukseen. Voit myös valita monta kurssia kerralla suodattimeen.
Klikkaamalla kurssin nimen vieressä olevaa sinistä nuolta, siirryt ko. kurssin **Course Statistics** -näkymään.
Taulukon tietoja voi suodattaa antamalla opiskelijoiden vähimmäismäärän kurssilla kenttään *Limit to courses where student number at least*.
Lisäksi kurssin nimi **name** ja koodisarakkeissa **code** suodatinkuvaketta klikkaamalla tulee esiin hakukentät suodattamista varten.
Suodattimet saa tyhjennettyä painamalla kentän vieressä olevaa ruksia tai valitsemall valikon alaosasta **Reset column filter**.
**Huom! Nämä suodattimet vaikuttavat vain tähän taulukkoon, eivät koko populatioon.**
**Taulukot**
- **pass/fail** - näkymä sisältää kurssisuoritukset, hylätyt, yritykset sekä ilmoittautumistiedot. Huom.! ilmoittautumistiedot ovat saatavilla vain Sisun käyttöönoton jälkeiseltä ajalta.
- **grades** - arvosanajakauma.
`,
Search: `Tässä osiossa voi tarkastella koulutusohjelmakohtaisia populaatioita sisäänotto-lukuvuoden mukaisesti,
vuosikurssi kerrallaan. Uudet koulutusohjelmat ovat pääsääntöisesti alkaneet 1.8.2017,
@@ -34,6 +34,21 @@ export default {
esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat. Sisältää myös opintopisteet, jotka opiskelija on suorittanut koulutusohjelmaan ennen opintojen aloittamista koulutusohjelmassa tai opintojen päätyttyä.
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet.
**Non-degree students**: Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
**Transferred credits**: Sisältää opintopisteet, jotka ovat suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
`,
StudentsOfProgrammeCourses: `
Sisältää kurssien opiskelijat.
**Not completed**: Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
**Major students**: Sisältää opiskelijat, joilla on ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan.
**Non-major students**: Sisältää kurssin opiskelijat, joilla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan. Sisältää
esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat.
**Transferred students**: Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
`,
GraduatedAndThesisWritersOfTheProgramme: `
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman tai pro gradu-tutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
@@ -96,14 +111,8 @@ export default {
Mikäli yläosan valikosta valittuna on jokin tietty opintosuunta, myös nämä graafit ja taulukot kuvaavat kyseisen opintosuunnan opiskelijoita.
`,
NonDegree: `
Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
`,
TransferredCourses: `
Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
`,
TransferredCredits: `
Sisältää opintopisteet, jotka ovat suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
Name: `
Pitkät kurssinimet on lyhennetty. Näet ne kokonaan laittamalla hiiren nimen yläpuolelle.
`,
NotCompleted: `
Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
+2
View File
@@ -284,6 +284,8 @@ export const getTargetCreditsForProgramme = code => {
if (code === 'MH90_001') return 180
if (code.includes('MH')) return 120
return 180
// There are also doctoral degrees of 40, but probably not necessary
// Those codes begin with 'LIS' or 'T'
}
const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
@@ -123,10 +123,14 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
.filter(c => c !== '')
const studentList = studentInput
.split(/[\s,]+/)
.map(code => code.trim().toUpperCase())
.map(code => code.trim())
.filter(s => s !== '')
.map(s => (s.length === 8 ? `0${s}` : s))
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
return
}
setValues({
studentList,
courseList,
@@ -143,7 +147,7 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
return (
<Modal
trigger={
<Button size="small" color="blue" onClick={() => setModal(true)} data-cy="completed-courses-search-button">
<Button size="small" color="blue" onClick={() => setModal(true)} data-cy="open-completed-courses-modal-button">
Search completed courses of students
</Button>
}
@@ -160,6 +164,7 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
value={studentInput}
placeholder="012345678, 12345678"
onChange={e => setStudentInput(e.target.value)}
data-cy="student-no-input"
/>
</Form.Field>
<Form.Field>
@@ -168,6 +173,7 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
value={courseInput}
placeholder="TKT12345, PSYK-123"
onChange={e => setCourseInput(e.target.value)}
data-cy="course-list-input"
/>
</Form.Field>
<Form.Field>
@@ -182,7 +188,7 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
</Form.Field>
</Form.Field>
<SearchHistory
header="Saved courselist"
header="Saved courselists"
items={
searchList
? searchList.map(list => ({
@@ -216,8 +222,8 @@ const CompletedCoursesSearch = ({ setValues, history, location }) => {
/>
<Button onClick={handleClose}>Cancel</Button>
<Button positive onClick={e => onClicker(e)} data-cy="search-button">
Search students
<Button positive onClick={e => onClicker(e)} data-cy="completed-courses-search-button">
Search
</Button>
</Modal.Actions>
</Form>
@@ -16,7 +16,7 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
const enrollment = student.enrollments.find(e => e.courseCode === courseCode)
if (completion === undefined) {
if (!enrollment) {
return icon ? null : 'No completion'
return icon ? null : ''
}
if (icon) {
if (moment(enrollment.date) > moment().subtract(6, 'months')) {
@@ -28,7 +28,9 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
return `Latest enrollment: ${moment(enrollment.date).format('YYYY-MM-DD')}`
}
return icon ? <Icon fitted name="check" color="green" /> : 'Passed'
const substitutionString = completion.substitution ? ` as ${completion.substitution}` : ''
return icon ? <Icon fitted name="check" color="green" /> : `Passed${substitutionString}`
}
const getTotalPassed = s => s.credits.filter(c => isPassed(c.creditType)).length
@@ -73,6 +75,7 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
</div>
</div>
),
textTitle: course.code,
cellProps: s => getCellProps(s, course.code),
headerProps: { title: course.code },
getRowVal: s => getCompletion(s, course.code, { icon: false }),
@@ -101,16 +104,6 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
},
]
const emailColumn = {
key: 'email',
title: 'Email',
cellProps: { style },
headerProps: { title: 'Email' },
getRowVal: s => (s.email ? s.email : ''),
getRowContent: s => (s.email ? s.email : ''),
child: true,
}
const statisticHeader = {
key: 'statistics',
title: <b>Totals:</b>,
@@ -119,6 +112,51 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
children: statisticColumns,
}
const infoColumnsForCsv = [
{
key: 'hidden-lastname',
title: 'Last name',
forceToolsMode: 'none',
getRowVal: s => s.lastName,
headerProps: { style: { display: 'none' } },
cellProps: { style: { display: 'none' } },
export: true,
},
{
key: 'hidden-firstnames',
title: 'First names',
getRowVal: s => s.firstNames,
forceToolsMode: 'none',
headerProps: { style: { display: 'none' } },
cellProps: { style: { display: 'none' } },
export: true,
},
{
key: 'hidden-email',
title: 'E-mail',
getRowVal: s => (s.email ? s.email : ''),
forceToolsMode: 'none',
headerProps: { style: { display: 'none' } },
cellProps: { style: { display: 'none' } },
export: true,
},
]
const emailColumn = showStudentNames
? [
{
key: 'email',
title: 'Email',
cellProps: { style },
headerProps: { title: 'Email' },
getRowVal: s => (s.email ? s.email : ''),
getRowContent: s => (s.email ? s.email : ''),
child: true,
export: false,
},
]
: []
const nameColumns = showStudentNames
? [
{
@@ -127,6 +165,7 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
cellProps: { style },
getRowVal: s => s.lastName,
getRowContent: s => s.lastName,
export: false,
},
{
key: 'firstnames',
@@ -134,10 +173,28 @@ const getColumns = (courses, showStudentNames, getTextIn) => {
cellProps: { style },
getRowVal: s => s.firstNames,
getRowContent: s => s.firstNames,
export: false,
},
]
: []
return [...nameColumns, ...studentNbrColumn, ...completionStatusColumns, statisticHeader, emailColumn]
const hiddenParentColumn = {
key: 'hiddenFiles',
title: '',
mergeHeader: true,
textTitle: null,
parent: true,
children: infoColumnsForCsv,
}
return [
hiddenParentColumn,
...nameColumns,
...studentNbrColumn,
...completionStatusColumns,
statisticHeader,
...emailColumn,
]
}
const CompletedCoursesSearchResults = ({ searchValues }) => {
@@ -164,7 +221,9 @@ const CompletedCoursesSearchResults = ({ searchValues }) => {
return (
<div>
<StudentNameVisibilityToggle />
{data.forbiddenStudents?.length > 0 && <RightsNotification studentNumbers={data.forbiddenStudents} />}
{data.discardedStudentNumbers?.length > 0 && (
<RightsNotification discardedStudentNumbers={data.discardedStudentNumbers} />
)}
<div
style={{ maxWidth: '100vh', overflowX: 'auto', paddingBottom: '50px' }}
data-cy="completed-courses-table-div"
@@ -31,12 +31,7 @@ const CompletedCourses = () => {
<p>
Here you can search by a list of student and course numbers to see whether students have completed certain
courses yet. The tool will also show if students have enrolled on the course, if they have not yet completed
it. Course substitutions are taken into account. To see the date of last completion or enrollment, hover the
mouse on the icon. Clicking the blue button will open the tool.
</p>
<p>
Note that if you do not have the required rights to view the data of some students, those rows will omitted
from the results.
it. Course substitutions are taken into account.
</p>
<p>
<Icon fitted name="check" color="green" />: Student has completed the course with a passing grade. <br />
@@ -242,7 +242,7 @@ const CoursePopulation = ({
content: {
content: (
<div ref={programmeRef}>
<CustomPopulationCoursesWrapper filteredStudents={filtered} showFilter={false} />
<CustomPopulationCoursesWrapper filteredStudents={filtered} showFilter />
</div>
),
},
@@ -372,7 +372,12 @@ const CustomPopulationCoursesWrapper = props => {
if (isLoading) return null
return <CustomPopulationCourses {...props} courses={courseStatistics} />
return (
<>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
<CustomPopulationCourses {...props} courses={courseStatistics} />
</>
)
}
const mapStateToProps = ({ singleCourseStats, populations, courseSearch }) => {
@@ -287,6 +287,36 @@ const resolveStudyRightElement = ({ studyright_elements }) => {
)
}
const filterGraduations = (student, selectedStudyRight) => {
const graduated = student.studyrights.filter(({ graduated }) => graduated)
// eslint-disable-next-line camelcase
if (!selectedStudyRight)
return graduated.map(({ enddate, studyright_elements }) => {
const studyrightElem = studyright_elements
? studyright_elements.filter(ele => ele.element_detail?.type === 20)
: []
let elemName = ''
if (studyrightElem.length > 0) {
elemName = getTextIn(studyrightElem[0].element_detail?.name)
}
return {
value: new Date(enddate).getTime(),
studyright: elemName,
}
})
const selectedGraduation = graduated.find(({ studyrightid }) => studyrightid === selectedStudyRight.studyrightid)
if (!selectedGraduation) return []
const element = selectedGraduation.studyright_elements
? selectedGraduation.studyright_elements.filter(ele => ele.element_detail?.type === 20)
: []
return [
{
value: new Date(selectedGraduation.enddate).getTime(),
studyright: element.length > 0 ? getTextIn(element[0].element_detail?.name) : '',
},
]
}
const createStudentCreditLines = (
students,
singleStudent,
@@ -322,6 +352,32 @@ const createStudentCreditLines = (
courses => courses.reduce(reduceCreditsToPoints, { credits: 0, points: [], singleStudent })
)(student.courses)
const graduation =
points.length === 0
? null
: student.studyrights
.filter(({ graduated }) => graduated)
.find(({ studyright_elements }) =>
studyright_elements.find(
({ element_detail }) => element_detail.code === programmeCode && element_detail.type === 20
)
)
if (graduation) {
const graduationX = new Date(graduation.enddate).getTime()
const index = points.findIndex(point => point.x > graduationX)
let graduationY
if (index <= 0) {
graduationY = graduationX > points[0].x ? points[points.length - 1].y : points[0].y
} else {
const yBefore = points[index - 1].y
const yAfter = points[index].y
graduationY = Math.round((yBefore + yAfter) / 2)
}
points.push({ x: graduationX, y: graduationY, marker: { enabled: true, radius: 10, symbol: 'diamond' } })
points.sort((a, b) => a.x - b.x)
}
return {
name: student.studentNumber,
data: points,
@@ -333,40 +389,14 @@ const createStudentCreditLines = (
}
})
const filterGraduations = (student, selectedStudyRight) => {
const graduated = student.studyrights.filter(({ graduated }) => graduated)
// eslint-disable-next-line camelcase
if (!selectedStudyRight)
return graduated.map(({ enddate, studyright_elements }) => {
const studyrightElem = studyright_elements
? studyright_elements.filter(ele => ele.element_detail?.type === 20)
: []
let elemName = ''
if (studyrightElem.length > 0) {
elemName = getTextIn(studyrightElem[0].element_detail?.name)
}
return {
value: new Date(enddate).getTime(),
studyright: elemName,
}
})
const selectedGraduation = graduated.find(({ studyrightid }) => studyrightid === selectedStudyRight.studyrightid)
if (!selectedGraduation) return []
const element = selectedGraduation.studyright_elements
? selectedGraduation.studyright_elements.filter(ele => ele.element_detail?.type === 20)
: []
return [
{
value: new Date(selectedGraduation.enddate).getTime(),
studyright: element.lenght > 0 ? getTextIn(element[0].element_detail?.name) : '',
},
]
}
const findTransferName = (student, transfer) =>
student.studyrights.flatMap(right => right.studyright_elements).find(elem => elem.code === transfer.targetcode)
.element_detail.name
const filterTransfers = student => {
const filterTransfers = (student, language) => {
const transferTimes = student.transfers.map(transfer => ({
value: new Date(transfer.transferdate).getTime(),
studyright: `From ${transfer.sourcecode} to ${transfer.targetcode}`,
studyright: `to ${getTextIn(findTransferName(student, transfer), language)}`,
}))
const removeOverlapping = transferTimes.filter((transfer, i, a) => a.indexOf(transfer) === i)
return removeOverlapping
@@ -468,9 +498,8 @@ const CreditAccumulationGraphHighCharts = ({
return Math.min(..._.flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime()))))
return studyRightStart
}
const graduations = singleStudent ? filterGraduations(students[0], selectedStudyRight) : []
const transfers = singleStudent ? filterTransfers(students[0]) : []
const transfers = singleStudent ? filterTransfers(students[0], language) : []
const studyRightStartLine =
!singleStudent && studyPlanFilterIsActive
? [new Date(customStudyStartYear || students[0].studyrightStart).getTime()]
@@ -13,7 +13,6 @@ const getTableData = studentsData => {
courseInfo: { ...studentsData[student].courseInfo },
email: studentsData[student].email,
secondaryEmail: studentsData[student].secondaryEmail,
disseminationInfoAllowed: studentsData[student].disseminationInfoAllowed,
totals: studentsData[student].totals,
},
],
@@ -75,23 +74,7 @@ const getColumns = (labelsToCourses, language) => {
},
]
const getDisseminationInfoAllowed = s => {
if (s.disseminationInfoAllowed === null) return 'unknown'
if (s.disseminationInfoAllowed) return 'yes'
return 'no'
}
const informationColumns = [
{
key: 'disseminationInfoAllowed',
title: 'Marketing Allowed',
getRowVal: s => getDisseminationInfoAllowed(s),
getRowContent: s => getDisseminationInfoAllowed(s),
headerProps: { title: 'Marketing Allowed' },
child: true,
childOf: 'Marketing Allowed',
cellProps: { style },
},
{
key: 'email-child',
title: 'Email',
@@ -25,6 +25,7 @@ const CustomPopulationSearch = ({
loading,
customPopulationSearches,
customPopulationSearchSaving,
setStudentsInput,
}) => {
const [modal, setModal] = useState(false)
const [input, setInput] = useState('')
@@ -54,8 +55,15 @@ const CustomPopulationSearch = ({
clearForm()
}
const parseInput = studentNumbers =>
studentNumbers
.split(/[\s,]+/)
.map(code => code.trim())
.filter(s => s !== '')
.map(s => (s.length === 8 ? `0${s}` : s))
const onSave = () => {
const studentnumberlist = input.match(/[0-9]+/g)
const studentnumberlist = parseInput(input)
if (selectedSearchId) {
updateCustomPopulationSearchDispatch({ id: selectedSearchId, studentnumberlist })
} else {
@@ -87,7 +95,8 @@ const CustomPopulationSearch = ({
const onClicker = e => {
e.preventDefault()
const studentnumbers = input.match(/[0-9]+/g).map(sNumber => (sNumber.length === 8 ? `0${sNumber}` : sNumber))
const studentnumbers = parseInput(input)
setStudentsInput(studentnumbers)
getCustomPopulationDispatch({ studentnumberlist: studentnumbers, onProgress })
getCustomPopulationCoursesByStudentnumbers({ studentnumberlist: studentnumbers })
selectCustomPopulationSearchDispatch(selectedSearchId || null)
@@ -5,6 +5,7 @@ import { Segment, Header, Accordion, Message } from 'semantic-ui-react'
import _ from 'lodash'
import scrollToComponent from 'react-scroll-to-component'
import semestersApi from 'redux/semesters'
import RightsNotification from 'components/RightsNotification'
import { useProgress, useTitle } from '../../common/hooks'
import infotooltips from '../../common/InfoToolTips'
import { getCustomPopulationSearches } from '../../redux/customPopulationSearch'
@@ -89,12 +90,13 @@ const CustomPopulationContent = ({ students, custompop }) => {
const { language } = useLanguage()
const [activeIndex, setIndex] = useState([])
const [newestIndex, setNewest] = useState(null)
const [studentsInput, setStudentsInput] = useState(null)
const [discardedStudentNumbers, setDiscarded] = useState([])
const creditGainRef = useRef()
const programmeRef = useRef()
const coursesRef = useRef()
const studentRef = useRef()
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
const { customPopulationSearches, searchedCustomPopulationSearchId } = useSelector(
state => state.customPopulationSearch
)
@@ -108,6 +110,15 @@ const CustomPopulationContent = ({ students, custompop }) => {
studentNumbers: students.map(s => s.studentNumber),
})
useEffect(() => {
setDiscarded(
_.difference(
studentsInput,
students.map(s => s.studentNumber)
)
)
}, [studentsInput, students])
useEffect(() => {
if (newestIndex) {
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
@@ -179,6 +190,7 @@ const CustomPopulationContent = ({ students, custompop }) => {
content: {
content: (
<div ref={coursesRef}>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
<CustomPopulationCourses filteredStudents={students} courses={courseStats} />
</div>
),
@@ -236,7 +248,10 @@ const CustomPopulationContent = ({ students, custompop }) => {
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
</p>
</Message>
<CustomPopulationSearch />
{discardedStudentNumbers?.length > 0 && !populations.pending && (
<RightsNotification discardedStudentNumbers={discardedStudentNumbers} />
)}
<CustomPopulationSearch setStudentsInput={setStudentsInput} />
{custompop.length > 0 && customPopulationFlag ? (
<Segment className="contentSegment">{renderCustomPopulation(students)}</Segment>
) : (
@@ -24,11 +24,13 @@ const EnrollmentStatusFilterCard = ({ options, onOptionsChange, allSemesters, la
return null
}
const semesterOptions = semesterCodes.map(code => ({
key: `semester-option-${code}`,
text: getTextIn(allSemesters[code].name, language),
value: code,
}))
const semesterOptions = semesterCodes
.sort((prev, cur) => cur - prev)
.map(code => ({
key: `semester-option-${code}`,
text: getTextIn(allSemesters[code].name, language),
value: code,
}))
return (
<div className="card-content">
@@ -82,12 +82,17 @@ const NavigationBar = () => {
</span>
</Menu.Item>
)
const showSearch = item => {
if (item.key === 'class' || item.key === 'overview') return true
if (checkUserAccess(['openUniSearch', 'admin'], roles) && item.key === 'openUniSearch') return true
if ((checkUserAccess(['studyGuidanceGroups', 'admin'], roles) || rights.length > 0) && item.key === 'customSearch')
return true
if (checkUserAccess(['studyGuidanceGroups', 'admin'], roles) && item.key === 'completedCoursesSearch') return true
if (
(checkUserAccess(['studyGuidanceGroups', 'admin'], roles) || rights.length > 0) &&
item.key === 'completedCoursesSearch'
)
return true
return false
}
@@ -1,233 +0,0 @@
import React, { useMemo } from 'react'
import _ from 'lodash'
import SortableTable, { group } from 'components/SortableTable'
import { Item, Icon } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import CourseFilterToggle from '../CourseFilterToggle'
import { UsePopulationCourseContext } from '../PopulationCourseContext'
import { getTextIn } from '../../../common'
const createModuleAggregateRow = ({ definition, children }) => ({
label_code: definition.module.code,
label_name: definition.module.name,
stats: {
passed: _.chain(children).map('stats.passed').sum().value(),
retryPassed: _.chain(children).map('stats.retryPassed').sum().value(),
percentage: _.chain(children).map('stats.percentage').mean().value(),
failed: _.chain(children).map('stats.failed').sum().value(),
failedMany: _.chain(children).map('stats.failedMany').sum().value(),
attempts: _.chain(children).map('stats.attempts').sum().value(),
perStudent: _.chain(children).map('stats.perStudent').mean().value(),
passedOfPopulation: _.chain(children).map('stats.passedOfPopulation').mean().value(),
triedOfPopulation: _.chain(children).map('stats.triedOfPopulation').mean().value(),
},
})
const PassFail = ({ flat, onlyIamRights }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const columns = useMemo(() => {
const columns = [
{
key: 'course',
title: 'Course',
children: [
{
key: 'course-name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'course-name',
title: 'Name',
getRowVal: (row, isGroup) => getTextIn(isGroup ? row.label_name : row.name ?? row.course.name),
cellProps: {
style: {
maxWidth: '20em',
whiteSpace: 'normal',
overflow: 'hidden',
},
},
},
{
key: 'filter-toggle',
export: false,
getRowContent: (row, isGroup) => {
if (isGroup) return null
return <CourseFilterToggle course={row} />
},
},
{
key: 'go-to-course',
export: false,
getRowContent: (row, isGroup) =>
!isGroup && (
<Item
as={Link}
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
row.code
)}"]&separate=false&unifyOpenUniCourses=false`}
>
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
</Item>
),
},
],
},
{
key: 'course-code',
title: 'Code',
getRowVal: (row, isGroup) => (isGroup ? row.label_code : row.code ?? row.course.code),
},
],
},
{
key: 'statistics',
noHeader: true,
getRowVal: (_, isGroup) => (isGroup ? ' ' : undefined),
children: [
{
key: 'passed',
title: 'Passed',
children: [
{
key: 'passed-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.passed ?? 0,
},
{
key: 'passed-after-retry',
title: 'After Retry',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.retryPassed ?? 0,
},
{
key: 'passed-percentage',
title: 'Passed-%',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.percentage ?? 0,
formatValue: value =>
value &&
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
],
},
{
key: 'failed',
title: 'Failed',
children: [
{
key: 'failed-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.failed ?? 0,
},
{
key: 'failed-many',
title: 'Multiple Times',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.failedMany ?? 0,
},
],
},
{
key: 'attempts',
title: 'Attempts',
children: [
{
key: 'attempts-n',
title: 'Total',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.attempts,
},
{
key: 'attempts-per-student',
title: 'per Student',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.perStudent,
formatValue: value =>
new Intl.NumberFormat('fi-FI', { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(value),
},
],
},
{
key: 'of-population',
title: 'Percentage of Population',
children: [
{
key: 'passed-of-population',
title: 'Passed',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.passedOfPopulation,
formatValue: value =>
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
{
key: 'attempted-of-population',
title: 'Attempted',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.triedOfPopulation,
formatValue: value =>
new Intl.NumberFormat('fi-FI', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100),
},
],
},
],
},
]
if (onlyIamRights) columns[0].children[0].children.pop()
return columns
}, [onGoToCourseStatisticsClick])
const data = useMemo(() => {
if (flat) {
return courseStatistics.map(course => ({ ...course, code: course.course.code, name: course.course.name }))
}
return _.chain(modules)
.map(({ module, courses }) =>
group(
{
key: `module-${module.code}`,
module,
headerRowData: createModuleAggregateRow,
columnOverrides: {},
},
courses
)
)
.value()
}, [modules, courseStatistics])
return (
<>
<SortableTable title="Pass and fail statistics of courses" data={data} columns={columns} />
</>
)
}
export default PassFail
@@ -95,7 +95,6 @@ const PassFailEnrollments = ({ flat, onlyIamRights }) => {
{
key: 'total',
title: 'Total\nstudents',
helpText: 'Total count of students, including enrolled students with no grade.',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.totalStudents ?? 0,
@@ -117,7 +116,6 @@ const PassFailEnrollments = ({ flat, onlyIamRights }) => {
{
key: 'totalEnrolledNoGrade',
title: 'Enrolled, \nno grade',
helpText: 'Students with a valid enrollment and no passing or failing grade.',
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: row => row.stats?.totalEnrolledNoGrade ?? 0,
@@ -210,7 +208,7 @@ const PassFailEnrollments = ({ flat, onlyIamRights }) => {
if (onlyIamRights) columns[0].children[0].children.pop()
return columns
}, [onGoToCourseStatisticsClick])
}, [onGoToCourseStatisticsClick, courseStatistics])
const data = useMemo(() => {
if (flat) {
@@ -230,7 +228,7 @@ const PassFailEnrollments = ({ flat, onlyIamRights }) => {
)
)
.value()
}, [modules])
}, [modules, courseStatistics])
return (
<>
@@ -8,9 +8,9 @@ import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
import TSA from '../../common/tsa'
import GradeDistribution from './GradeDistribution'
import PassFail from './PassFail'
import { getTextIn } from '../../common'
import useLanguage from '../LanguagePicker/useLanguage'
import PassFailEnrollments from './PassFailEnrollments'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent('Population statistics', action, name, value)
@@ -158,7 +158,10 @@ const PopulationCourseStatsFlat = ({ courses, pending, filteredStudents, showFil
'Courses of Population student count filter change',
value
)
setState({ ...state, studentAmountLimit: value }) // , () => this.handleCourseStatisticsCriteriaChange())
setState({
...state,
studentAmountLimit: typeof Number(value) === 'number' ? Number(value) : state.studentAmountLimit,
})
}, 1000)
)
}
@@ -220,7 +223,7 @@ const PopulationCourseStatsFlat = ({ courses, pending, filteredStudents, showFil
menuItem: 'pass/fail',
render: () => (
<Tab.Pane>
<PassFail flat />
<PassFailEnrollments flat />
</Tab.Pane>
),
},
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { connect, useSelector } from 'react-redux'
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
import { Table, Input, Tab, Icon, Form } from 'semantic-ui-react'
import { orderBy, debounce } from 'lodash'
import { withRouter } from 'react-router-dom'
import { clearCourseStats } from '../../redux/coursestats'
@@ -39,8 +39,17 @@ const lodashSortOrderTypes = {
DESC: 'desc',
}
function updateCourseStatisticsCriteria(courseStats, language, state, mandatoryCourses) {
const { sortCriteria, codeFilter, nameFilter, reversed } = state
const updateCourseStatisticsCriteria = (courseStats, language, state, mandatoryCourses) => {
if (!courseStats) {
return []
}
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return studentAmountLimit === 0 || students >= studentAmountLimit
}
const courseCodeFilter = ({ course }) => {
const { code } = course
@@ -60,6 +69,7 @@ function updateCourseStatisticsCriteria(courseStats, language, state, mandatoryC
mandatoryCourses &&
courseStats
.filter(mandatoryFilter)
.filter(studentAmountFilter)
.filter(c => !codeFilter || courseCodeFilter(c))
.filter(c => !nameFilter || courseNameFilter(c))
@@ -79,6 +89,7 @@ const initialState = props => ({
reversed: true,
codeFilter: '',
nameFilter: '',
studentAmountLimit: Math.round((props?.filteredStudents?.length ?? 0) * 0.3),
activeView: null,
filteredStudentsLength: props.filteredStudentsLength || 0,
})
@@ -96,6 +107,7 @@ const PopulationCourseStats = props => {
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
const [modules, setModules] = useState([])
const [state, setState] = useState(initialState(props))
const [timer, setTimer] = useState(null)
const [expandedGroups, setExpandedGroups] = useState(new Set())
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
@@ -145,11 +157,16 @@ const PopulationCourseStats = props => {
return mandatoryCourses.some(c => c.code === course.code && c.visible && c.visible.visibility)
}
const studentAmountFilter = ({ stats }) => {
const { students } = stats
return state.studentAmountLimit === 0 || students >= state.studentAmountLimit
}
const filteredCourses =
coursestatistics &&
mandatoryCourses &&
coursestatistics
.filter(visibleCoursesFilter)
.filter(studentAmountFilter)
.filter(courseCodeFilter)
.filter(courseNameFilter)
// it needs to be with flatMap and filter and not map and find
@@ -205,7 +222,20 @@ const PopulationCourseStats = props => {
setFilterFields({ ...filterFields, [field]: value })
sendAnalytics('Courses of Population filter changed', field, value)
}
const onStudentAmountLimitChange = e => {
const {
target: { value },
} = e
clearTimeout(timer)
setTimer(
setTimeout(() => {
setState({
...state,
studentAmountLimit: typeof Number(value) === 'number' ? Number(value) : state.studentAmountLimit,
})
}, 1000)
)
}
const setFilters = useCallback(
debounce(({ codeFilter, nameFilter }) => {
setState({ ...state, codeFilter, nameFilter })
@@ -368,6 +398,12 @@ const PopulationCourseStats = props => {
}
return (
<div>
<Form>
<Form.Field inline>
<label>Limit to courses where student number at least</label>
<Input defaultValue={state.studentAmountLimit} onChange={onStudentAmountLimitChange} />
</Form.Field>
</Form>
<PopulationCourseContext.Provider value={contextValue}>
<Tab panes={panes} onTabChange={handleTabChange} />
</PopulationCourseContext.Provider>
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { func, object, string } from 'prop-types'
import { Button, Icon, Modal } from 'semantic-ui-react'
import { Button, Icon, Modal, Container } from 'semantic-ui-react'
import DegreeCoursesTable from '../StudyProgramme/DegreeCourses'
import sendEvent from '../../common/sendEvent'
@@ -30,13 +30,13 @@ const FilterDegreeCoursesModal = ({ studyProgramme, criteria, setCriteria }) =>
>
<Modal.Header>Hide degree courses</Modal.Header>
<Modal.Content image>
<Modal.Description>
<Container>
<DegreeCoursesTable studyProgramme={studyProgramme} criteria={criteria} setCriteria={setCriteria} />
<Button onClick={() => setModalOpenState(false)} icon labelPosition="left">
<Button onClick={() => setModalOpenState(false)} icon labelPosition="left" style={{ marginTop: '10px' }}>
<Icon name="save" />
Save & Close
</Button>
</Modal.Description>
</Container>
</Modal.Content>
</Modal>
)
@@ -92,7 +92,11 @@ const PopulationCourses = ({ query = {}, filteredStudents, selectedStudentsByYea
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{showByStudytrack ? (
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfClass} />
) : (
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
)}
{renderToggleStructureButton()}
{query.studyRights.programme && !onlyIamRights && (
<FilterDegreeCoursesModal
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router-dom'
import { useGetSemestersQuery } from 'redux/semesters'
import useLanguage from 'components/LanguagePicker/useLanguage'
import { studentNumberFilter } from 'components/FilterView/filters'
import { getTargetCreditsForProgramme } from 'common'
import { getMonths } from '../../../common/query'
import useFilters from '../../FilterView/useFilters'
@@ -65,7 +66,6 @@ export const splitStudentCredits = (student, timeSlots, cumulative) => {
}
const LIMITS_NON_CUMULATIVE = [15, 30, 45, 60]
const LIMITS_CUMULATIVE = [30, 60, 90, 120, 150, 180]
const hasGraduatedBefore = (student, programme, date) => {
const sr = student.studyrights
@@ -81,8 +81,21 @@ const hasGraduatedBefore = (student, programme, date) => {
const GRADUATED = Symbol('GRADUATED')
const getChartData = (students, timeSlots, order, programme, limitScale, cumulative) => {
const limitBreaks = (cumulative ? LIMITS_CUMULATIVE : LIMITS_NON_CUMULATIVE).map(lb => lb * limitScale)
const getChartData = (students, timeSlots, order, programme, timeDivision, cumulative) => {
const programmeCredits = getTargetCreditsForProgramme(programme)
// In calendar-year mode, minus 30 from target credits because programmers (usually) start in autumn,
// also if current date is before august, minus 30
const isCalendar = timeDivision === TimeDivision.CALENDAR_YEAR
const isPastAugust = new Date().getMonth() > 6
const calendarModifier = 30 + (isPastAugust ? 0 : 30)
const creditsByTimeslots =
timeSlots.length * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
const maxCredits = creditsByTimeslots > programmeCredits ? programmeCredits : creditsByTimeslots
const limitBreaks = cumulative
? [1, 2, 3, 4, 5, 6].map(num => Math.round((num * maxCredits) / 6))
: LIMITS_NON_CUMULATIVE.map(limit => limit * (timeDivision === TimeDivision.SEMESTER ? 0.5 : 1))
let limits = _.range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
@@ -208,13 +221,11 @@ const CreditDistributionDevelopment = ({ students, query }) => {
}, [timeDivision, months, semestersQuery, getTextIn])
const seriesList = useMemo(() => {
const limitScale = timeDivision === TimeDivision.SEMESTER ? 0.5 : 1.0
return [
getChartData(students, timeSlots, stackOrdering, programme, limitScale, false),
getChartData(students, timeSlots, stackOrdering, programme, limitScale, true),
getChartData(students, timeSlots, stackOrdering, programme, timeDivision, false),
getChartData(students, timeSlots, stackOrdering, programme, timeDivision, true),
]
}, [students, timeSlots, stackOrdering, programme])
}, [students, timeSlots, stackOrdering, programme, timeDivision, cumulative])
const labels = timeSlots.map(ts => ts.label)
const series = seriesList[0 + cumulative]
@@ -60,7 +60,7 @@ const CreditsGainedTable = ({ filteredStudents, type, year, creditDateFilterOpti
months = getMonths(start, endDate)
title = `${moment(start).format('DD.MM.YYYY')} and ${moment(endDate).format('DD.MM.YYYY')}`
creditList = filteredStudents.map(student =>
student.courses.lenght > 0
student.courses.length > 0
? student.courses.reduce(
(results, course) =>
results + (course.passed && endDate.isSameOrAfter(course.date, 'day')) ? course.credits : 0,
@@ -87,10 +87,6 @@ const PopulationDetails = ({
return null
}
/* if (filteredStudents.length === 0) {
return <Message negative content="No statistics found for the given query." />
} */
const onlyIamRights = !authLoading && !isAdmin && !rights.includes(query?.studyRights?.programme)
const panels = [
@@ -2,18 +2,17 @@ import React, { useState, useEffect, useRef } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import qs from 'query-string'
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
import { Form, Button, Message, Icon, Grid, Radio } from 'semantic-ui-react'
import { v4 as uuidv4 } from 'uuid'
import Datetime from 'react-datetime'
import { sortBy, isEqual } from 'lodash'
import moment from 'moment'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import infoTooltips from 'common/InfoToolTips'
import { getPopulationStatistics, clearPopulations } from '../../redux/populations'
import { getPopulationCourses } from '../../redux/populationCourses'
import { getPopulationSelectedStudentCourses, clearSelected } from '../../redux/populationSelectedStudentCourses'
import { getMandatoryCourses } from '../../redux/populationMandatoryCourses'
import { getProgrammes } from '../../redux/populationProgrammes'
import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, cancelablePromise } from '../../common'
import { useSearchHistory } from '../../common/hooks'
@@ -38,6 +37,7 @@ const initialQuery = () => ({
})
const PopulationSearchForm = props => {
const { isAdmin } = useGetAuthorizedUserQuery()
const [totalState, setTotalState] = useState({
query: initialQuery(),
isLoading: false,
@@ -45,7 +45,7 @@ const PopulationSearchForm = props => {
})
const [didMount, setDidMount] = useState(false)
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('populationSearch', 8)
const [filterProgrammes, setFilterProgrammes] = useState(isAdmin)
const fetchPopulationPromises = useRef()
const setState = newState => setTotalState({ ...totalState, ...newState })
@@ -325,12 +325,26 @@ const PopulationSearchForm = props => {
onChange={handleYearSelection}
/>
</Form.Field>
<Form.Field className="yearControl">
<Button.Group basic vertical className="yearControlButtonGroup">
<Button type="button" icon="plus" className="yearControlButton" onClick={addYear} tabIndex="-1" />
<Button type="button" icon="minus" className="yearControlButton" onClick={subtractYear} tabIndex="-1" />
</Button.Group>
</Form.Field>
<Form.Field>
<div>
{isAdmin && (
<Radio
data-cy="toggleFilterProgrammes"
toggle
label="Filter out old and specialized programmes"
checked={filterProgrammes}
onChange={() => setFilterProgrammes(!filterProgrammes)}
/>
)}
</div>
</Form.Field>
</Form.Group>
)
}
@@ -385,7 +399,12 @@ const PopulationSearchForm = props => {
let programmesToRender
if (Object.values(studyProgrammes).length !== 0) {
const sortedStudyProgrammes = sortBy(studyProgrammes, s => getTextIn(s.name, language))
let sortedStudyProgrammes = sortBy(studyProgrammes, s => getTextIn(s.name, language))
if (filterProgrammes) {
sortedStudyProgrammes = sortedStudyProgrammes.filter(
programme => programme.code.slice(0, 2) === 'MH' || programme.code.slice(0, 2) === 'KH'
)
}
programmesToRender = renderableList(sortedStudyProgrammes)
}
@@ -4,6 +4,7 @@ import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import useFilters from 'components/FilterView/useFilters'
import { hopsFilter } from 'components/FilterView/filters'
import Toggle from 'components/StudyProgramme/Toggle'
import PopulationSearchForm from './PopulationSearchForm'
import PopulationSearchHistory from './PopulationSearchHistory'
import ProgressBar from '../ProgressBar'
@@ -11,7 +12,7 @@ import InfoBox from '../Info/InfoBox'
import { useProgress } from '../../common/hooks'
import infotoolTips from '../../common/InfoToolTips'
const PopulationSearch = ({ populationFound, history, location, loading }) => {
const PopulationSearch = ({ populationFound, history, location, loading, unifiedProgramme }) => {
const { onProgress, progress } = useProgress(loading)
const { filterDispatch, useFilterSelector } = useFilters()
@@ -49,7 +50,17 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
label="Show only credits included in study plan"
/>
</Form.Field>
{unifiedProgramme && unifiedProgramme.combinedProgrammeCode && (
<div className="toggle-container">
<Toggle
cypress="programmeToggle"
firstLabel={`Filter by graduated and studyplan credits by ${unifiedProgramme.combinedProgrammeCode} `}
secondLabel={unifiedProgramme.programmeCode}
value={unifiedProgramme.filterByBachelor}
setValue={unifiedProgramme.setFilterByBachelor}
/>
</div>
)}
<PopulationSearchHistory history={history} />
</Form>
)}
@@ -175,6 +175,7 @@ export default ({ students, programmeCode }) => {
}),
'phone number': s.phoneNumber,
email: s.email,
secondaryEmail: s.secondaryEmail,
'transferred from': s.transferredStudyright ? transferFrom(s) : '',
priority: priorityText(s.studyrights),
extent: extentCodes(s.studyrights),
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { useLocation, useHistory } from 'react-router-dom'
import { Header, Segment } from 'semantic-ui-react'
@@ -43,23 +43,25 @@ const PopulationStatistics = () => {
const { query, queryIsSet, isLoading, selectedStudentsByYear, samples } = useSelector(
populationToData.makePopulationsToData
)
// Option to set other Study programme in cases of combined view (e.g., eläinlääkkis)
const [filterByBachelor, setFilterByBachelor] = useState(true)
useTitle('Class statistics')
const { data: allSemesters } = useGetSemestersQuery()
const programmeCode = query?.studyRights?.programme
const combinedProgrammeCode = query?.studyRights?.combinedProgramme
const programmes = useSelector(store => store.populationProgrammes?.data?.programmes)
const programmeName = programmes && programmeCode ? programmes[programmeCode]?.name : ''
const combinedProgrammeName = programmes && combinedProgrammeCode ? programmes[combinedProgrammeCode]?.name : ''
const chosenProgrammeCode = filterByBachelor || !combinedProgrammeCode ? programmeCode : combinedProgrammeCode
const filters = [
studentNumberFilter,
hopsFilter({ programmeCode }),
hopsFilter({ programmeCode: chosenProgrammeCode }),
genderFilter,
ageFilter,
courseFilter({ courses }),
creditsEarnedFilter,
graduatedFromProgrammeFilter({ code: programmeCode }),
graduatedFromProgrammeFilter({ code: chosenProgrammeCode }),
transferredToProgrammeFilter,
startYearAtUniFilter,
tagsFilter,
@@ -68,14 +70,14 @@ const PopulationStatistics = () => {
allSemesters: allSemesters?.semesters ?? [],
language,
}),
studyTrackFilter({ code: programmeCode }),
studyrightStatusFilter({ code: programmeCode }),
studyTrackFilter({ code: chosenProgrammeCode }),
studyrightStatusFilter({ code: chosenProgrammeCode }),
]
if (parseInt(query?.year, 10) >= 2020) {
filters.push(
admissionTypeFilter({
programme: programmeCode,
programme: chosenProgrammeCode,
})
)
}
@@ -84,9 +86,9 @@ const PopulationStatistics = () => {
if (!samples) {
return []
}
return samples.map(student => {
const hopsCredits = student.studyplans.find(plan => plan.programme_code === programmeCode)?.completed_credits ?? 0
const hopsCredits =
student.studyplans?.find(plan => plan.programme_code === chosenProgrammeCode)?.completed_credits ?? 0
const studyrightStartDate = new Date(student.studyrightStart)
const courses = student.courses.filter(({ date }) => new Date(date) >= studyrightStartDate)
const credits = getStudentTotalCredits({ courses })
@@ -97,10 +99,23 @@ const PopulationStatistics = () => {
credits,
}
})
}, [samples, programmeCode])
}, [samples, chosenProgrammeCode])
const unifyProgrammeName = (bachelor, masterLisenciate) => {
if (language === 'fi')
return `${bachelor} ja ${
masterLisenciate?.includes('lisensiaatin') ? 'lisensiaatin koulutusojelma' : 'maisterin koulutusohjelma'
}`
if (language === 'en') return `${bachelor?.split(' ')[0]} and ${masterLisenciate}`
if (language === 'sv') return `${bachelor?.split('programmet')[0]}- och ${masterLisenciate}`
return bachelor
}
const programmeText =
query?.studyRights?.combinedProgramme !== undefined
? unifyProgrammeName(getTextIn(programmeName, language), getTextIn(combinedProgrammeName, language))
: getTextIn(programmeName, language)
const title = location.search === '' ? 'Class statistics' : `${programmeText} ${getYearText(query?.year)}`
const title =
location.search === '' ? 'Class statistics' : `${getTextIn(programmeName, language)} ${getYearText(query?.year)}`
return (
<FilterView
name="PopulationStatistics"
@@ -131,13 +146,23 @@ const PopulationStatistics = () => {
</Header>
<Segment className="contentSegment">
<PopulationSearch history={history} location={location} />
<PopulationSearch
history={history}
location={location}
unifiedProgramme={{ programmeCode, combinedProgrammeCode, filterByBachelor, setFilterByBachelor }}
/>
{location.search !== '' ? (
<PopulationDetails
queryIsSet={queryIsSet}
query={query}
isLoading={isLoading}
dataExport={<DataExport students={filteredStudents} programmeCode={query?.studyRights?.programme} />}
dataExport={
<DataExport
students={filteredStudents}
programmeCode={query?.studyRights?.programme}
combinedProgrammeCode={combinedProgrammeCode}
/>
}
allStudents={samples}
selectedStudentsByYear={selectedStudentsByYear}
filteredStudents={filteredStudents}
@@ -7,12 +7,13 @@ import sendEvent from 'common/sendEvent'
import { getTextIn } from 'common'
import { keyBy } from 'lodash'
import moment from 'moment'
import '../../StudentStatistics/StudentInfoCard/studentInfoCard.css'
const sendAnalytics = sendEvent.populationStudents
const ProgressTable = ({ criteria, students, months, programme }) => {
const ProgressTable = ({ criteria, students, months, programme, studyGuidanceGroupProgramme }) => {
const mandatoryCourses = useSelector(state => state?.populationMandatoryCourses?.data)
const namesVisible = useSelector(state => state?.settings?.namesVisible)
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
const findRowContent = (s, courseCode, year, start, end) => {
if (courseCode.includes('Credits'))
return s.criteriaProgress[year] && s.criteriaProgress[year].credits ? (
@@ -31,7 +32,7 @@ const ProgressTable = ({ criteria, students, months, programme }) => {
courses.some(course => moment(course.date).isBetween(moment(start), moment(end)))
)
return <Icon fitted name="check" color="green" />
if (courses && courses.some(course => course.passed)) return <Icon fitted name="check" color="yellow" />
if (courses && courses.some(course => course.passed)) return <Icon fitted name="check" color="grey" />
if (courses && courses.some(course => course.passed === false)) return <Icon fitted name="times" color="red" />
if (s.enrollments && s.enrollments.map(course => course.course_code).includes(courseCode))
return <Icon fitted name="minus" color="grey" />
@@ -56,6 +57,171 @@ const ProgressTable = ({ criteria, students, months, programme }) => {
}
return ''
}
const credtiMonths = [12, 24, 36, 48, 60, 72]
const courses = keyBy(mandatoryCourses, 'code')
const labelCriteria = Object.keys(criteria.courses).reduce((acc, year, idx) => {
acc[year] = [
{
code: `Credits`,
name: {
fi: `${credtiMonths[idx]} mos.: ${criteria.credits[year]}`,
en: `${credtiMonths[idx]} mos.: ${criteria.credits[year]}`,
sv: `${credtiMonths[idx]} mos.: ${criteria.credits[year]}`,
},
},
...[...criteria.courses[year]]
.sort((a, b) => a.localeCompare(b))
.map(courseCode => ({
code: courseCode,
name: courses[courseCode] ? courses[courseCode].name : '',
})),
{
code: `Criteria`,
name: {
fi: `Year ${idx + 1}: Fullfilled`,
en: `Year ${idx + 1}: Fullfilled`,
sv: `Year ${idx + 1}: Fullfilled`,
},
},
{
code: `Enrollment`,
name: { fi: `Year ${idx + 1}: Fall`, en: `Year ${idx + 1}: Fall`, sv: `Year ${idx + 1}: Fall` },
},
{
code: `Enrollment`,
name: { fi: `Year ${idx + 1}: Spring`, en: `Year ${idx + 1}: Spring`, sv: `Year ${idx + 1}: Spring` },
},
]
return acc
}, {})
const criteriaHeaders = [
{ title: months < 12 ? 'Academic Year 1 (in progress)' : 'Academic Year 1', year: 'year1', label: 'yearOne' },
{ title: months < 24 ? 'Academic Year 2 (in progress)' : 'Academic Year 2', year: 'year2', label: 'yearTwo' },
{ title: months < 36 ? 'Academic Year 3 (in progress)' : 'Academic Year 3', year: 'year3', label: 'yearThree' },
]
const getEnrollmentValue = enrollmentObj => {
if (enrollmentObj?.enrollmenttype === 1) return 'No info'
if (enrollmentObj?.enrollmenttype === 1) return 'Present'
if (enrollmentObj?.enrollmenttype === 2) return 'Absent'
return 'Inactive'
}
const nonCourse = ['Criteria', 'Credits']
const style = { verticalAlign: 'middle', textAlign: 'center' }
const helpTexts = {
0: 'no info',
1: 'Active',
2: 'Student has enrolled as absent.',
3: 'Student has not enrolled to semester and is counted as inactive.',
}
const findProp = (info, s, enrollStatusIdx) => {
const propObj = {
title: '',
style,
}
const courses = s.courses.filter(
course =>
course.course_code === info.code ||
(criteria?.allCourses[info.code] && criteria?.allCourses[info.code].includes(course.course_code))
)
if (nonCourse.includes(info.code)) return propObj
// Semester Enrollment - naming things is hard
if (info.code === 'Enrollment') {
if (info.name.en.includes('Fall') && s.semesterenrollments?.length > enrollStatusIdx) {
return { ...propObj, title: helpTexts[s.semesterenrollments[enrollStatusIdx]] }
}
if (s.semesterenrollments?.length < enrollStatusIdx + 1) {
return { ...propObj, title: helpTexts[s.semesterenrollments[enrollStatusIdx + 1]] }
}
return { ...propObj, title: helpTexts[0] }
}
if (courses && courses.some(course => course.passed))
return { ...propObj, title: `Passed-${moment(courses[0].date).format('YYYY-MM-DD')}` }
if (courses && courses.some(course => course.passed === false))
return { ...propObj, title: `Failed-${moment(courses[0].date).format('YYYY-MM-DD')}` }
if (s.enrollments && s.enrollments.map(course => course.course_code).includes(info.code)) {
const enrollment = s.enrollments.filter(enrollment => enrollment.course_code === info.code)
return {
...propObj,
title: `Enrollment-${moment(enrollment[0].enrollment_date_time).format('YYYY-MM-DD')}`,
}
}
return propObj
}
const enrolmentTypes = {
1: { className: 'label-present' },
2: { className: 'label-absent' },
3: { className: 'label-passive' },
}
const renderSemester = enrollmentObj => {
if (!enrollmentObj) return ''
const { className } = enrolmentTypes[enrollmentObj.enrollmenttype]
return <div className={`enrollment-label-no-margin ${className}`}> </div>
}
const createContent = (labels, year, start, end, enrollStatusIdx) => {
return labels.map(m => ({
key: `${year}-${m.code}-${m.name.fi}`,
title: (
<div
key={`${year}-${m.code}-${getTextIn(m.name)}`}
style={{ maxWidth: '7em', whiteSpace: 'normal', overflow: 'hidden', width: 'max-content' }}
>
<div key={`${m.code}-${year}`}>{m.code}</div>
<div key={`${getTextIn(m.name)}`} style={{ color: 'gray', fontWeight: 'normal' }}>
{getTextIn(m.name)}
</div>
</div>
),
textTitle:
m.name === ''
? `${m.code} ${enrollStatusIdx === 0 ? enrollStatusIdx + 1 : enrollStatusIdx}`
: `${m.code}-${getTextIn(m.name)}`,
headerProps: { title: `${m.code}, ${year}` },
cellProps: s => findProp(m, s, enrollStatusIdx),
getRowVal: s => {
if (m.code.includes('Criteria')) return s.criteriaProgress[year] ? s.criteriaProgress[year].totalSatisfied : 0
if (m.code.includes('Enrollment') && m.name.en.includes('Fall')) {
return s.semesterenrollments.length > enrollStatusIdx
? getEnrollmentValue(s.semesterenrollments[enrollStatusIdx])
: 'No info'
}
if (m.code.includes('Enrollment') && m.name.en.includes('Spring')) {
return s.semesterenrollments.length > enrollStatusIdx + 1
? getEnrollmentValue(s.semesterenrollments[enrollStatusIdx + 1])
: 'No info'
}
return findCsvText(s, m.code, year)
},
getRowContent: s => {
if (m.code.includes('Criteria')) return s.criteriaProgress[year] ? s.criteriaProgress[year].totalSatisfied : 0
if (m.code.includes('Enrollment') && m.name.en.includes('Fall'))
return s.semesterenrollments.length > enrollStatusIdx
? renderSemester(s.semesterenrollments[enrollStatusIdx])
: ''
if (m.code.includes('Enrollment') && m.name.en.includes('Spring'))
return s.semesterenrollments.length > enrollStatusIdx + 1
? renderSemester(s.semesterenrollments[enrollStatusIdx + 1])
: ''
return findRowContent(s, m.code, year, start, end)
},
child: true,
}))
}
const acaYearStart = moment()
.subtract(months - 1, 'months')
.startOf('month')
const acaYearEnd = moment()
.subtract(months - 12, 'months')
.endOf('month')
const columns = useMemo(() => {
const studentColumns = []
if (namesVisible) {
@@ -78,7 +244,6 @@ const ProgressTable = ({ criteria, students, months, programme }) => {
}
)
}
studentColumns.push({
key: 'studentnumber-parent',
title: 'Student Number',
@@ -101,134 +266,7 @@ const ProgressTable = ({ criteria, students, months, programme }) => {
child: true,
})
const courses = keyBy(mandatoryCourses, 'code')
const labelCriteria = Object.keys(criteria.courses).reduce((acc, year, idx) => {
acc[year] = [
{
code: `Credits`,
name: {
fi: `Year ${idx + 1}: ${criteria.credits[year]}`,
en: `Year ${idx + 1}: ${criteria.credits[year]}`,
sv: `Year ${idx + 1}: ${criteria.credits[year]}`,
},
},
...[...criteria.courses[year]]
.sort((a, b) => a.localeCompare(b))
.map(courseCode => ({
code: courseCode,
name: courses[courseCode] ? courses[courseCode].name : '',
})),
{
code: `Criteria`,
name: {
fi: `Year ${idx + 1}: Fullfilled`,
en: `Year ${idx + 1}: Fullfilled`,
sv: `Year ${idx + 1}: Fullfilled`,
},
},
{
code: `Enrollment`,
name: { fi: `Year ${idx + 1}: Fall`, en: `Year ${idx + 1}: Fall`, sv: `Year ${idx + 1}: Fall` },
},
{
code: `Enrollment`,
name: { fi: `Year ${idx + 1}: Spring`, en: `Year ${idx + 1}: Spring`, sv: `Year ${idx + 1}: Spring` },
},
]
return acc
}, {})
const criteriaHeaders = [
{ title: months < 12 ? 'Academic Year 1 (in progress)' : 'Academic Year 1', year: 'year1', label: 'yearOne' },
{ title: months < 24 ? 'Academic Year 2 (in progress)' : 'Academic Year 2', year: 'year2', label: 'yearTwo' },
{ title: months < 36 ? 'Academic Year 3 (in progress)' : 'Academic Year 3', year: 'year3', label: 'yearThree' },
]
const getEnrollmentValue = enrollmentObj => {
if (enrollmentObj?.enrollmenttype === 1) return 'Present'
if (enrollmentObj?.enrollmenttype === 2) return 'Absent'
return 'Inactive'
}
const nonCourse = ['Enrollment', 'Criteria', 'Credits']
const style = { verticalAlign: 'middle', textAlign: 'center' }
const findProp = (courseCode, s) => {
const propObj = {
title: '',
style,
}
const courses = s.courses.filter(
course =>
course.course_code === courseCode ||
(criteria?.allCourses[courseCode] && criteria?.allCourses[courseCode].includes(course.course_code))
)
if (nonCourse.includes(courseCode)) return propObj
if (courses && courses.some(course => course.passed))
return { ...propObj, title: `Passed-${moment(courses[0].date).format('YYYY-MM-DD')}` }
if (courses && courses.some(course => course.passed === false))
return { ...propObj, title: `Failed-${moment(courses[0].date).format('YYYY-MM-DD')}` }
if (s.enrollments && s.enrollments.map(course => course.course_code).includes(courseCode)) {
const enrollment = s.enrollments.filter(enrollment => enrollment.course_code === courseCode)
return {
...propObj,
title: `Enrollment-${moment(enrollment[0].enrollment_date_time).format('YYYY-MM-DD')}`,
}
}
return propObj
}
const columns = []
const createContent = (labels, year, start, end, enrollStatusIdx) => {
return labels.map(m => ({
key: `${year}-${m.code}-${m.name.fi}`,
title: (
<div
key={`${year}-${m.code}-${getTextIn(m.name)}`}
style={{ maxWidth: '7em', whiteSpace: 'normal', overflow: 'hidden', width: 'max-content' }}
>
<div key={`${m.code}-${year}`}>{m.code}</div>
<div key={`${getTextIn(m.name)}`} style={{ color: 'gray', fontWeight: 'normal' }}>
{getTextIn(m.name)}
</div>
</div>
),
textTitle:
m.name === ''
? `${m.code} ${enrollStatusIdx === 0 ? enrollStatusIdx + 1 : enrollStatusIdx}`
: `${m.code}-${getTextIn(m.name)}`,
headerProps: { title: `${m.code}, ${year}` },
cellProps: s => findProp(m.code, s),
getRowVal: s => {
if (m.code.includes('Criteria')) return s.criteriaProgress[year] ? s.criteriaProgress[year].totalSatisfied : 0
if (m.code.includes('Enrollment') && m.name.fi.includes('Fall'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx])
if (m.code.includes('Enrollment') && m.name.fi.includes('Spring'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx + 1])
return findCsvText(s, m.code, year)
},
getRowExportVal: s => {
if (m.code.includes('Criteria')) return s.criteriaProgress[year] ? s.criteriaProgress[year].totalSatisfied : 0
if (m.code.includes('Enrollment') && m.name.fi.includes('Fall'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx])
if (m.code.includes('Enrollment') && m.name.fi.includes('Spring'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx + 1])
return findCsvText(s, m.code, year)
},
getRowContent: s => {
if (m.code.includes('Criteria')) return s.criteriaProgress[year] ? s.criteriaProgress[year].totalSatisfied : 0
if (m.code.includes('Enrollment') && m.name.fi.includes('Fall'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx])
if (m.code.includes('Enrollment') && m.name.fi.includes('Spring'))
return getEnrollmentValue(s.semesterenrollments[enrollStatusIdx + 1])
return findRowContent(s, m.code, year, start, end)
},
child: true,
}))
}
const acaYearStart = moment()
.subtract(months - 1, 'months')
.startOf('month')
const acaYearEnd = moment()
.subtract(months - 12, 'months')
.endOf('month')
columns.push(
{
key: 'general',
@@ -553,56 +591,65 @@ const ProgressTable = ({ criteria, students, months, programme }) => {
return students
}, [students])
return (
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ maxHeight: '80vh', width: '100%' }}>
<h5>
Criteria can be changed{' '}
<Link
to={`/study-programme/${programme}?p_m_tab=0&p_tab=3`}
onClick={() => {
sendAnalytics('No criteria defined button clicked', 'Degree courses tab')
}}
>
here.
</Link>{' '}
Please refresh page after changes.
</h5>
<Message style={{ maxWidth: '840px', fontSize: '16px' }}>
<p>
<Icon fitted name="check" color="green" />: Student has passed the course.{' '}
<Icon fitted name="check" color="yellow" />: Student has passed the course outside of the corresponding
academic year. <Icon fitted name="clipboard check" color="green" />: Student has credit transfer for the
course. <br />
<Icon fitted name="times" color="red" />: Student has failed the course.{' '}
<Icon fitted name="minus" color="grey" />: Student has enrolled, but has not received any grade from the
course.
</p>
</Message>
{isCriteriaSet ? (
<SortableTable
style={{ height: '80vh' }}
tableId="progress-of-population-students"
title={`Progress of population's students after predefined criteria`}
getRowKey={s => s.studentNumber}
tableProps={{
collapsing: true,
basic: true,
compact: 'very',
padded: false,
celled: true,
}}
columns={columns}
data={data}
/>
) : (
<div>
<h3>There is no criteria set for this programme.</h3>
</div>
)}
<>
{!isStudyGuidanceGroupProgramme && (
<h5>
Criteria can be changed{' '}
<Link
to={`/study-programme/${programme}?p_m_tab=0&p_tab=3`}
onClick={() => {
sendAnalytics('No criteria defined button clicked', 'Degree courses tab')
}}
>
here.
</Link>{' '}
Please refresh page after changes.
</h5>
)}
<Message style={{ fontSize: '16px', maxWidth: '700px' }}>
<p>
<Icon fitted name="check" color="green" />: Student has passed the course in the academic year. <br />
<Icon fitted name="check" color="grey" />: Student has passed the course outside of the corresponding academic
year. <br /> <Icon fitted name="clipboard check" color="green" />: Student has credit transfer for the course.
<br />
<Icon fitted name="times" color="red" />: Student has failed the course. <br />
<Icon fitted name="minus" color="grey" />: Student has enrolled, but has not received any grade from the
course.
<br />
<span className="enrollment-label-no-margin label-present" />: Student has an active semester enrollment.{' '}
<br />
<span className="enrollment-label-no-margin label-absent" />: Student has enrolled as absent. <br />
<span className="enrollment-label-no-margin label-passive" />: Student has no semester enrollment.
</p>
</Message>
<Tab.Pane>
<div style={{ display: 'flex' }}>
<div style={{ maxHeight: '80vh', width: '100%' }}>
{isCriteriaSet ? (
<SortableTable
style={{ height: '80vh' }}
tableId="progress-of-population-students"
title={`Progress of population's students after predefined criteria`}
getRowKey={s => s.studentNumber}
tableProps={{
collapsing: true,
basic: true,
compact: 'very',
padded: false,
celled: true,
}}
columns={columns}
data={data}
/>
) : (
<div>
<h3>There is no criteria set for this programme.</h3>
</div>
)}
</div>
</div>
</div>
</Tab.Pane>
</Tab.Pane>
</>
)
}
@@ -0,0 +1,337 @@
import React, { useMemo, useCallback } from 'react'
import _, { orderBy, uniqBy, sortBy, isNumber } from 'lodash'
import { useSelector } from 'react-redux'
import { Icon, Tab, Item } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import useLanguage from 'components/LanguagePicker/useLanguage'
import '../../PopulationCourseStats/populationCourseStats.css'
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
import SortableTable, { row } from '../../SortableTable'
import '../populationStudents.css'
import sendEvent from '../../../common/sendEvent'
const sendAnalytics = sendEvent.populationStudents
const getMandatoryPassed = (mandatoryCourses, populationCourses, studyGuidanceCourses) => {
if (!mandatoryCourses || (!populationCourses && !studyGuidanceCourses)) return {}
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
const popCourses = populationCourses || studyGuidanceCourses
if (popCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = popCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return mandatoryPassed
}
const CoursesTable = ({ students, studyGuidanceCourses }) => {
const { getTextIn } = useLanguage()
const namesVisible = useSelector(state => state?.settings?.namesVisible)
const mandatoryCourses = useSelector(state => state?.populationMandatoryCourses?.data)
const { data: populationCourses, pending } = useSelector(state => state?.populationSelectedStudentCourses)
const mandatoryPassed = useMemo(
() => getMandatoryPassed(mandatoryCourses, populationCourses, studyGuidanceCourses),
[mandatoryCourses, populationCourses, studyGuidanceCourses]
)
const hasPassedMandatory = useCallback(
(studentNumber, code) => mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber),
[mandatoryPassed]
)
const totalMandatoryPassed = useCallback(
studentNumber => _.sumBy(mandatoryCourses, ({ code }) => hasPassedMandatory(studentNumber, code)),
[mandatoryCourses, hasPassedMandatory]
)
const columns = useMemo(() => {
const nameColumns = []
if (namesVisible) {
nameColumns.push(
{
key: 'lastname',
title: 'Last name',
getRowVal: s => (s.total ? null : s.lastname),
cellProps: { title: 'last name' },
child: true,
},
{
key: 'firstname',
title: 'Given names',
getRowVal: s => (s.total ? null : s.firstnames),
cellProps: { title: 'first names' },
child: true,
}
)
}
nameColumns.push(
{
key: 'studentnumber-parent',
title: 'Student Number',
cellProps: { title: 'student number', className: 'studentNumber' },
getRowVal: s => (s.total ? '*' : s.studentNumber),
getRowContent: s =>
s.total ? (
'Summary:'
) : (
<div>
<span>{s.studentNumber}</span>
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'Mandatory courses table')
}}
>
<Icon style={{ borderLeft: '1em' }} name="user outline" />
</Item>
</div>
),
child: true,
},
{
key: 'totalpassed',
title: 'Total Passed',
filterType: 'range',
vertical: true,
getRowVal: s =>
s.total
? Object.values(s)
.filter(isNumber)
.reduce((acc, e) => acc + e, 0)
: totalMandatoryPassed(s.studentNumber),
cellProps: { title: 'total passed' },
child: true,
}
)
const mandatoryCourseLabels = []
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
acc[label].push(e)
if (e.label) mandatoryCourseLabels.push({ ...e.label, code: e.label_code })
else mandatoryCourseLabels.push({ id: 'null', label: '', code: '' })
return acc
}, {})
const sortedlabels = orderBy(
uniqBy(mandatoryCourseLabels, l => l.label),
[e => e.orderNumber],
['asc']
)
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
acc.visibleCourseCodes.add(cur.code)
}
return acc
},
{ visibleLabels: new Set(), visibleCourseCodes: new Set() }
)
const getTotalRowVal = (t, m) => t[m.code]
const columns = []
columns.push(
{
key: 'general',
title: <b>Labels:</b>,
textTitle: null,
parent: true,
children: nameColumns,
},
...sortedlabels
.filter(({ code }) => visibleLabels.has(code))
.map(e => ({
key: e.id + e.code,
title: (
<div key={e.code} style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<div key={`${e.code}-${e.id}`}>{e.code}</div>
<div key={`${e.code}${e.id}`} style={{ color: 'gray', fontWeight: 'normal' }}>
{e.id}
</div>
</div>
),
textTitle: e.code,
children: sortBy(labelToMandatoryCourses[e.label], [
m => {
const res = m.code.match(/\d+/)
return res ? Number(res[0]) : Number.MAX_VALUE
},
'code',
])
.filter(course => visibleCourseCodes.has(course.code))
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: (
<div
key={`${m.code}-${m.label ? m.label.label : 'fix'}`}
style={{ maxWidth: '15em', whiteSpace: 'normal', overflow: 'hidden', width: 'max-content' }}
>
<div key={`${m.label ? m.label.label : 'fix'}-${m.code}`}>{m.code}</div>
<div
key={`${m.label ? m.label.label : 'fix'}-${getTextIn(m.name)}`}
style={{ color: 'gray', fontWeight: 'normal' }}
>
{getTextIn(m.name)}
</div>
</div>
),
textTitle: m.code,
vertical: true,
forceToolsMode: 'dangling',
cellProps: s => {
let grade = s.courses
? s.courses.find(course => course.course_code === m.code || course.course_code === `AY${m.code}`)
?.grade
: null
grade =
s.courses && !grade && hasPassedMandatory(s.studentNumber, m.code)
? s.courses.find(course => course.course_code === m.label_code)?.grade
: grade
const gradeText = grade ? `\nGrade: ${grade}` : ''
const studentCode = s.studentNumber ? `\nStudent number: ${s.studentNumber}` : ``
return {
title: `${m.code}, ${getTextIn(m.name)}${studentCode} ${gradeText}`,
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
}
},
headerProps: { title: `${m.code}, ${getTextIn(m.name)}` },
getRowVal: s => {
if (s.total) {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowExportVal: s => {
if (s.total) {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
return hasPassedMandatory(s.studentNumber, m.code) ? <Icon fitted name="check" color="green" /> : null
},
child: true,
childOf: e.label,
code: m.code,
})),
}))
)
columns.push({
key: 'email',
title: 'Email',
getRowVal: s => (s.total ? null : s.email),
cellProps: { title: 'emails' },
child: true,
})
return columns
}, [namesVisible, mandatoryCourses, getTextIn])
const data = useMemo(() => {
const totals = students.reduce(
(acc, s) => {
const passedCourses = new Set()
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
return [row(totals, { ignoreFilters: true }), ...students]
}, [students, mandatoryCourses, hasPassedMandatory])
return (
<Tab.Pane loading={pending}>
<div style={{ display: 'flex' }}>
<div style={{ maxHeight: '80vh', width: '100%' }}>
{mandatoryCourses.length > 0 && (
<SortableTable
tableId="course-of-population-students"
title={`Courses of population's students`}
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
celled: true,
compact: 'very',
padded: false,
collapsing: true,
basic: true,
striped: true,
singleLine: true,
textAlign: 'center',
}}
columns={columns}
data={data}
/>
)}
</div>
</div>
</Tab.Pane>
)
}
// study guidance groups -feature uses different population + rtk query, so it needs to
// be rendered differently also here: TODO: refactor things nicely
const StudyGuidanceGroupCoursesTabContainer = ({ students, group }) => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const populationsCourses = useGetStudyGuidanceGroupPopulationCoursesQuery(groupStudentNumbers).data
if (populationsCourses.pending) return null
return <CoursesTable students={students} studyGuidanceCourses={populationsCourses} />
}
const CoursesTabContainer = ({ students, variant, studyGuidanceGroup }) => {
if (variant === 'studyGuidanceGroupPopulation') {
return <StudyGuidanceGroupCoursesTabContainer students={students} group={studyGuidanceGroup} />
}
return <CoursesTable students={students} />
}
export default CoursesTabContainer
@@ -513,11 +513,14 @@ const GeneralTab = ({
/>
</>
),
textTitle: 'Email',
getRowVal: s => s.email,
},
{
key: 'copyEmail',
getRowVal: s =>
textTitle: 'Secondary email',
getRowVal: s => s.secondaryEmail,
getRowContent: s =>
s.email && !s.obfuscated ? (
<Popup
trigger={
@@ -592,7 +595,8 @@ const GeneralTab = ({
// backend returns with population query below (so caching works)
const StudyGuidanceGroupGeneralTabContainer = ({ group, ...props }) => {
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const populations = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
const { tags } = group
const populations = useGetStudyGuidanceGroupPopulationQuery({ studentnumberlist: groupStudentNumbers, tags })
return <GeneralTab populations={populations} group={group} {...props} />
}
@@ -1,12 +1,10 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import _, { orderBy, uniqBy, sortBy, isNumber } from 'lodash'
import React, { useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
import { Tab, Grid, Dropdown } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import scrollToComponent from 'react-scroll-to-component'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import useLanguage from 'components/LanguagePicker/useLanguage'
import moment from 'moment'
import { useTabChangeAnalytics, usePrevious } from '../../common/hooks'
import { getTagsByStudytrackAction } from '../../redux/tags'
@@ -14,7 +12,6 @@ import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
import '../PopulationCourseStats/populationCourseStats.css'
import SortableTable, { row } from '../SortableTable'
import InfoBox from '../Info/InfoBox'
import CheckStudentList from './CheckStudentList'
import TagPopulation from '../TagPopulation'
@@ -24,318 +21,10 @@ import './populationStudents.css'
import GeneralTab from './StudentTable/GeneralTab'
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
import infotoolTips from '../../common/InfoToolTips'
import CoursesTable from './StudentTable/CourseTab'
const sendAnalytics = sendEvent.populationStudents
const getMandatoryPassed = (mandatoryCourses, populationCourses) => {
if (!mandatoryCourses || !populationCourses) return {}
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
let mandatoryPassed = {}
if (populationCourses.coursestatistics) {
const mandatorycodesMapCourseCodeToCourseID = new Map()
mandatoryCourses
.filter(course => course.visible && course.visible.visibility)
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
const courses = populationCourses.coursestatistics
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
const foundCourse = !!courses.find(c => c.course.code === code)
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
let foundSubsCourse
if (searchCode) {
foundSubsCourse = courses.find(c => {
if (!c.course.substitutions) return false
if (c.course.substitutions.length === null) return false
return c.course.substitutions.includes(searchCode)
})
}
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
if (foundSubsCourse) {
passedArray = passedArray
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
: Object.keys(foundSubsCourse.students.passed)
}
obj[code] = passedArray
return obj
}, {})
}
return mandatoryPassed
}
const CoursesTable = ({ students }) => {
const { getTextIn } = useLanguage()
const namesVisible = useSelector(state => state?.settings?.namesVisible)
const mandatoryCourses = useSelector(state => state?.populationMandatoryCourses?.data)
const { data: populationCourses, pending } = useSelector(state => state?.populationSelectedStudentCourses)
const mandatoryPassed = useMemo(
() => getMandatoryPassed(mandatoryCourses, populationCourses),
[mandatoryCourses, populationCourses]
)
const hasPassedMandatory = useCallback(
(studentNumber, code) => mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber),
[mandatoryPassed]
)
const totalMandatoryPassed = useCallback(
studentNumber => _.sumBy(mandatoryCourses, ({ code }) => hasPassedMandatory(studentNumber, code)),
[mandatoryCourses, hasPassedMandatory]
)
const columns = useMemo(() => {
const nameColumns = []
if (namesVisible) {
nameColumns.push(
{
key: 'lastname',
title: 'Last name',
getRowVal: s => (s.total ? null : s.lastname),
cellProps: { title: 'last name' },
child: true,
},
{
key: 'firstname',
title: 'Given names',
getRowVal: s => (s.total ? null : s.firstnames),
cellProps: { title: 'first names' },
child: true,
}
)
}
nameColumns.push(
{
key: 'studentnumber-parent',
title: 'Student Number',
cellProps: { title: 'student number', className: 'studentNumber' },
getRowVal: s => (s.total ? '*' : s.studentNumber),
getRowContent: s =>
s.total ? (
'Summary:'
) : (
<div>
<span>{s.studentNumber}</span>
<Item
as={Link}
to={`/students/${s.studentNumber}`}
onClick={() => {
sendAnalytics('Student details button clicked', 'Mandatory courses table')
}}
>
<Icon style={{ borderLeft: '1em' }} name="user outline" />
</Item>
</div>
),
child: true,
},
{
key: 'totalpassed',
title: 'Total Passed',
filterType: 'range',
vertical: true,
getRowVal: s =>
s.total
? Object.values(s)
.filter(isNumber)
.reduce((acc, e) => acc + e, 0)
: totalMandatoryPassed(s.studentNumber),
cellProps: { title: 'total passed' },
child: true,
}
)
const mandatoryCourseLabels = []
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
const label = e.label ? e.label.label : ''
acc[label] = acc[label] || []
if (acc[label].some(l => l.code === e.code)) return acc
acc[label].push(e)
if (e.label) mandatoryCourseLabels.push({ ...e.label, code: e.label_code })
else mandatoryCourseLabels.push({ id: 'null', label: '', code: '' })
return acc
}, {})
const sortedlabels = orderBy(
uniqBy(mandatoryCourseLabels, l => l.label),
[e => e.orderNumber],
['asc']
)
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
(acc, cur) => {
if (cur.visible && cur.visible.visibility) {
acc.visibleLabels.add(cur.label_code)
acc.visibleCourseCodes.add(cur.code)
}
return acc
},
{ visibleLabels: new Set(), visibleCourseCodes: new Set() }
)
const getTotalRowVal = (t, m) => t[m.code]
const columns = []
columns.push(
{
key: 'general',
title: <b>Labels:</b>,
textTitle: null,
parent: true,
children: nameColumns,
},
...sortedlabels
.filter(({ code }) => visibleLabels.has(code))
.map(e => ({
key: e.id + e.code,
title: (
<div key={e.code} style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<div key={`${e.code}-${e.id}`}>{e.code}</div>
<div key={`${e.code}${e.id}`} style={{ color: 'gray', fontWeight: 'normal' }}>
{e.id}
</div>
</div>
),
textTitle: e.code,
children: sortBy(labelToMandatoryCourses[e.label], [
m => {
const res = m.code.match(/\d+/)
return res ? Number(res[0]) : Number.MAX_VALUE
},
'code',
])
.filter(course => visibleCourseCodes.has(course.code))
.map(m => ({
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
title: (
<div
key={`${m.code}-${m.label ? m.label.label : 'fix'}`}
style={{ maxWidth: '15em', whiteSpace: 'normal', overflow: 'hidden', width: 'max-content' }}
>
<div key={`${m.label ? m.label.label : 'fix'}-${m.code}`}>{m.code}</div>
<div
key={`${m.label ? m.label.label : 'fix'}-${getTextIn(m.name)}`}
style={{ color: 'gray', fontWeight: 'normal' }}
>
{getTextIn(m.name)}
</div>
</div>
),
textTitle: m.code,
vertical: true,
forceToolsMode: 'dangling',
cellProps: s => {
let grade = s.courses
? s.courses.find(course => course.course_code === m.code || course.course_code === `AY${m.code}`)
?.grade
: null
grade =
s.courses && !grade && hasPassedMandatory(s.studentNumber, m.code)
? s.courses.find(course => course.course_code === m.label_code)?.grade
: grade
const gradeText = grade ? `\nGrade: ${grade}` : ''
const studentCode = s.studentNumber ? `\nStudent number: ${s.studentNumber}` : ``
return {
title: `${m.code}, ${getTextIn(m.name)}${studentCode} ${gradeText}`,
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
}
},
headerProps: { title: `${m.code}, ${getTextIn(m.name)}` },
getRowVal: s => {
if (s.total) {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowExportVal: s => {
if (s.total) {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
return hasPassedMandatory(s.studentNumber, m.code) ? <Icon fitted name="check" color="green" /> : null
},
child: true,
childOf: e.label,
code: m.code,
})),
}))
)
columns.push({
key: 'email',
title: 'Email',
getRowVal: s => (s.total ? null : s.email),
cellProps: { title: 'emails' },
child: true,
})
return columns
}, [namesVisible, mandatoryCourses, getTextIn])
const data = useMemo(() => {
const totals = students.reduce(
(acc, s) => {
const passedCourses = new Set()
mandatoryCourses.forEach(m => {
if (passedCourses.has(m.code)) return
passedCourses.add(m.code)
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
})
return acc
},
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
)
return [row(totals, { ignoreFilters: true }), ...students]
}, [students, mandatoryCourses, hasPassedMandatory])
return (
<Tab.Pane loading={pending}>
<div style={{ display: 'flex' }}>
<div style={{ maxHeight: '80vh', width: '100%' }}>
{mandatoryCourses.length > 0 && (
<SortableTable
tableId="course-of-population-students"
title={`Courses of population's students`}
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
tableProps={{
celled: true,
compact: 'very',
padded: false,
collapsing: true,
basic: true,
striped: true,
singleLine: true,
textAlign: 'center',
}}
columns={columns}
data={data}
/>
)}
</div>
</div>
</Tab.Pane>
)
}
const Panes = ({
filteredStudents,
tags,
@@ -356,6 +45,7 @@ const Panes = ({
'Change students table tab'
)
const programme = studyGuidanceGroup?.tags?.studyProgramme || ''
const panesAvailable = [
{
menuItem: 'General',
@@ -375,7 +65,9 @@ const Panes = ({
},
{
menuItem: 'Courses',
render: () => <CoursesTable students={filteredStudents} />,
render: () => (
<CoursesTable students={filteredStudents} variant={variant} studyGuidanceGroup={studyGuidanceGroup} />
),
},
{
menuItem: 'Tags',
@@ -428,7 +120,8 @@ const Panes = ({
students={filteredStudents}
criteria={criteria}
months={months}
programme={queryStudyrights[0]}
programme={queryStudyrights[0] || programme}
studyGuidanceGroupProgramme={programme}
/>
),
},
@@ -474,7 +167,10 @@ const PopulationStudents = ({
const { data: tags } = useSelector(({ tags }) => tags)
const { query } = useSelector(({ populations }) => populations)
const queryStudyrights = query ? Object.values(query.studyRights) : []
const months = query ? query.months : 0
let months = query ? query.months : 0
if (studyGuidanceGroup && studyGuidanceGroup?.tags?.year) {
months = moment(`${studyGuidanceGroup?.tags?.year}-08-01`).diff(moment(), 'months')
}
const prevShowList = usePrevious(showList)
const { isAdmin } = useGetAuthorizedUserQuery()
const admin = isAdmin
@@ -523,12 +219,18 @@ const PopulationStudents = ({
)
}
const getTabs = programmeCode => {
if (programmeCode && (programmeCode.includes('KH') || ['MH30_001', 'MH30_003'].includes(programmeCode)))
return ['General', 'Courses', 'Progress']
if (programmeCode) return ['General', 'Courses']
return ['General']
}
const PopulationStudentsContainer = ({ ...props }) => {
const { variant } = props
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant)) {
throw new Error(`${variant} is not a proper variant!`)
}
const contentByVariant = {
population: {
panesToInclude:
@@ -549,7 +251,7 @@ const PopulationStudentsContainer = ({ ...props }) => {
infotoolTipContent: infotoolTips.PopulationStatistics.StudentsCustom,
},
studyGuidanceGroupPopulation: {
panesToInclude: ['General'],
panesToInclude: getTabs(props.studyGuidanceGroup?.tags?.studyProgramme),
infotoolTipContent: infotoolTips.PopulationStatistics.StudentsGuidanceGroups,
},
}
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { Message, Button } from 'semantic-ui-react'
const RightsNotification = ({ studentNumbers }) => {
const RightsNotification = ({ discardedStudentNumbers }) => {
const [visible, setVisible] = useState(true)
if (!visible) {
@@ -9,15 +9,15 @@ const RightsNotification = ({ studentNumbers }) => {
}
return (
<Message>
<Message data-cy="rights-notification">
<Message.Header>Invalid or forbidden student numbers</Message.Header>
<p style={{ maxWidth: '50em' }}>
The following students information could not be displayed. This could be either because they do not exist, or
you do not have the right to view their information.
</p>
<ul>
{studentNumbers.map(num => (
<li>{num}</li>
{[...new Set(discardedStudentNumbers)].map(num => (
<li key={num}>{num}</li>
))}
</ul>
<Button onClick={() => setVisible(false)}>Hide notification</Button>
@@ -124,13 +124,7 @@ const Routes = () => (
path={routes.studyGuidanceGroups}
component={StudyGuidanceGroups}
/>
<ProtectedRoute
requiredRoles={['admin']}
requireUserHasRights
exact
path={routes.evaluationOverview}
component={EvaluationOverview}
/>
<ProtectedRoute requireUserHasRights exact path={routes.evaluationOverview} component={EvaluationOverview} />
<Redirect from="/cool-data-science" to="/trends" />
<Redirect to="/" />
</Switch>
@@ -1,7 +1,7 @@
/* eslint-disable no-continue */
/* eslint-disable max-classes-per-file */
import React, { useState, useMemo } from 'react'
import React, { useState, useMemo, useEffect } from 'react'
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
import _ from 'lodash'
import xlsx from 'xlsx'
@@ -57,6 +57,10 @@ const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
const flatData = useMemo(() => flattenData(data), [data])
const [selected, setSelected] = useState(_.uniq(_.map(exportColumns, 'key')))
useEffect(() => {
setSelected(_.uniq(_.map(exportColumns, 'key')))
}, [exportColumns])
const sampledValues = useMemo(
() => ValueVisitor.visit(data, exportColumns, { exportMode: true }).sample(10),
[exportColumns, data]
@@ -8,13 +8,14 @@ import './studentInfoCard.css'
const enrolmentTypes = {
1: { text: 'Present', className: 'label-present' },
2: { text: 'Absent', className: 'label-absent' },
3: { text: 'Passive', className: 'label-passive' },
3: { text: 'Inactive', className: 'label-passive' },
STATUTORY: { text: 'Absent, statutory', className: 'label-absent-statutory' },
}
const helpTexts = {
1: '',
2: 'The registration has been done and the student is not attending to the study term.',
3: 'The registration has not been done and the student is counted as passive.',
3: 'The registration has not been done and the student is counted as inactive.',
STATUTORY: 'This absence is statutory e.g. parental leave or military service',
}
const curDate = new Date()
@@ -38,8 +38,18 @@
min-height: 15px;
}
.enrollment-label-no-margin {
border-radius: 0.28rem;
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
display: inline-block;
width: 15px;
height: 15px;
min-width: 15px;
min-height: 15px;
}
.label-present {
background-color: #96d7c3;
background-color: #03cc0d;
}
.label-absent {
@@ -19,6 +19,7 @@ import {
import { useHistory } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { useToggle } from 'common/hooks'
import { useGetProgressCriteriaQuery } from 'redux/programmeProgressCriteria'
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
import { useGetSemestersQuery } from '../../redux/semesters'
@@ -54,6 +55,9 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
const [activeIndex, setActiveIndex] = useState([])
const [newestIndex, setNewestIndex] = useState(null)
const isMounted = useIsMounted()
const criteria = useGetProgressCriteriaQuery({
programmeCode: group?.tags?.studyProgramme ? group?.tags?.studyProgramme : '',
}).data
const { data: courses, isLoading: coursesAreLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(
_.map(filteredStudents, 'studentNumber')
@@ -177,6 +181,7 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
variant="studyGuidanceGroupPopulation"
language={language}
filteredStudents={students}
criteria={criteria}
studyGuidanceGroup={group}
/>
</div>
@@ -186,14 +191,14 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
]
return (
<div style={{ overflow: 'hidden', flexGrow: 1, padding: '1px' }}>
<div style={{ overflowX: 'auto', flexGrow: 1, padding: '1px' }}>
<Accordion
activeIndex={activeIndex}
exclusive={false}
styled
fluid
panels={createPanels(filteredStudents)}
style={{ overflow: 'hidden' }}
style={{ overflowX: 'auto' }}
/>
</div>
)
@@ -218,6 +223,7 @@ const SingleStudyGroupFilterView = props => {
courses: props.courses?.coursestatistics ?? [],
}),
filters.creditDateFilter,
filters.creditsEarnedFilter,
filters.studyTrackFilter,
filters.programmeFilter({
additionalModes: props.group?.tags?.year
@@ -312,7 +318,8 @@ const SingleStudyGuidanceGroupContainer = ({ group }) => {
const { language } = useSelector(({ settings }) => settings)
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
const { tags } = group
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery({ studentnumberlist: groupStudentNumbers, tags })
const { data: courses, isLoading: coursesAreLoading } =
useGetStudyGuidanceGroupPopulationCoursesQuery(groupStudentNumbers)
@@ -29,7 +29,11 @@ const StudyGuidanceGroupPopulationCourses = ({
return (
<Segment basic>
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
{studyProgramme && showStructured ? (
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfClass} />
) : (
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
)}
{studyProgramme && (
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
@@ -15,6 +15,12 @@ import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const isNewProgramme = code => code.includes('KH') || code.includes('MH') || /^(T)[0-9]{6}$/.test(code)
const getGraduatedText = code => {
if (code.slice(0, 1) === 'T' || code.slice(0, 3) === 'LIS') {
return 'Graduated of the programme'
}
return 'Graduated and thesis writers of the programme'
}
const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYear, setAcademicYear }) => {
const [showMedian, setShowMedian] = useState(false)
@@ -147,7 +153,7 @@ const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYea
)}
{graduations.isSuccess && graduations.data && (
<>
{getDivider('Graduated and thesis writers of the programme', 'GraduatedAndThesisWritersOfTheProgramme')}
{getDivider(getGraduatedText(studyprogramme), 'GraduatedAndThesisWritersOfTheProgramme')}
<div className="section-container">
<BarChart cypress="GraduatedAndThesisWritersOfTheProgramme" data={graduations?.data} />
<DataTable
@@ -272,20 +272,22 @@ const DegreeCourses = ({ studyProgramme, criteria, setCriteria, setExclusion, re
}
return (
<>
<Container>
{(studyProgramme.includes('KH') || ['MH30_001', 'MH30_003'].includes(studyProgramme)) && (
<>
<Message style={{ fontSize: '16px' }}>
<Message.Header>Change visibility of degree courses and select criteria for academic years</Message.Header>
<p>
Here you can change visibility of degree courses as and set course and credits criteria, fore each year
their own. Credits criteria is computed as follows: for the first academic year the credits are taken into
account if they are completed during the first 12 months. For the second year, we take into account the
completions during the first 24 months, for the third year the first 36 months.
</p>
<p>The progress of the students by these criteria will be shown in class statistics view.</p>
</Message>
<Container>
<Message style={{ fontSize: '16px' }}>
<Message.Header>
Change visibility of degree courses and select criteria for academic years
</Message.Header>
<p>
Here you can change visibility of degree courses as and set course and credits criteria, for each year
their own. Credits criteria is computed as follows: for the first academic year the credits are taken
into account if they are completed during the first 12 months. For the second year, we take into account
the completions during the first 24 months, for the third year the first 36 months.
</p>
<p>The progress of the students by these criteria will be shown in class statistics view.</p>
</Message>
<h5>Credit criteria</h5>
<Form>
<Form.Group widths="equal">
@@ -413,7 +415,7 @@ const DegreeCourses = ({ studyProgramme, criteria, setCriteria, setExclusion, re
))}
</Table.Body>
</Table>
</>
</Container>
)
}
@@ -9,15 +9,47 @@ const getMonths = year => {
const start = `${year}-08-01`
return Math.round(moment.duration(moment(lastDayOfMonth).diff(moment(start))).asMonths())
}
const getTotalPopulationLink = (studytrack, studyprogramme, combinedProgramme, months, yearsString) => {
if (studytrack) {
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=All&years=${yearsString}`
)
}
if (combinedProgramme) {
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"combinedProgramme"%3A"${combinedProgramme}"%7D&year=All&years=${yearsString}`
)
}
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=All&years=${yearsString}`
)
}
const PopulationStatisticsLink = ({ studytrack, studyprogramme, year }) => {
const getPopulationLink = (studytrack, studyprogramme, combinedProgramme, startYear, months) => {
if (studytrack) {
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=${startYear}`
)
}
if (combinedProgramme) {
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"combinedProgramme"%3A"${combinedProgramme}"%7D&year=${startYear}`
)
}
return (
`/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${startYear}`
)
}
const PopulationStatisticsLink = ({ studytrack, studyprogramme, combinedProgramme, year }) => {
const startYear = Number(year.slice(0, 4))
const months = Math.ceil(moment.duration(moment().diff(`${startYear}-08-01`)).asMonths())
const href = studytrack
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=${startYear}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=${startYear}`
const href = getPopulationLink(studytrack, studyprogramme, combinedProgramme, startYear, months)
return (
<Link title={`Population statistics of class ${year}`} to={href}>
<Icon name="level up alternate" />
@@ -25,14 +57,10 @@ const PopulationStatisticsLink = ({ studytrack, studyprogramme, year }) => {
)
}
const TotalPopulationLink = ({ studytrack, studyprogramme, years }) => {
const TotalPopulationLink = ({ studytrack, studyprogramme, combinedProgramme, years }) => {
const yearsString = years.join('&years=')
const months = getMonths(Math.min(...years.map(year => Number(year))))
const href = studytrack
? `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%2C"studyTrack"%3A"${studytrack}"%7D&year=All&years=${yearsString}`
: `/populations?months=${months}&semesters=FALL&semesters=` +
`SPRING&studyRights=%7B"programme"%3A"${studyprogramme}"%7D&year=All&years=${yearsString}`
const href = getTotalPopulationLink(studytrack, studyprogramme, combinedProgramme, years, months, yearsString)
return (
<Link title="Population statistics of all years" to={href}>
<Icon name="level up alternate" />
@@ -2,6 +2,7 @@ import React from 'react'
import { Item, Icon } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import InfoToolTips from 'common/InfoToolTips'
import InfoBox from 'components/Info/InfoBox'
import sendEvent from '../../../common/sendEvent'
import useLanguage from '../../LanguagePicker/useLanguage'
import SortableTable from '../../SortableTable'
@@ -12,6 +13,7 @@ const toolTips = InfoToolTips.Studyprogramme
const getColumns = (language, showStudents) => {
let columns = null
if (showStudents) {
columns = [
{
@@ -89,7 +91,6 @@ const getColumns = (language, showStudents) => {
{
key: 'not-completed',
title: 'Not Completed',
helpText: toolTips.NotCompleted,
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: course => course.totalAllNotCompleted,
@@ -121,7 +122,6 @@ const getColumns = (language, showStudents) => {
{
key: 'totalWithoutStudyright',
title: 'Non-degree students',
helpText: toolTips.NonDegree,
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: course => course.totalWithoutStudyrightStudents,
@@ -182,8 +182,13 @@ const getColumns = (language, showStudents) => {
{
key: 'name',
title: 'Name',
helpText: toolTips.Name,
getRowVal: course => course.name[language],
getRowContent: course => course.name[language],
getRowContent: course =>
course.name[language].length > 46 ? `${course.name[language].slice(0, 44)}...` : course.name[language],
cellProps: course => {
return { title: course.name[language] }
},
},
{
key: 'total',
@@ -212,7 +217,6 @@ const getColumns = (language, showStudents) => {
{
key: 'totalWithoutStudyright',
title: 'Non-degree credits',
helpText: toolTips.NonDegree,
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: course => course.totalWithoutStudyrightCredits,
@@ -221,7 +225,6 @@ const getColumns = (language, showStudents) => {
{
key: 'totalTransfer',
title: 'Transferred credits',
helpText: toolTips.TransferredCredits,
cellStyle: { textAlign: 'right' },
filterType: 'range',
getRowVal: course => course.totalTransferCredits,
@@ -236,17 +239,23 @@ const getColumns = (language, showStudents) => {
const OverallStatsTable = ({ data, showStudents }) => {
const { language } = useLanguage()
return (
<div data-cy="CoursesSortableTable">
<SortableTable
title={`Student statistics for studyprogramme courses `}
defaultSort={['name', 'asc']}
defaultdescending
getRowKey={course => course.code}
tableProps={{ celled: true, fixed: true }}
columns={getColumns(language, showStudents)}
data={data}
<>
<InfoBox
content={showStudents ? toolTips.StudentsOfProgrammeCourses : toolTips.CreditsProducedByTheStudyprogramme}
cypress="programme-courses"
/>
</div>
<div data-cy="CoursesSortableTable">
<SortableTable
title={`Student statistics for studyprogramme courses `}
defaultSort={['name', 'asc']}
defaultdescending
getRowKey={course => course.code}
tableProps={{ celled: true, fixed: true }}
columns={getColumns(language, showStudents)}
data={data}
/>
</div>
</>
)
}
+2 -1
View File
@@ -56,9 +56,10 @@ export const getCoursePopulation = ({ coursecodes, from, to, onProgress, separat
export const getCustomPopulation = ({ studentnumberlist, onProgress, usingStudyGuidanceGroups }) => {
const route = '/v3/populationstatisticsbystudentnumbers'
const prefix = 'GET_CUSTOM_POP_'
const tags = { studyProgramme: '', year: 1900 } // This nonsense is added here becaus of the studyguidance groups
const params = { studentnumberlist, usingStudyGuidanceGroups }
const query = { studentnumberlist, studyRights: { programme: 'KH555' } }
const body = { studentnumberlist, usingStudyGuidanceGroups }
const body = { studentnumberlist, usingStudyGuidanceGroups, tags }
return callController(route, prefix, body, 'post', query, params, onProgress)
}
@@ -19,10 +19,10 @@ const studyGuidanceGroupsApi = RTKApi.injectEndpoints({
invalidatesTags: ({ studyGuidanceGroupId }) => [{ type: 'StudyGuidanceGroups', id: studyGuidanceGroupId }],
}),
getStudyGuidanceGroupPopulation: builder.query({
query: studentnumberlist => ({
query: ({ studentnumberlist, tags }) => ({
url: '/v3/populationstatisticsbystudentnumbers',
method: 'POST',
body: { studentnumberlist, usingStudyGuidanceGroups: true },
body: { studentnumberlist, tags, usingStudyGuidanceGroups: true },
}),
}),
getStudyGuidanceGroupPopulationCourses: builder.query({
@@ -0,0 +1,6 @@
module.exports = {
up: async queryInterface => {
await queryInterface.removeColumn('student', 'dissemination_info_allowed')
},
down: async () => {},
}
@@ -1,4 +1,4 @@
const { Model, STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
const { Model, STRING, DATE, INTEGER } = require('sequelize')
const { dbConnections } = require('../connection')
class Student extends Model {}
@@ -27,7 +27,6 @@ Student.init(
home_country_en: { type: STRING },
gender_code: { type: STRING },
sis_person_id: { type: STRING },
dissemination_info_allowed: { type: BOOLEAN },
createdAt: {
type: DATE,
},
@@ -84,7 +84,7 @@ const calculateTotalCreditsFromAttainments = attainments => {
return totalCredits
}
const studentMapper = (attainments, studyRights, attainmentsToBeExluced, disclosures) => student => {
const studentMapper = (attainments, studyRights, attainmentsToBeExluced) => student => {
const {
last_name,
first_names,
@@ -107,11 +107,6 @@ const studentMapper = (attainments, studyRights, attainmentsToBeExluced, disclos
// https://github.com/UniversityOfHelsinkiCS/oodikone/issues/2958
const country = getCountry(student.country_urn)
const home_country = student.citizenships ? getCountry(student.citizenships[0]) : null
// "hy-luovutusehto-6" = Open University's dissemination of information on its course offerings
const openUniDisseminationOfInfo = disclosures.filter(
d => d.person_id === id && d.disclosure_category_id === 'hy-luovutusehto-6'
)
const marketingAllowed = openUniDisseminationOfInfo.length > 0 ? openUniDisseminationOfInfo[0].authorized : null
const studyRightsOfStudent = studyRights.filter(SR => SR.person_id === id)
const dateofuniversityenrollment =
@@ -142,7 +137,6 @@ const studentMapper = (attainments, studyRights, attainmentsToBeExluced, disclos
home_country_sv: home_country ? home_country.name.sv : null,
home_country_en: home_country ? home_country.name.en : null,
sis_person_id: id,
dissemination_info_allowed: marketingAllowed,
}
}
@@ -183,7 +183,6 @@ const updateStudents = async personIds => {
studyRightPrimalities,
enrollments,
studyplans,
disclosures,
] = await Promise.all([
selectFromByIds('persons', personIds),
selectFromByIds('studyrights', personIds, 'person_id'),
@@ -192,7 +191,6 @@ const updateStudents = async personIds => {
selectFromByIds('study_right_primalities', personIds, 'student_id'),
selectFromByIds('enrolments', personIds, 'person_id'),
selectFromByIds('plans', personIds, 'user_id'),
selectFromByIds('disclosures', personIds, 'person_id'),
])
const parsedStudyrightSnapshots = parseStudyrightSnapshots(studyrightSnapshots)
@@ -213,7 +211,7 @@ const updateStudents = async personIds => {
const attainmentsToBeExluced = getAttainmentsToBeExcluded()
const mappedStudents = students
.filter(s => s.student_number)
.map(studentMapper(attainments, parsedStudyrightSnapshots, attainmentsToBeExluced, disclosures))
.map(studentMapper(attainments, parsedStudyrightSnapshots, attainmentsToBeExluced))
await bulkCreate(Student, mappedStudents)