mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb270a070 | ||
|
|
c894f858ca | ||
|
|
f5b04beda0 | ||
|
|
c20a4e4727 | ||
|
|
3081752d8f | ||
|
|
19fe7fd817 | ||
|
|
99b3f7756c | ||
|
|
51475be0db | ||
|
|
a8eb68e317 | ||
|
|
8281564fef | ||
|
|
4373065fdd | ||
|
|
8dbbf00b51 | ||
|
|
584a1b474a | ||
|
|
8151c4f463 | ||
|
|
bb818ee9ac | ||
|
|
cd3b9bb843 | ||
|
|
aaa012894c | ||
|
|
4d06eb7558 | ||
|
|
b563b29a36 | ||
|
|
617e234798 | ||
|
|
754d525f87 | ||
|
|
a0e6d342b9 | ||
|
|
60f500c9e7 | ||
|
|
dc9ce89aa7 | ||
|
|
8a1fb2725b | ||
|
|
0ca6bca243 | ||
|
|
7457bc412b | ||
|
|
3d2e8fb1ca | ||
|
|
1d980795c6 | ||
|
|
6bc6a28127 | ||
|
|
a2f84931cd | ||
|
|
1f1bae8557 | ||
|
|
c714c61269 | ||
|
|
e85deff947 | ||
|
|
8c87135f58 | ||
|
|
7eac6c686e | ||
|
|
11172f7d77 | ||
|
|
be29486cdd | ||
|
|
04cbec19a5 | ||
|
|
7c3cfb56b5 | ||
|
|
090305be3b | ||
|
|
c1e0d34670 | ||
|
|
9cfc51382d | ||
|
|
3f179d3460 | ||
|
|
0ad0d73d18 | ||
|
|
7952b3703f | ||
|
|
e9902a0bbe | ||
|
|
b225e03bd5 | ||
|
|
374cef4a1a | ||
|
|
6dff16f683 | ||
|
|
73a1d50ec1 | ||
|
|
b473b393f0 | ||
|
|
218f09ea65 | ||
|
|
5948a34c6e | ||
|
|
6db0e982d9 | ||
|
|
205637c4db | ||
|
|
7ac397969a | ||
|
|
9fc927daff | ||
|
|
72635b53ac | ||
|
|
980a7a6703 | ||
|
|
80709a129c | ||
|
|
26c7f3e00b | ||
|
|
cc60c9f415 | ||
|
|
7f8f40a193 | ||
|
|
1749503aa4 | ||
|
|
da55bfefac | ||
|
|
0c2f197460 | ||
|
|
6503971915 | ||
|
|
c30624d319 | ||
|
|
a4c798ea25 | ||
|
|
bf175e9140 | ||
|
|
70f4440974 | ||
|
|
cde5dd5ced | ||
|
|
35777287b2 | ||
|
|
3ec010f347 | ||
|
|
36792ac667 | ||
|
|
d4f187ce92 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+73
-14
@@ -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()]
|
||||
|
||||
-17
@@ -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
|
||||
+2
-4
@@ -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 (
|
||||
<>
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+19
-8
@@ -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]
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+40
-12
@@ -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" />
|
||||
|
||||
+24
-15
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user