Compare commits

...
36 Commits
Author SHA1 Message Date
Miko Keskimäki 19fe7fd817 Remove target: production for updated Dockerfiles 2023-05-04 18:01:55 +03:00
ShootingStar91 99b3f7756c [Progress tab] Infobox margin issue fixed 2023-05-04 17:47:02 +03:00
ShootingStar91 51475be0db [Class statistics] Credits gained: info improved 2023-05-04 17:29:32 +03:00
ShootingStar91 a8eb68e317 Next academic year only shown after 1.8.: affects many views 2023-05-04 16:23:35 +03:00
ShootingStar91 8281564fef [Student statistics] Transfer text made nicer 2023-05-04 14:25:58 +03:00
ShootingStar91 4373065fdd [Studyprogramme Overview] Fixed title for doctors 2023-05-04 14:24:58 +03:00
Miko Keskimäki 8dbbf00b51 Remove openshift branch from staging action 2023-05-04 13:42:08 +03:00
Outi SavolainenandGitHub 584a1b474a Merge pull request #4195 from UniversityOfHelsinkiCS/openshift
Update configuration to work with OpenShift
2023-05-04 13:40:09 +03:00
ShootingStar91 8151c4f463 [General tab] Emails in CSV fixes 2023-05-04 12:44:05 +03:00
Miko Keskimäki bb818ee9ac Run workflow when files updated 2023-05-04 12:16:25 +03:00
Miko Keskimäki cd3b9bb843 Test new staging workflow 2023-05-04 12:14:25 +03:00
Miko Keskimäki aaa012894c Don't run sentry in staging, update staging workflow 2023-05-04 12:12:59 +03:00
ShootingStar91 4d06eb7558 Fix typo lenght to length 2023-05-04 11:17:59 +03:00
ShootingStar91 b563b29a36 [Credit accumulation] Graduations are shown as diamond-icon 2023-05-04 10:48:36 +03:00
outisa 617e234798 [class stats] Small fixes 2023-05-03 18:44:08 +03:00
Miko Keskimäki 754d525f87 Update environment configuration 2023-05-03 15:28:04 +03:00
Miko Keskimäki a0e6d342b9 Configurable Jami url 2023-05-03 14:37:27 +03:00
Miko Keskimäki 60f500c9e7 Configurable importer url 2023-05-03 14:12:17 +03:00
Miko Keskimäki dc9ce89aa7 More env variables 2023-05-03 13:37:24 +03:00
Miko Keskimäki 8a1fb2725b Pass built_at to frontend, update staging 2023-05-03 13:33:19 +03:00
Miko Keskimäki 0ca6bca243 Maybe now 2023-05-03 12:50:10 +03:00
Miko Keskimäki 7457bc412b Again 2023-05-03 12:48:13 +03:00
Miko Keskimäki 3d2e8fb1ca Try again 2023-05-03 12:47:41 +03:00
Miko Keskimäki 1d980795c6 Update image build and release 2023-05-03 12:27:38 +03:00
Miko Keskimäki 6bc6a28127 Define secrets as inputs 2023-05-03 12:11:50 +03:00
Miko Keskimäki a2f84931cd Pass toska registry secrets to workflow 2023-05-03 12:04:32 +03:00
Miko Keskimäki 1f1bae8557 Add missing build jobs 2023-05-03 11:59:15 +03:00
Miko Keskimäki c714c61269 Workflow refactor 2023-05-03 11:53:30 +03:00
Miko Keskimäki e85deff947 Delete unnecessary steps, update image names 2023-05-03 11:37:45 +03:00
Miko Keskimäki 8c87135f58 Update openshift workflow 2023-05-03 11:09:56 +03:00
Miko Keskimäki 7eac6c686e Remove extra backend package installs 2023-05-03 10:11:04 +03:00
Miko Keskimäki 11172f7d77 Update temporary workflow 2023-05-03 10:07:34 +03:00
Miko Keskimäki be29486cdd Update frontend, backend Dockerfiles to work with openshift 2023-05-03 10:04:49 +03:00
Miko Keskimäki 04cbec19a5 Separate backend and frontend dev setups to separate Dockerfiles 2023-05-03 09:50:48 +03:00
Miko Keskimäki 7c3cfb56b5 Fix iams not being saved to Jami 2023-05-03 09:10:04 +03:00
outisa 090305be3b [Class stats] Unify bachelor and master (eläinlääkkis) 2023-05-02 18:39:26 +03:00
30 changed files with 421 additions and 287 deletions
-2
View File
@@ -35,7 +35,6 @@ jobs:
context: services/backend
push: true
tags: toska/oodikone2-backend:latest
target: production
build_and_release_frontend:
name: 'Build and release frontend'
@@ -71,7 +70,6 @@ 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'
+88
View File
@@ -0,0 +1,88 @@
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 }}
@@ -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,9 +271,10 @@ 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:
@@ -300,9 +289,10 @@ 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/Completed_courses.js
test_studyprogramme_overview:
name: 'Test studyprogramme overview'
needs:
@@ -317,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:
@@ -335,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
@@ -355,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:
@@ -373,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:
@@ -402,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'
@@ -416,100 +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_completed_courses
- 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_completed_courses
- test_studyprogramme_overview
- test_teachers_and_users
- test_faculty_statistics
- test_open_uni_search
- test_service_exit_statuses
if: failure()
steps:
- uses: actions/checkout@v3
- name: 'Send notification to slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.4.2
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: test-failure
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
'{services,updater}/**/*.{js,jsx}': files => `eslint --fix ${files.join(' ')}`,
'*.{js,json,md,yml,yaml,html}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.7.0 hadolint ${relativeFilePaths(files)}`,
Dockerfile: files => `${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
'docker-compose*': files => {
+5 -2
View File
@@ -8,15 +8,18 @@ const deleteTag = name => {
}
const getEmptyYears = isAcademicYear => {
const thisYear = new Date().getFullYear()
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = thisYear; year > 2021; year--) {
for (let year = latestYear; year > 2021; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
console.log({ years, latestYear, isAcademicYear })
return years
}
+2
View File
@@ -19,6 +19,8 @@ services:
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://importer.cs.helsinki.fi/api/importer'
JAMI_URL: 'http://jami:3003/'
image: toska/oodikone2-backend:staging
frontend:
+9 -7
View File
@@ -4,7 +4,7 @@ x-defaults: &defaults
restart: unless-stopped
x-build_defaults: &build_defaults
target: development
dockerfile: dev.Dockerfile
services:
adminer:
@@ -39,13 +39,15 @@ services:
SECRET_TOKEN: dev
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
IMPORTER_DB_API_URL: 'https://importer.cs.helsinki.fi/api/importer'
JAMI_URL: 'http://jami:3003/'
ports:
- 8080:8080
profiles:
- oodikone
volumes:
- backend-nodemod:/usr/src/app/node_modules
- ./services/backend/:/usr/src/app
- backend-nodemod:/opt/app-root/src/node_modules
- ./services/backend/:/opt/app-root/src
frontend:
<<: *defaults
@@ -58,8 +60,8 @@ services:
profiles:
- oodikone
volumes:
- frontend-nodemod:/usr/src/app/node_modules
- ./services/frontend/:/usr/src/app
- frontend-nodemod:/opt/app-root/src/node_modules
- ./services/frontend/:/opt/app-root/src
kone-db:
<<: *defaults
@@ -108,7 +110,7 @@ services:
sis-updater-scheduler:
<<: *defaults
build:
<<: *build_defaults
target: development
context: updater/sis-updater-scheduler
container_name: sis-updater-scheduler
depends_on:
@@ -137,7 +139,7 @@ services:
sis-updater-worker:
<<: *defaults
build:
<<: *build_defaults
target: development
context: updater/sis-updater-worker
depends_on:
- redis
+16 -18
View File
@@ -1,23 +1,21 @@
FROM node:14.17-alpine3.13 as base
FROM registry.access.redhat.com/ubi8/nodejs-14-minimal
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
WORKDIR /usr/src/app
EXPOSE 8080
FROM base as development
ENV NODE_ENV=development
COPY ./package* /usr/src/app/
RUN npm ci
COPY . /usr/src/app/
CMD ["npm", "run", "dev"]
FROM base as production
ENV NODE_ENV=production
ARG SENTRY_ENVIRONMENT
ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
COPY --chown=node:node ./package* /usr/src/app/
RUN npm ci --production && apk add --no-cache vim curl nano
COPY --chown=node:node . /usr/src/app/
USER node
ARG BASE_PATH
ENV PUBLIC_URL=$BASE_PATH
ARG STAGING
ENV REACT_APP_STAGING=$STAGING
COPY ./package* ./
RUN npm ci --omit-dev
COPY . .
CMD ["npm", "start"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:14.17-alpine3.13
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 8080
ENV NODE_ENV=development
COPY ./package* ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
+5 -3
View File
@@ -1,7 +1,7 @@
// Node env to use
const isStaging = process.env.NODE_ENV === 'staging'
const isDev = process.env.NODE_ENV === 'development'
const isProduction = process.env.NODE_ENV === 'production'
const isStaging = process.env.REACT_APP_STAGING === 'true'
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
// Sentry
const sentryRelease = process.env.SENTRY_RELEASE || ''
@@ -17,9 +17,10 @@ const requiredGroup = isStaging
const pateToken = process.env.PATE_API_TOKEN || ''
// Jami
const jamiUrl = isDev || runningInCI ? 'http://jami:3003/' : 'https://importer.cs.helsinki.fi/api/auth'
const jamiUrl = process.env.JAMI_URL || ''
// Importer client
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
// Networking: Urls & ports
@@ -86,5 +87,6 @@ module.exports = {
baseUrl,
frontUrl,
backendPort,
importerUrl,
importerToken,
}
@@ -28,7 +28,7 @@ const currentUserMiddleware = async (req, _res, next) => {
}
const iamGroups = parseIamGroups(hygroupcn)
const { access = {}, specialGroup = {} } = await getOrganizationAccess({ iamGroups })
const { access = {}, specialGroup = {} } = await getOrganizationAccess({ id: sisId, iamGroups })
const iamRights = Object.keys(access)
if (!hasRequiredIamGroup(iamGroups, iamRights)) {
+7 -1
View File
@@ -239,7 +239,13 @@ router.get('/v3/populationstatistics', async (req, res) => {
res.json(filterPersonalTags(result, user.id))
} else {
const result = await Population.optimizedStatisticsOf({ ...req.query, studyRights })
// If two studyprogrammes are given, query does not work just right
// This solution is temporal
const result = await Population.optimizedStatisticsOf({
...req.query,
studyRights: { programme: studyRights.programme },
})
if (result.error) {
Sentry.captureException(new Error(result.error))
res.status(400).end()
@@ -525,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()
@@ -103,7 +103,9 @@ const getYearsArray = (since, isAcademicYear, yearsCombined) => {
const years = []
const allYears = 'Total'
if (yearsCombined) years.push(allYears)
for (let i = since; i <= new Date().getFullYear(); i++) {
const today = new Date()
const until = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
for (let i = since; i <= until; i++) {
const year = isAcademicYear ? `${i} - ${i + 1}` : i
years.push(year)
}
+2 -2
View File
@@ -1,12 +1,12 @@
const axios = require('axios')
const { importerToken } = require('../conf-backend')
const { importerUrl, importerToken } = require('../conf-backend')
const logger = require('./logger')
const importerClient = axios.create({
headers: {
token: importerToken,
},
baseURL: 'https://importer.cs.helsinki.fi/api/importer',
baseURL: importerUrl,
})
const getImporterClient = () => {
+2 -2
View File
@@ -1,10 +1,10 @@
const Sentry = require('@sentry/node')
const Tracing = require('@sentry/tracing')
const { sentryRelease, sentryEnvironment, runningInCI } = require('../conf-backend')
const { sentryRelease, sentryEnvironment, runningInCI, isStaging } = require('../conf-backend')
const initializeSentry = app => {
if (!sentryRelease || !sentryEnvironment || runningInCI) return
if (!sentryRelease || !sentryEnvironment || runningInCI || isStaging) return
Sentry.init({
dsn: 'https://020b79f0cbb14aad94cc9d69a1ea9d52@sentry.cs.helsinki.fi/2',
+20 -19
View File
@@ -1,25 +1,26 @@
FROM node:14.17-alpine3.13 as base
FROM registry.access.redhat.com/ubi8/nodejs-14-minimal
WORKDIR /usr/src/app
ENV TZ="Europe/Helsinki"
FROM base as development
COPY ./package* /usr/src/app/
RUN npm ci
COPY . /usr/src/app/
EXPOSE 3000
CMD ["npm", "start"]
WORKDIR /opt/app-root/src
EXPOSE 5000
ENV NODE_ENV=production
ARG BASE_PATH
ENV PUBLIC_URL=$BASE_PATH
FROM base as production
ARG SENTRY_ENVIRONMENT
ENV REACT_APP_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
ARG SENTRY_RELEASE
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
ARG BUILT_AT
ENV REACT_APP_BUILT_AT=$BUILT_AT
COPY --chown=node:node ./package* /usr/src/app/
RUN npm ci --production
COPY --chown=node:node . /usr/src/app/
RUN npm run build && npm install -g serve@12.0.0 && chown -R node:node /usr/src/app/build
USER node
EXPOSE 5000
ARG STAGING
ENV REACT_APP_STAGING=$STAGING
COPY ./package* ./
RUN npm ci --omit-dev
COPY . .
RUN npm run build && npm install -g serve@12.0.0
CMD ["serve", "--single", "--listen", "5000", "build"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:14.17-alpine3.13
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
EXPOSE 3000
COPY ./package* ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
@@ -17,6 +17,7 @@ export default {
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics” puolelle ja näkee valitun opiskelijan opintotiedot.
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
@@ -25,8 +26,10 @@ export default {
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Opintopisteet sisältävät opiskelijoiden **kaikki opintopisteet**, eli siis myös ennen valitun tutkinnon alkamista suoritetut opinnot.
Huom! Nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
@@ -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()]
@@ -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 = [
@@ -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}
@@ -156,7 +156,7 @@ const ProgressTable = ({ criteria, students, months, programme, studyGuidanceGro
const renderSemester = ({ enrollmenttype }) => {
const { className } = enrolmentTypes[enrollmenttype]
return <div className={`enrollment-label ${className}`}> </div>
return <div className={`enrollment-label-no-margin ${className}`}> </div>
}
const createContent = (labels, year, start, end, enrollStatusIdx) => {
@@ -599,9 +599,10 @@ const ProgressTable = ({ criteria, students, months, programme, studyGuidanceGro
<Icon fitted name="minus" color="grey" />: Student has enrolled, but has not received any grade from the
course.
<br />
<span className="enrollment-label label-present" />: Student has an active semester enrollment. <br />
<span className="enrollment-label label-absent" />: Student has enrolled as absent. <br />
<span className="enrollment-label label-passive" />: Student has no semester enrollment.
<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>
@@ -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={
@@ -38,6 +38,16 @@
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: #03cc0d;
}
@@ -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
@@ -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" />