Compare commits

...
31 Commits
Author SHA1 Message Date
Valtteri Kantanen cfcdaf6f10 [Study programme overview] Fix graduation times graph 2024-02-18 19:50:29 +02:00
ShootingStar91 4162717401 [Programme overview] Make body overflow-x auto: temporary fix for credit stats going out of reach on small screens despite flex wrap 2024-02-18 19:04:41 +02:00
arttu f9a10d233a Fix credits produced tests 2024-02-18 18:12:53 +02:00
arttu 4474fd861b [Programme overview] Reverse credit table years order 2024-02-18 18:12:53 +02:00
Valtteri Kantanen 2acbb5c0b6 [Population statistics] Add retries to a flaky test 2024-02-18 18:10:50 +02:00
Valtteri Kantanen f235d216b1 Upgrade two frontend dependencies to newer patch versions 2024-02-18 18:10:27 +02:00
Valtteri Kantanen 5ee30f163a Fix path for sourcemaps 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 73eea50003 Change Vite build sourcemap option to 'hidden' 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 8bdff4fde9 Fix ESLint errors 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 37ed2d312e Remove separate ESLint config file for backend 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 09dcc22cd4 Add recommended extensions 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 6d5354d006 Fix lint-staged errors on CSS files and remove CSS files that aren't used 2024-02-18 17:55:04 +02:00
Valtteri Kantanen d5103f65df Use prettier to also check CSS files and update lint-staged rules accordingly 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 343757b1c3 Fix prettier errors 2024-02-18 17:55:04 +02:00
Valtteri Kantanen f8f0ddb3aa Update prettier configuration after major update 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 063c426206 Update dependencies (only minor/patch bumps for regular dependencies, also major versions for cypress and linting packages) 2024-02-18 17:55:04 +02:00
Valtteri Kantanen 649bab46bd Eliminate single-use frontend libraries 2024-02-18 17:55:04 +02:00
Valtteri Kantanen e72ced9f8a [Updater] Use fetch to send slack notification instead of axios 2024-02-18 17:55:04 +02:00
arttu 7cd766b1a7 Fix eslint: remove unused rule ignore 2024-02-18 17:32:34 +02:00
ShootingStar91 a13851095d [Programme overview] Credits: Use rapo categories 2024-02-18 17:24:00 +02:00
ShootingStar91 bbaae51a2e [Frontpage] Make code better 2024-02-18 17:24:00 +02:00
Valtteri Kantanen 5381766631 [Updater] Don't try to add enrollments without a studentnumber to the database 2024-02-16 16:34:24 +02:00
Valtteri Kantanen 6498e5a120 Revert "Remove dev dependencies after building for production has finished" 2024-02-16 15:59:31 +02:00
Valtteri Kantanen beadf34d49 Add reading permissions to package.json and package-lock.json in Dockerfile 2024-02-16 15:43:11 +02:00
Valtteri Kantanen e6b368d4ff Remove dev dependencies after building for production has finished 2024-02-16 15:15:30 +02:00
Valtteri Kantanen 46b91f16d3 [Course statistics] Fix tests 2024-02-16 15:11:35 +02:00
Valtteri Kantanen fd3783f93d Run vite directly instead of npm in Dockerfile 2024-02-16 15:00:41 +02:00
Riku Rauhala 44523d2ea1 [Course statistics] Move the direction toggle into the settings bar 2024-02-16 14:15:55 +02:00
Riku Rauhala 1f70c29f75 [Course statistics] Simplify provider organization toggle logic 2024-02-16 14:15:55 +02:00
Riku Rauhala d333b84923 [Course statistics] Create a more unified settings bar for the tables 2024-02-16 14:15:55 +02:00
Valtteri Kantanen 309d666ac8 [Study programme overview] Fix graduation times chart data 2024-02-16 14:03:32 +02:00
70 changed files with 2663 additions and 2527 deletions
+9 -5
View File
@@ -1,15 +1,19 @@
{
"env": {
"es6": true,
"node": true,
"jest": true
"commonjs": true,
"es2021": true,
"node": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "commonjs"
},
"extends": ["eslint:recommended", "plugin:prettier/recommended", "plugin:cypress/recommended"],
"plugins": ["prettier", "cypress"],
"rules": {
"no-console": "error",
"no-async-promise-executor": "off",
"prettier/prettier": "error",
"cypress/no-assigning-return-values": "off"
"cypress/no-assigning-return-values": "off",
"cypress/unsafe-to-chain-command": "off"
}
}
+2 -2
View File
@@ -105,8 +105,8 @@ jobs:
with:
environment: production
set_commits: 'skip'
sourcemaps: './build/static/js'
url_prefix: '~/static/js'
sourcemaps: './build/assets'
url_prefix: '~/assets'
version: ${{ steps.extract_version.outputs.VERSION }}
report_success:
+1 -1
View File
@@ -5,7 +5,7 @@ const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file
module.exports = {
'{services,updater}/**/*.{js,jsx}': files => `eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
'*.{js,json,md,yml,yaml,html}': files => `prettier --write ${files.join(' ')}`,
'*.{js,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
+3 -2
View File
@@ -5,5 +5,6 @@
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false
}
"jsxSingleQuote": false,
"trailingComma": "es5"
}
+2 -18
View File
@@ -1,22 +1,6 @@
{
"extends": ["stylelint-config-standard", "stylelint-prettier/recommended"],
"extends": ["stylelint-config-standard"],
"rules": {
"selector-type-no-unknown": [
true,
{
"ignoreTypes": ["/^--/", "--"]
}
],
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
],
"prettier/prettier": true,
"selector-class-pattern": null,
"custom-property-pattern": null,
"value-no-vendor-prefix": null,
"property-no-vendor-prefix": null
"selector-class-pattern": "^[a-z][a-zA-Z-]*$"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"aaron-bond.better-comments",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"github.vscode-github-actions",
"stylelint.vscode-stylelint"
]
}
+6 -5
View File
@@ -227,7 +227,8 @@ describe('Course Statistics tests', () => {
cy.contains('Search for courses').should('not.exist')
cy.contains('TKT10004, 581328, AYTKT10004, A581328 Tietokantojen perusteet')
cy.get('[data-cy=unify_radio_regular]').click()
cy.cs('providerCheckboxUniversity').should('have.class', 'checked').click()
cy.cs('providerCheckboxOpenUni').should('have.class', 'checked').click()
cy.contains('TKT10004, 581328 Tietokantojen perusteet')
})
@@ -280,7 +281,7 @@ describe('Course Statistics tests', () => {
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
cy.contains(/^TKT20003, 582219$/).click()
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
cy.get('[data-cy=unify_radio_regular]').click()
cy.cs('providerCheckboxOpenUni').click()
cy.get('tbody > :nth-child(3) > :nth-child(2) .level').click()
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
cy.contains('Language distribution').click()
@@ -465,12 +466,12 @@ describe('Course Statistics tests', () => {
})
})
it('If no data available, unify toggle is disabled', () => {
it('If no data available, toggle is disabled', () => {
cy.get("input[placeholder='Search by a course code']").type('AYCSM14111')
cy.contains('AYCSM14111').click()
cy.contains('AYCSM14111 Avoin yo: Full Stack -websovelluskehitys: React Native')
cy.get('[data-cy=unify_radio_regular]').find('input').should('be.disabled')
cy.get('[data-cy=unify_radio_unify]').find('input').should('not.be.disabled')
cy.cs('providerCheckboxUniversity').find('input').should('be.disabled')
cy.cs('providerCheckboxOpenUni').find('input').should('not.be.disabled')
})
it('Has right to see all the students, because course provider is TKT', () => {
+1 -1
View File
@@ -112,7 +112,7 @@ describe('Population Statistics tests', () => {
// This test sometimes fails on headless mode. It seems that the click on the
// 'Fetch class with new settings' button doesn't always trigger history.push()
// so the page doesn't reload. This is why waiting also doesn't help.
it('Advanced settings work', () => {
it('Advanced settings work', { retries: 2 }, () => {
cy.visit(pathToCSBach2017)
cy.get('[data-cy=advanced-toggle]').click()
// only spring
+17 -26
View File
@@ -55,15 +55,11 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Total, Major credits, Non-major credits, Non-degree credits, Transferred credits]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2021, 0, 0, 0, 0, 0],
[2020, 4928, 4736, 25, 0, 167],
[2019, 7737, 7266, 99, 23, 349],
[2018, 6730, 6289, 84, 85, 272],
[2017, 2900, 2452, 33, 27, 388],
[2020, 4511, 4511, 0, 0, 167],
[2019, 7117, 7097, 0, 0, 349],
[2018, 5747, 5742, 0, 0, 272],
[2017, 1910, 1910, 0, 0, 388],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
@@ -101,12 +97,10 @@ describe('Studyprogramme overview', () => {
const creditTableContents = [
// [Year, Total, Major students credits, Transferred credits]
...years.map(year => [year, 0, 0, 0]),
[2021, 0, 0, 0],
[2020, 4224, 3954, 167],
[2019, 5812, 5357, 349],
[2018, 4639, 4302, 272],
[2017, 1968, 1580, 388],
[2020, 4511, 4511, 0, 0, 167],
[2019, 7117, 7097, 0, 0, 349],
[2018, 5747, 5742, 0, 0, 272],
[2017, 1910, 1910, 0, 0, 388],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
@@ -141,13 +135,11 @@ describe('Studyprogramme overview', () => {
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
const creditTableContents = [
// [Year, Total, Major students credits, Non-major students credits, Non-degree credits, Transferred credits]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0, 0],
['2020 - 2021', 412, 342, 10, 0, 60],
['2019 - 2020', 7546, 7319, 34, 0, 193],
['2018 - 2019', 8866, 8380, 115, 43, 328],
['2017 - 2018', 5220, 4702, 82, 92, 344],
// Credit table doesnt show (later) years without data
['2020 - 2021', 352, 352, 0, 0, 60],
['2019 - 2020', 6966, 6966, 0, 0, 193],
['2018 - 2019', 7797, 7777, 0, 0, 328],
['2017 - 2018', 4170, 4165, 0, 0, 344],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
@@ -161,11 +153,10 @@ describe('Studyprogramme overview', () => {
.should('contain', 'Transferred away')
cy.get('[data-cy=Graph-CreditsProducedByTheStudyprogramme')
.should('contain', 'Major students credits')
.should('contain', 'Non-major students credits')
.should('contain', 'Transferred credits')
.should('contain', 7266)
.should('contain', 6289)
.should('contain', 'Degree students')
.should('contain', 'Transferred')
.should('contain', 388)
.should('contain', 7097)
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
.should('contain', 'Graduated students')
+1 -1
View File
@@ -13,7 +13,7 @@
// https://on.cypress.io/configuration
// ***********************************************************
import './commands'
require('./commands')
// This error is benign and seems to be only present in Cypress
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
+1818 -1198
View File
File diff suppressed because it is too large Load Diff
+12 -14
View File
@@ -32,24 +32,22 @@
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"concurrently": "^7.4.0",
"cypress": "^12.17.4",
"eslint": "^8.25.0",
"cypress": "^13.6.4",
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.9",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"node-actionlint": "^1.2.2",
"prettier": "^2.5.1",
"stylelint": "^14.13.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^26.0.0",
"stylelint-prettier": "^2.0.0"
"prettier": "3.2.5",
"stylelint": "^16.2.1",
"stylelint-config-standard": "^36.0.0"
},
"dependencies": {
"lodash": "^4.17.21",
-22
View File
@@ -1,22 +0,0 @@
module.exports = {
env: {
commonjs: true,
es2021: true,
node: true,
},
extends: 'eslint:recommended',
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parserOptions: {
ecmaVersion: 'latest',
},
}
+52 -66
View File
@@ -285,17 +285,17 @@
}
},
"node_modules/@types/debug": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
"integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
"version": "0.7.34",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
},
"node_modules/@types/node": {
"version": "18.6.2",
@@ -303,9 +303,9 @@
"integrity": "sha512-KcfkBq9H4PI6Vpu5B/KoPeuVDAbmi+2mDBqGPGUgoL7yXQtcWGu2vJWmmRkneWK3Rh0nIAX192Aa87AqKHYChQ=="
},
"node_modules/@types/validator": {
"version": "13.7.4",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.4.tgz",
"integrity": "sha512-uAaSWegu2lymY18l+s5nmcXu3sFeeTOl1zhSGoYzcr6T3wz1M+3OcW4UjfPhIhHGd13tIMRDsEpR+d8w/MexwQ=="
"version": "13.11.9",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.9.tgz",
"integrity": "sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw=="
},
"node_modules/abbrev": {
"version": "1.1.1",
@@ -705,9 +705,9 @@
}
},
"node_modules/dottie": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.2.tgz",
"integrity": "sha512-fmrwR04lsniq/uSr8yikThDTrM7epXHBAAjH9TbeH3rEA8tdCO7mRzB9hdmdGyJCxF8KERo9CITcm3kGuoyMhg=="
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
"integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA=="
},
"node_modules/ee-first": {
"version": "1.1.1",
@@ -842,9 +842,9 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"node_modules/follow-redirects": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
@@ -901,27 +901,13 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/gelf-pro": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.3.tgz",
"integrity": "sha512-gohbipAj/okE7pUgRSkIMEiqAS2M3q5YWnoejUDA9qwBeqbJr8LSwe3E7YW2QFK7hi4tZje1ZnKQe0296zdW/w==",
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.11.tgz",
"integrity": "sha512-y6DOxU40U4Sd+ECSLtMMtOTig1gEmnIHnvfocyvH+okc6LSfd7ADirT1tbzpAMzOXIgDG/2j9psZ1ck3HXfjEA==",
"dependencies": {
"async": "2.6.2",
"lodash": "4.17.14"
"lodash": "~4.17.21"
}
},
"node_modules/gelf-pro/node_modules/async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz",
"integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==",
"dependencies": {
"lodash": "^4.17.11"
}
},
"node_modules/gelf-pro/node_modules/lodash": {
"version": "4.17.14",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz",
"integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw=="
},
"node_modules/get-intrinsic": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
@@ -1081,9 +1067,9 @@
"dev": true
},
"node_modules/inflection": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.2.tgz",
"integrity": "sha512-cmZlljCRTBFouT8UzMzrGcVEvkv6D/wBdcdKG7J1QH5cXjtU75Dm+P27v9EKu/Y43UYyCJd1WC4zLebRrC8NBw==",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
"integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==",
"engines": [
"node >= 0.4.0"
]
@@ -1344,11 +1330,11 @@
}
},
"node_modules/moment-timezone": {
"version": "0.5.37",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.37.tgz",
"integrity": "sha512-uEDzDNFhfaywRl+vwXxffjjq1q0Vzr+fcQpQ1bU0kbzorfS7zVtZnCnGc8mhWmF39d4g4YriF6kwA75mJKE/Zg==",
"version": "0.5.45",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz",
"integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==",
"dependencies": {
"moment": ">= 2.9.0"
"moment": "^2.29.4"
},
"engines": {
"node": "*"
@@ -1386,9 +1372,9 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/msgpackr": {
"version": "1.9.9",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.9.tgz",
"integrity": "sha512-sbn6mioS2w0lq1O6PpGtsv6Gy8roWM+o3o4Sqjd6DudrL/nOugY+KyJUimoWzHnf9OkO0T6broHFnYE/R05t9A==",
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.1.tgz",
"integrity": "sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
@@ -1613,9 +1599,9 @@
}
},
"node_modules/pg-connection-string": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
@@ -1841,9 +1827,9 @@
}
},
"node_modules/retry-as-promised": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-6.1.0.tgz",
"integrity": "sha512-Hj/jY+wFC+SB9SDlIIFWiGOHnNG0swYbGYsOj2BJ8u2HKUaobNKab0OIC0zOLYzDy0mb7A4xA5BMo4LMz5YtEA=="
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz",
"integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
@@ -1936,9 +1922,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/sequelize": {
"version": "6.24.0",
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.24.0.tgz",
"integrity": "sha512-mPo7Q7gWkrsstjR2aw8ahkrj8RUJCozz3kedAz2B5ZIdLoQEH1z2tvaeJONI8R6RqeuyRQosx9Yn3s9yQyo6lQ==",
"version": "6.37.0",
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.0.tgz",
"integrity": "sha512-MS6j6aXqWzB3fe9FhmfpQMgVC16bBdYroJCqIqR0l9M2ko8pZdKoi/0PiNWgMyFQDXUHxXyAOG3K07CbnOhteQ==",
"funding": [
{
"type": "opencollective",
@@ -1946,21 +1932,21 @@
}
],
"dependencies": {
"@types/debug": "^4.1.7",
"@types/validator": "^13.7.1",
"debug": "^4.3.3",
"dottie": "^2.0.2",
"inflection": "^1.13.2",
"@types/debug": "^4.1.8",
"@types/validator": "^13.7.17",
"debug": "^4.3.4",
"dottie": "^2.0.6",
"inflection": "^1.13.4",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"pg-connection-string": "^2.5.0",
"retry-as-promised": "^6.1.0",
"semver": "^7.3.5",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"pg-connection-string": "^2.6.1",
"retry-as-promised": "^7.0.4",
"semver": "^7.5.4",
"sequelize-pool": "^7.1.0",
"toposort-class": "^1.0.1",
"uuid": "^8.3.2",
"validator": "^13.7.0",
"validator": "^13.9.0",
"wkx": "^0.5.0"
},
"engines": {
@@ -2259,9 +2245,9 @@
}
},
"node_modules/validator": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==",
"version": "13.11.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz",
"integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==",
"engines": {
"node": ">= 0.10"
}
+5
View File
@@ -32,5 +32,10 @@
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.3.4"
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-console */
const { getCreditStatsForRapodiff } = require('../services/studyprogramme/rapoCredits')
const { getCreditStatsForRapodiff } = require('../services/studyprogramme/studyprogrammeCredits')
const { parseCsv } = require('./helpers')
const _ = require('lodash')
+9 -23
View File
@@ -1,6 +1,6 @@
const router = require('express').Router()
const { getBasicStatsForStudytrack } = require('../services/studyprogramme/studyprogrammeBasics')
const { getCreditStatsForStudytrack } = require('../services/studyprogramme/studyprogrammeCredits')
const { getProgrammeCreditStats } = require('../services/studyprogramme/studyprogrammeCredits')
const { getGraduationStatsForStudytrack } = require('../services/studyprogramme/studyprogrammeGraduations')
const {
getStudyprogrammeCoursesForStudytrack,
@@ -10,8 +10,6 @@ const { getStudytrackStatsForStudyprogramme } = require('../services/studyprogra
const {
getBasicStats,
setBasicStats,
getCreditStats,
setCreditStats,
getGraduationStats,
setGraduationStats,
getStudytrackStats,
@@ -56,26 +54,14 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
return res.json(updated)
})
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
const code = req.params.id
const yearType = req.query?.year_type
const specialGroups = req.query?.special_groups
const combinedProgramme = req.query?.combined_programme
if (!code) return res.status(422).end()
const data = await getCreditStats(code, combinedProgramme, yearType, specialGroups)
if (data) return res.json(data)
const updatedStats = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
combinedProgramme,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
if (updatedStats) await setCreditStats(updatedStats, yearType, specialGroups)
return res.json(updatedStats)
router.get('/v2/studyprogrammes/creditstats', async (req, res) => {
const { codes: codesListString, isAcademicYear, includeSpecials } = req.query
const codes = JSON.parse(codesListString)
const stats = {}
for (const code of codes) {
stats[code] = await getProgrammeCreditStats(code, isAcademicYear !== 'false', includeSpecials)
}
return res.json({ stats })
})
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
@@ -33,18 +33,21 @@ const setBasicStats = async (data, yearType, specialGroups) => {
return dataToRedis
}
const getCreditStats = async (id, combinedProgramme, yearType, specialGroups) => {
const getCreditStats = async (id, isAcademicYear, specialGroups) => {
if (!isUpdatedNewProgramme(id)) return null
let searchkey = combinedProgramme ? `${id}-${combinedProgramme}` : id
const redisKey = createRedisKeyForCreditStats(searchkey, yearType, specialGroups)
const redisKey = createRedisKeyForCreditStats(id, isAcademicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR', specialGroups)
const dataFromRedis = await redisClient.getAsync(redisKey)
if (!dataFromRedis) return null
return JSON.parse(dataFromRedis)
}
const setCreditStats = async (data, yearType, specialGroups) => {
const setCreditStats = async (data, isAcademicYear, specialGroups) => {
const { id } = data
const redisKey = createRedisKeyForCreditStats(id, yearType, specialGroups)
const redisKey = createRedisKeyForCreditStats(
id,
isAcademicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR',
specialGroups ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
)
const dataToRedis = {
...data,
status: 'DONE',
@@ -136,9 +136,8 @@ const getFacultyCreditStatsForStudytrack = async ({
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, facultyProgrammes, facultyCode }
const { majors, facultyNonMajors, otherNonMajors, facultyOthers, nonDegree } = await getFacultyRegularCreditStats(
queryParameters
)
const { majors, facultyNonMajors, otherNonMajors, facultyOthers, nonDegree } =
await getFacultyRegularCreditStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const dataFound = [majors, facultyNonMajors, otherNonMajors].some(d => d.graphStats.length) // , transferred
@@ -1,131 +0,0 @@
const { mapToProviders } = require('../../util/utils')
const { defineYear, getCorrectStartDate } = require('./studyprogrammeHelpers')
const { getCourseCodesForStudyProgramme, allTransfers } = require('.')
const { getCreditsForStudyProgramme, getTransferredCredits } = require('./creditGetters')
const { getStudyRights } = require('./studyrightFinders')
/**
* Rapo-kategoriat 9.2.2024, joiden perusteella tämä koodi on tehty. Numerot täsmäävät vain 2022 alkaen, koska sisu/oodi ero.
'basic'
Perustutkinto-opiskelijat: niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai
ylempi korkeakoulututkinto.
'incoming-exchange'
Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia.
Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
'agreement'
Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen
yhteistyösopimukseen perustuvalla opiskeluoikeudella.
'open-uni'
Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on
"avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
'separate'
Erillisoikeus: opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai
"opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
'transferred'
Muut hyväksiluetut opintopisteet: kotimaassa suoritetut, hyväksiluetut opintopisteet.
*/
const getCategory = (extent, hadDegreeStudyright) => {
if ([1, 2, 3, 4].includes(extent) || hadDegreeStudyright) return 'basic' // Rapo-kategoria: Perustutkinto-opiskelijat
if (extent === 7 || extent === 34) return 'incoming-exchange' // Saapuvat vaihto-opiskelijat
if (extent === 9 && !hadDegreeStudyright) return 'open-uni' // Avoimen opiskeluoikeus, ei tutkinto-opiskelija
if (extent === 14) return 'agreement' // Korkeakoulujen väliset yhteistyöopinnot
if (extent === 99) return 'separate' // Erillisoikeus
return 'other'
}
/*
If credit didn't include studyright (pre-sisu times mostly), try to find out relevant
other way. First check for same faculty as credit, but if not found, take first
without (both have to be active when course was completed)
*/
const findRelevantStudyright = (attainmentDate, studyrights) => {
return studyrights.find(studyright => {
const startDate = getCorrectStartDate(studyright)
if (!studyright.graduated) return new Date(attainmentDate) >= new Date(startDate)
return (
new Date(studyright.startdate).getTime() <= new Date(attainmentDate).getTime() &&
new Date(attainmentDate).getTime() <= new Date(studyright.enddate).getTime()
)
})
}
// At the given date, student had SOME degree-studyright.
const isDegreeStudent = (studyrights, date) =>
studyrights?.some(sr => {
const rightExtentCode = [1, 2, 3, 4].includes(sr.extentcode)
const rightDates =
new Date(sr.startdate).getTime() <= new Date(date).getTime() &&
new Date(date).getTime() <= new Date(sr.enddate).getTime()
// it is possible to also check if student had active semester enrollment to the studyright here.
// However, it is unsure if rapo requires this (seems like maybe not). shouldn't make a huge difference.
return rightExtentCode && rightDates
})
/* includes all specials, is calendar year, since 2017-01-01 */
const getCreditStatsForRapodiff = async (programmeCode, isAcademicYear, specialIncluded = true) => {
const since = new Date('2017-01-01')
const providercode = mapToProviders([programmeCode])[0]
const courses = await getCourseCodesForStudyProgramme(providercode)
const credits = await getCreditsForStudyProgramme(providercode, courses, since)
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
const transfers = (await allTransfers(programmeCode, since)).map(t => t.studyrightid)
let studyrights = await getStudyRights(students)
if (!specialIncluded) {
studyrights = studyrights.filter(s => !transfers.includes(s.studyrightid))
}
const studyrightIdToStudyrightMap = studyrights.reduce((obj, cur) => {
obj[cur.actual_studyrightid] = cur
return obj
}, {})
const studentNumberToStudyrightsMap = studyrights.reduce((obj, cur) => {
if (!obj[cur.studentNumber]) obj[cur.studentNumber] = []
obj[cur.studentNumber].push(cur)
return obj
}, {})
const stats = {}
credits.forEach(({ attainmentDate, studentNumber, credits, studyrightId }) => {
const relevantStudyright = studyrightId
? studyrightIdToStudyrightMap[studyrightId]
: findRelevantStudyright(attainmentDate, studentNumberToStudyrightsMap[studentNumber])
if (!relevantStudyright) return
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
const hadDegreeStudyright = isDegreeStudent(studentNumberToStudyrightsMap[studentNumber], attainmentDate)
const category = getCategory(relevantStudyright.extentcode, hadDegreeStudyright)
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
stats[attainmentYear].total += credits
if (!stats[attainmentYear][category]) {
stats[attainmentYear][category] = 0
}
stats[attainmentYear][category] += credits
})
const transferredCredits = await getTransferredCredits(providercode, since)
transferredCredits.forEach(({ attainment_date, credits }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
stats[attainmentYear].transferred += credits || 0
// Transferred not counted in total
})
return stats
}
module.exports = {
getCreditStatsForRapodiff,
}
@@ -47,7 +47,6 @@ const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear,
includeAllSpecials,
includeTransferredTo: includeAllSpecials,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
studyrights.forEach(({ enddate, studyrightElements }) => {
// Check that the study right element ending to graduation belong to study programme
@@ -1,189 +1,141 @@
const { indexOf } = require('lodash')
const { mapToProviders } = require('../../util/utils')
const {
getStatsBasis,
defineYear,
isMajorStudentCredit,
isNonMajorCredit,
getYearsArray,
getStartDate,
isSpecialGroupCredit,
tableTitles,
} = require('./studyprogrammeHelpers')
const { allTransfers, getCourseCodesForStudyProgramme } = require('.')
const { getTransferredCredits, getCreditsForStudyProgramme } = require('./creditGetters')
const { defineYear, getCorrectStartDate } = require('./studyprogrammeHelpers')
const { getCourseCodesForStudyProgramme, allTransfers } = require('.')
const { getCreditsForStudyProgramme, getTransferredCredits } = require('./creditGetters')
const { getStudyRights } = require('./studyrightFinders')
const { getCreditStats, setCreditStats } = require('../analyticsService')
const createGraphStats = (majors, nonMajors, nonDegree, transferred) => {
return [
{
name: 'Major students credits',
data: majors.graphStats,
},
{
name: 'Non-major students credits',
data: nonMajors.graphStats,
},
{
name: 'Non-degree credits',
data: nonDegree.graphStats,
},
{
name: 'Transferred credits',
data: transferred.graphStats,
},
]
/**
* Rapo-kategoriat 9.2.2024, joiden perusteella tämä koodi on tehty. Numerot täsmäävät vain 2022 alkaen, koska sisu/oodi ero.
'basic'
Perustutkinto-opiskelijat: niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai
ylempi korkeakoulututkinto.
'incoming-exchange'
Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia.
Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
'agreement'
Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen
yhteistyösopimukseen perustuvalla opiskeluoikeudella.
'open-uni'
Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on
"avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
'separate'
Erillisoikeus: opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai
"opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
'transferred'
Muut hyväksiluetut opintopisteet: kotimaassa suoritetut, hyväksiluetut opintopisteet.
*/
const getCategory = (extent, hadDegreeStudyright) => {
if ([1, 2, 3, 4].includes(extent) || hadDegreeStudyright) return 'basic' // Rapo-kategoria: Perustutkinto-opiskelijat
if (extent === 7 || extent === 34) return 'incoming-exchange' // Saapuvat vaihto-opiskelijat
if (extent === 9 && !hadDegreeStudyright) return 'open-uni' // Avoimen opiskeluoikeus, ei tutkinto-opiskelija
if (extent === 14) return 'agreement' // Korkeakoulujen väliset yhteistyöopinnot
if (extent === 99) return 'separate' // Erillisoikeus
return 'other'
}
const createTableStats = (reversedYears, includeAllSpecials, majors, nonMajors, transferred, nonDegree) => {
const tableStats = reversedYears.map(year =>
includeAllSpecials
? [
year,
majors.tableStats[year] +
nonMajors.tableStats[year] +
transferred.tableStats[year] +
nonDegree.tableStats[year],
majors.tableStats[year],
nonMajors.tableStats[year],
nonDegree.tableStats[year],
transferred.tableStats[year],
]
: [
year,
majors.tableStats[year] +
nonMajors.tableStats[year] +
transferred.tableStats[year] +
nonDegree.tableStats[year],
majors.tableStats[year],
transferred.tableStats[year],
]
)
return tableStats
/*
If credit didn't include studyright (pre-sisu times mostly), try to find out relevant
other way. First check for same faculty as credit, but if not found, take first
without (both have to be active when course was completed)
*/
const findRelevantStudyright = (attainmentDate, studyrights) => {
return studyrights.find(studyright => {
const startDate = getCorrectStartDate(studyright)
if (!studyright.graduated) return new Date(attainmentDate) >= new Date(startDate)
return (
new Date(studyright.startdate).getTime() <= new Date(attainmentDate).getTime() &&
new Date(attainmentDate).getTime() <= new Date(studyright.enddate).getTime()
)
})
}
// Fetches all the credits for the studyprogramme and divides them into major-students and non-major students credits
// Division is done on the basis that whether the student had a primary studyright to the programme on the attainmentDate
// If special groups are excluded, the transfer students are filtered away from the major-students as well
const getRegularCreditStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
let majors = getStatsBasis(years)
let nonMajors = getStatsBasis(years)
let nonDegree = getStatsBasis(years)
if (!studyprogramme) {
return { majors, nonMajors, nonDegree }
}
const providercode = mapToProviders([studyprogramme])[0]
// At the given date, student had SOME degree-studyright.
const isDegreeStudent = (studyrights, date) =>
studyrights?.some(sr => {
const rightExtentCode = [1, 2, 3, 4].includes(sr.extentcode)
const rightDates =
new Date(sr.startdate).getTime() <= new Date(date).getTime() &&
new Date(date).getTime() <= new Date(sr.enddate).getTime()
// it is possible to also check if student had active semester enrollment to the studyright here.
// However, it is unsure if rapo requires this (seems like maybe not). shouldn't make a huge difference.
return rightExtentCode && rightDates
})
const getProgrammeCreditStats = async (programmeCode, isAcademicYear, specialIncluded = true) => {
let data = await getCreditStats(programmeCode, isAcademicYear, specialIncluded)
if (data) return data
data = await computeProgrammeCreditStats(programmeCode, isAcademicYear, specialIncluded)
await setCreditStats(data, isAcademicYear, specialIncluded)
return data
}
/* includes all specials, is calendar year, since 2017-01-01 */
const computeProgrammeCreditStats = async (programmeCode, isAcademicYear, specialIncluded = true) => {
const since = new Date('2017-01-01')
const providercode = mapToProviders([programmeCode])[0]
const courses = await getCourseCodesForStudyProgramme(providercode)
const credits = await getCreditsForStudyProgramme(providercode, courses, since)
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
const transfers = (await allTransfers(programmeCode, since)).map(t => t.studyrightid)
let studyrights = await getStudyRights(students)
const transfers = (await allTransfers(studyprogramme, since)).map(t => t.studyrightid)
if (!includeAllSpecials) {
if (!specialIncluded) {
studyrights = studyrights.filter(s => !transfers.includes(s.studyrightid))
}
const studyrightIdToStudyrightMap = studyrights.reduce((obj, cur) => {
obj[cur.actual_studyrightid] = cur
return obj
}, {})
const studentNumberToStudyrightsMap = studyrights.reduce((obj, cur) => {
if (!obj[cur.studentNumber]) obj[cur.studentNumber] = []
obj[cur.studentNumber].push(cur)
return obj
}, {})
credits.forEach(({ studentNumber, attainmentDate, credits }) => {
const studentStudyrights = studentNumberToStudyrightsMap[studentNumber] || []
const stats = {}
credits.forEach(({ attainmentDate, studentNumber, credits, studyrightId }) => {
const relevantStudyright = studyrightId
? studyrightIdToStudyrightMap[studyrightId]
: findRelevantStudyright(attainmentDate, studentNumberToStudyrightsMap[studentNumber])
if (!relevantStudyright) return
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
if (!includeAllSpecials && isSpecialGroupCredit(studentStudyrights, attainmentDate, transfers)) {
return
}
if (isMajorStudentCredit(studentStudyrights, attainmentDate, studyprogramme)) {
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
majors.tableStats[attainmentYear] += credits || 0
} else if (isNonMajorCredit(studentStudyrights, attainmentDate, studyprogramme)) {
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
nonMajors.tableStats[attainmentYear] += credits || 0
} else {
nonDegree.graphStats[indexOf(years, attainmentYear)] += credits || 0
nonDegree.tableStats[attainmentYear] += credits || 0
const hadDegreeStudyright = isDegreeStudent(studentNumberToStudyrightsMap[studentNumber], attainmentDate)
const category = getCategory(relevantStudyright.extentcode, hadDegreeStudyright)
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
stats[attainmentYear].total += credits
if (!stats[attainmentYear][category]) {
stats[attainmentYear][category] = 0
}
stats[attainmentYear][category] += credits
})
return { majors, nonMajors, nonDegree }
}
const transferredCredits = await getTransferredCredits(providercode, since)
// Fetches all the credits with the type "transferred / hyväksiluettu" and divides them by year
const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcademicYear }) => {
const providercode = mapToProviders([studyprogramme])[0]
const credits = await getTransferredCredits(providercode, since)
const { graphStats, tableStats } = getStatsBasis(years)
credits.forEach(({ attainment_date, credits }) => {
transferredCredits.forEach(({ attainment_date, credits }) => {
const attainmentYear = defineYear(attainment_date, isAcademicYear)
graphStats[indexOf(years, attainmentYear)] += credits || 0
tableStats[attainmentYear] += credits || 0
if (!stats[attainmentYear]) stats[attainmentYear] = { transferred: 0 }
stats[attainmentYear].transferred += credits || 0
// Transferred not counted in total
})
return { graphStats, tableStats }
}
// Fetches all credits for the studytrack and combines them into the statistics for the table
// and graph in the studyprogramme overview
const getCreditStatsForStudytrack = async ({ studyprogramme, combinedProgramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
const queryParametersSecondProg = {
studyprogramme: combinedProgramme,
since,
years,
isAcademicYear,
includeAllSpecials,
}
const { majors, nonMajors, nonDegree } = await getRegularCreditStats(queryParameters)
const transferred = await getTransferredCreditStats(queryParameters)
const {
majors: majorsSecondProg,
nonMajors: nonMajorsSecondProg,
nonDegree: nonDegreeSecondProg,
} = await getRegularCreditStats(queryParametersSecondProg)
const transferredSecondProg = await getTransferredCreditStats(queryParametersSecondProg)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const dataFound = [majors, nonMajors, transferred].some(d => d.graphStats.length)
if (!dataFound) return null
const dataFoundSecondProg = [majorsSecondProg, nonMajorsSecondProg, transferredSecondProg].some(
d => d.graphStats.length
)
return {
id: combinedProgramme ? `${studyprogramme}-${combinedProgramme}` : studyprogramme,
years,
tableStats: createTableStats(reversedYears, includeAllSpecials, majors, nonMajors, transferred, nonDegree),
graphStats: createGraphStats(majors, nonMajors, nonDegree, transferred),
tableStatsSecondProg: dataFoundSecondProg
? createTableStats(
reversedYears,
includeAllSpecials,
majorsSecondProg,
nonMajorsSecondProg,
transferredSecondProg,
nonDegreeSecondProg
)
: [],
graphStatsSecondProg: dataFoundSecondProg
? createGraphStats(majorsSecondProg, nonMajorsSecondProg, nonDegreeSecondProg, transferredSecondProg)
: [],
titles,
}
return { stats, id: programmeCode }
}
module.exports = {
getCreditStatsForStudytrack,
getProgrammeCreditStats,
computeProgrammeCreditStats,
}
@@ -39,7 +39,6 @@ const checkStartdate = async (id, startdate) => {
const addGraduation = async (studyright, isAcademicYear, amounts, times) => {
const startdate = await checkStartdate(studyright.studyrightid, studyright.startdate)
const graduationYear = defineYear(studyright.enddate, isAcademicYear)
const totalTimeToGraduation = moment(studyright.enddate).diff(moment(startdate), 'months')
const statutoryAbsences = await getStatutoryAbsences(studyright.studentNumber, startdate, studyright.enddate)
const timeToGraduation = totalTimeToGraduation - statutoryAbsences
@@ -101,13 +100,13 @@ const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, in
}
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
let graduationAmounts = getYearsObject({ years })
let graduationTimes = getYearsObject({ years, emptyArrays: true })
const graduationAmounts = getYearsObject({ years })
const graduationTimes = getYearsObject({ years, emptyArrays: true })
if (!studyprogramme) return { times: { medians: [], goal: 0 }, doCombo: false, comboTimes: { medians: [], goal: 0 } }
// for bc+ms combo
const doCombo = studyprogramme.startsWith('MH') && !['MH30_001', 'MH30_003'].includes(studyprogramme)
let graduationAmountsCombo = getYearsObject({ years })
let graduationTimesCombo = getYearsObject({ years, emptyArrays: true })
const graduationAmountsCombo = getYearsObject({ years })
const graduationTimesCombo = getYearsObject({ years, emptyArrays: true })
const studentnumbers = await getCorrectStudentnumbers({
codes: [studyprogramme],
@@ -116,11 +115,13 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
includeAllSpecials,
includeTransferredTo: includeAllSpecials,
})
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbers)
// for masters, separate bc+ms combo from just ms students (except some medical progs)
for (const right of studyrights) {
const elements = right.studyrightElements.filter(
element => new Date(element.enddate).toDateString() === new Date(right.enddate).toDateString()
)
if (elements.length === 0) continue
if (doCombo && right.studyrightid.slice(-2) === '-2') {
await addGraduation(right, isAcademicYear, graduationAmountsCombo, graduationTimesCombo)
} else {
@@ -161,11 +162,11 @@ const getTransferStudyrightMap = async (studyprogramme, since) => {
const formatStats = (stats, years) => {
const tableStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => [p.code, getId(p.code), p.name['fi'], ...years.map(year => p[year])])
.map(p => [p.code, getId(p.code), p.name.fi, ...years.map(year => p[year])])
const graphStats = Object.values(stats)
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
.map(p => ({ name: p.name['fi'], code: p.code, data: years.map(year => p[year]) }))
.map(p => ({ name: p.name.fi, code: p.code, data: years.map(year => p[year]) }))
return { tableStats, graphStats }
}
@@ -176,10 +177,12 @@ const getProgrammesBeforeStarting = async ({ studyprogramme, since, years, isAca
// Get studyrights for bachelor studyprogrammes. This excludes studytracks.
const programmes = await getAllProgrammes()
let studyrightsBeforeMasters = await previousStudyrights(programmes, allStudents)
const studyrightsBeforeMasters = await previousStudyrights(programmes, allStudents)
const studyprogrammeCodes = uniqBy(studyrightsBeforeMasters, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
studyprogrammeCodes.forEach(c => {
stats[c.code] = { ...c, ...getYearsObject({ years }) }
})
// Map transfers to the Master's programme
const transfers = await getTransferStudyrightMap(studyprogramme, since)
@@ -214,10 +217,12 @@ const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAc
// Get studyrights for masters studyprogramme. This excludes studytracks.
const programmes = await getAllProgrammes()
let studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
const studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
const stats = {}
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
studyprogrammeCodes.forEach(c => {
stats[c.code] = { ...c, ...getYearsObject({ years }) }
})
const transfers = await getTransferStudyrightMap(studyprogramme, since)
@@ -1,9 +1,9 @@
const { getBasicStatsForStudytrack } = require('./studyprogrammeBasics')
const { getCreditStatsForStudytrack } = require('./studyprogrammeCredits')
const { getGraduationStatsForStudytrack } = require('./studyprogrammeGraduations')
const { getStudytrackStatsForStudyprogramme } = require('./studytrackStats')
const { setBasicStats, setCreditStats, setGraduationStats, setStudytrackStats } = require('../analyticsService')
const logger = require('../../util/logger')
const { computeProgrammeCreditStats } = require('./studyprogrammeCredits')
const updateBasicView = async (code, combinedProgramme) => {
const specialCalendar = {
@@ -37,15 +37,16 @@ const updateBasicView = async (code, combinedProgramme) => {
})
await setBasicStats(basicStats, option.yearType, option.specialGroups)
const creditStats = await getCreditStatsForStudytrack({
studyprogramme: code,
combinedProgramme,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setCreditStats(creditStats, option.yearType, option.specialGroups)
const creditStats = await computeProgrammeCreditStats(
code,
option.yearType === 'ACADEMIC_YEAR',
option.specialGroups === 'SPECIAL_INCLUDED'
)
await setCreditStats(
creditStats,
option.yearType === 'ACADEMIC_YEAR',
option.specialGroups === 'SPECIAL_INCLUDED'
)
const graduationStats = await getGraduationStatsForStudytrack({
studyprogramme: code,
+2 -1
View File
@@ -39,7 +39,8 @@
"quotes": ["error", "single", { "avoidEscape": true }],
"react-hooks/rules-of-hooks": "error",
"no-console": "error",
"no-param-reassign": ["error", { "props": false }]
"no-param-reassign": ["error", { "props": false }],
"no-continue": "off"
},
"plugins": ["react", "react-hooks"],
"settings": {
+1 -1
View File
@@ -10,4 +10,4 @@ COPY ./package* ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
CMD ["node_modules/.bin/vite"]
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
+15 -123
View File
@@ -11,7 +11,6 @@
"@sentry/tracing": "^6.18.2",
"axios": "^0.21.4",
"chroma-js": "^2.4.2",
"formik": "^2.2.9",
"highcharts": "^9.3.3",
"highcharts-custom-events": "^3.0.10",
"immer": "^9.0.12",
@@ -20,18 +19,16 @@
"prop-types": "^15.8.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-datetime": "^3.1.1",
"react-datetime": "^3.2.0",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.3",
"react-range": "^1.8.12",
"react-redux": "^7.2.6",
"react-reverse-portal": "^2.1.0",
"react-router-dom": "^5.3.0",
"react-scroll-to-component": "^1.0.2",
"scheduler": "^0.23.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.2",
"semantic-ui-react": "^2.1.5",
"unfuck-spa-shibboleth-session": "^3.1.4",
"use-context-selector": "^1.3.9",
"uuid": "^3.4.0",
@@ -1149,40 +1146,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/component-clone": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/component-clone/-/component-clone-0.2.2.tgz",
"integrity": "sha512-MHxnjmZLxTCVtmaHQ1LdjO5Hxx4d9akIzqGA8BDAGKGKkoB8Vf+oLEl9cwKkYln6BdfGwoXMU6xXGFnp1s/Eow==",
"dependencies": {
"component-type": "*"
}
},
"node_modules/component-emitter": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.0.tgz",
"integrity": "sha512-U8EviusIm8Fc5vMbs9opNX8r/hAz8PFYOu003AR1OVkCnDSTaBHB8inMn97yIbkGlI+dcdsItTBjgiZkVVzxYg=="
},
"node_modules/component-raf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/component-raf/-/component-raf-1.2.0.tgz",
"integrity": "sha512-MF1EHzsI2u04n8HGUW36E4bCzC0VqkJsRdbEcGU2hf5+EcnPLZckKS5F5V4E+XfPWjrBAP3Qp1TG79TbVGyd0A=="
},
"node_modules/component-tween": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/component-tween/-/component-tween-1.2.0.tgz",
"integrity": "sha512-fQCubCyoosovh5VgnnnBsHnq6E0zjDpbAvW48t6glHIFWteowyP7cicPnzj9i3kuaFxI8P+zsSNao+26mQFG4A==",
"dependencies": {
"component-clone": "0.2.2",
"component-emitter": "1.2.0",
"component-type": "1.1.0",
"ease-component": "1.0.0"
}
},
"node_modules/component-type": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/component-type/-/component-type-1.1.0.tgz",
"integrity": "sha512-rLfU88D3tytip79EqPsLD+ND+Fl8HsIhz5KkOEeNidNVN1GUO9AWEs8+DPLtZR63BecAC883DuF2GZ1DM78BbA=="
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1216,26 +1179,13 @@
}
},
"node_modules/decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ease-component": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ease-component/-/ease-component-1.0.0.tgz",
"integrity": "sha512-9Ouvcal2rQpKVw3uwVbs4BKyiQyA4LZD9VOjb+qor3jUPKEl/1hn60oKW6NEVj/Fw924skDsKXyCasPX4rRH5Q=="
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
@@ -1301,9 +1251,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
@@ -1319,29 +1269,6 @@
}
}
},
"node_modules/formik": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
"integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
"funding": [
{
"type": "individual",
"url": "https://opencollective.com/formik"
}
],
"dependencies": {
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^1.10.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -1764,15 +1691,15 @@
}
},
"node_modules/react-datetime": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-3.1.1.tgz",
"integrity": "sha512-gHCTjAniCcMb6jdXpz+MpVe/uCeaHNDOofg+l41nLlJI3uBLBMV40CQbGB2TCTUpCzGT1mCs4vQzKGMjXO/WWQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-3.2.0.tgz",
"integrity": "sha512-w5XdeNIGzBht9CadaZIJhKUhEcDTgH0XokKxGPCxeeJRYL7B3HIKA8CM6Q0xej2JFJt0n5d+zi3maMwaY3262A==",
"dependencies": {
"prop-types": "^15.5.7"
},
"peerDependencies": {
"moment": "^2.16.0",
"react": "^16.5.0 || ^17.0.0"
"react": "^16.5.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
@@ -1798,11 +1725,6 @@
"object-assign": "^4.1.1"
}
},
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
"node_modules/react-highcharts": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/react-highcharts/-/react-highcharts-16.1.0.tgz",
@@ -1923,15 +1845,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/react-reverse-portal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.1.1.tgz",
"integrity": "sha512-FzuVLYEigKPB0NuMNLWymCgVp+P1h1MY57fQxhmY22idzz6El1rsXK5+bQ+wXvEa0smUtqTDcpM77epnXDV9wg==",
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-router": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.3.tgz",
@@ -1982,18 +1895,6 @@
"isarray": "0.0.1"
}
},
"node_modules/react-scroll-to-component": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/react-scroll-to-component/-/react-scroll-to-component-1.0.2.tgz",
"integrity": "sha512-KPvTGta2E5hVnjt5aB37EIPep36ya95Juw0tC9Dc14Czu5C37qWIUYkjS+7ni0IQcEhq+0FncHtwtPxeZZQ5Bg==",
"dependencies": {
"scroll-to": "0.0.2"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0 || ^16.0.0",
"react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/redux": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz",
@@ -2089,15 +1990,6 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/scroll-to": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/scroll-to/-/scroll-to-0.0.2.tgz",
"integrity": "sha512-Jej5WQ8RvLFNQwCvJtiPfJ0wECCed5KdLnGPi1Cbd+djxcpwP1iInwhGAmzT87LTQcwxqLGd64zCX+1e0MmlLA==",
"dependencies": {
"component-raf": "1.2.0",
"component-tween": "1.2.0"
}
},
"node_modules/semantic-ui-css": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/semantic-ui-css/-/semantic-ui-css-2.5.0.tgz",
@@ -2107,9 +1999,9 @@
}
},
"node_modules/semantic-ui-react": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.3.tgz",
"integrity": "sha512-dd2pqalMk0ycJoHf7hn4mYd0xohg0NMk7ohlpPVOigCfMLrKk2e3NZdRk0yBetvD+qJXPqZ04jA43bDwqOM5OA==",
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz",
"integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==",
"dependencies": {
"@babel/runtime": "^7.10.5",
"@fluentui/react-component-event-listener": "~0.63.0",
+2 -5
View File
@@ -12,7 +12,6 @@
"@sentry/tracing": "^6.18.2",
"axios": "^0.21.4",
"chroma-js": "^2.4.2",
"formik": "^2.2.9",
"highcharts": "^9.3.3",
"highcharts-custom-events": "^3.0.10",
"immer": "^9.0.12",
@@ -21,18 +20,16 @@
"prop-types": "^15.8.1",
"query-string": "^6.14.1",
"react": "^16.14.0",
"react-datetime": "^3.1.1",
"react-datetime": "^3.2.0",
"react-dom": "^16.14.0",
"react-highcharts": "^16.1.0",
"react-markdown": "^6.0.3",
"react-range": "^1.8.12",
"react-redux": "^7.2.6",
"react-reverse-portal": "^2.1.0",
"react-router-dom": "^5.3.0",
"react-scroll-to-component": "^1.0.2",
"scheduler": "^0.23.0",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.2",
"semantic-ui-react": "^2.1.5",
"unfuck-spa-shibboleth-session": "^3.1.4",
"use-context-selector": "^1.3.9",
"uuid": "^3.4.0",
@@ -15,6 +15,7 @@ export const studyProgrammeToolTips = {
`,
StudentsOfTheStudyprogramme: `
**Started studying**: Sisältää kyseisenä vuonna aloittaneet opiskelijat (aloitetut opinto-oikeudet). Opiskelija on myös voinut myöhemmin keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opiskelijat (opinto-oikeudet). Sama opiskelija voi esiintyä tilastoissa useana vuonna, mikäli hän on siirtynyt ohjelmaan ja siitä pois useasti.
@@ -24,16 +25,28 @@ export const studyProgrammeToolTips = {
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
`,
CreditsProducedByTheStudyprogramme: `
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan.
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan. Kategoriat vastaavat Rapon kategorioita. Pistemäärät ovat osin epätarkkoja ennen vuotta 2022 sisu-oodi-erojen vuoksi.
**Major students' credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan. Sisältää myös ohjelmaan siirtyneet ensisijaiset opiskelijat.
Total-kategoriaan ei lasketa mukaan hyväksiluettuja opintopisteitä.
**Non-major students' credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan. Sisältää
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ä.
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia.
Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
**Transferred**: Hyväksiluetut opintopisteet
Lisäksi painamalla "Show special categories" -valintaa, saat näkyviin myös harvinaisemmat kategoriat:
**Special**:
Opintosuoritukset, joiden suorituksen luokittelu on "erillisellä opiskeluoikeudella" tai "opettajankoulutuksen erillisellä opiskeluikeudella" suoritettu. Ei sisällä kansainvälisten vaihto-opiskelijoiden suorituksia.
Ulkomailta hyväksiluetut opintopisteet: ulkomailta hyväksilutetut opintopisteet.
**Other university**:
Korkeakoulujen väliset yhteistyöopinnot: opintosuoritukset, jotka on tehty korkeakoulujen väliseen yhteistyösopimukseen perustuvalla opiskeluoikeudella.
**Non-degree students**: Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
**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. **Luku ei sisälly total-sarakkeeseen**.
`,
StudentsOfProgrammeCourses: `
Sisältää kurssien opiskelijat.
@@ -1,7 +1,3 @@
.subRow {
background-color: var(--lightSilver);
}
.courseNameCell {
font-weight: 600;
}
@@ -8,7 +8,7 @@ export const AttemptsPane = ({ availableStats, datasets, separate, userHasAccess
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'ATTEMPTS', separate }}
initialSettings={{ viewMode: 'ATTEMPTS', separate, splitDirection: 'row' }}
settingsComponent={AttemptsTableSettings}
tableComponent={AttemptsTable}
userHasAccessToAllStats={userHasAccessToAllStats}
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { GradeDistributionChart } from './charts/GradeDistributionChart'
import { DirectionToggle } from './settings/common/DirectionToggle'
import { GradeDistributionChartSettings } from './settings/GradeDistributionChartSettings'
import { DirectionToggle } from './common/DirectionToggle'
import { PaneContent } from './common/PaneContent'
export const GradeDistributionPane = ({ datasets, userHasAccessToAllStats }) => {
@@ -8,7 +8,7 @@ export const StudentsPane = ({ availableStats, datasets, separate, userHasAccess
<Pane
availableStats={availableStats}
datasets={datasets}
initialSettings={{ viewMode: 'STUDENTS', separate, showDetails: false }}
initialSettings={{ viewMode: 'STUDENTS', separate, showDetails: false, splitDirection: 'row' }}
settingsComponent={StudentsTableSettings}
tableComponent={StudentsTable}
userHasAccessToAllStats={userHasAccessToAllStats}
@@ -1,7 +1,6 @@
import React, { useState } from 'react'
import { PassRateChart } from '../charts/PassRateChart'
import { PassRateChartSettings } from '../settings/PassRateChartSettings'
import { DirectionToggle } from './DirectionToggle'
import { PaneContent } from './PaneContent'
/**
@@ -21,28 +20,31 @@ export const Pane = ({
updateQuery,
}) => {
const [settings, setSettings] = useState(initialSettings)
const [splitDirection, setSplitDirection] = useState('row')
const setSplitDirection = splitDirection => {
setSettings({ ...settings, splitDirection })
}
const toggleSeparate = separate => {
setSettings({ ...settings, separate })
updateQuery(separate)
}
const halfWidth = datasets.filter(dataset => dataset).length > 1 && splitDirection === 'row'
const styleContainer = { display: 'flex', flexDirection: splitDirection, justifyContent: 'space-between' }
const halfWidth = datasets.filter(dataset => dataset).length > 1 && settings.splitDirection === 'row'
const styleContainer = { display: 'flex', flexDirection: settings.splitDirection, justifyContent: 'space-between' }
const styleData = { flexGrow: 1, flexBasis: 1, maxWidth: halfWidth ? '49%' : '100%' }
return (
<PaneContent>
<div style={{ display: 'flex', marginBottom: '2em' }}>
<div style={{ marginBottom: '1em' }}>
<Settings
availableStats={availableStats}
datasets={datasets}
onChange={setSettings}
onSeparateChange={toggleSeparate}
setSplitDirection={setSplitDirection}
value={settings}
/>
<div style={{ flexGrow: 1 }} />
<DirectionToggle datasets={datasets} setSplitDirection={setSplitDirection} splitDirection={splitDirection} />
</div>
<div style={styleContainer}>
{datasets
@@ -1,37 +1,53 @@
import React from 'react'
import { Menu, Radio } from 'semantic-ui-react'
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { DirectionToggle } from './common/DirectionToggle'
import { HelpButton } from './common/HelpButton'
import { UnifyRadioButtons } from './common/UnifyRadioButtons'
import { ProviderOrganization } from './common/ProviderOrganization'
export const AttemptsTableSettings = ({ value, onChange, availableStats, onSeparateChange }) => {
const { showGrades, separate } = value
export const AttemptsTableSettings = ({
availableStats,
datasets,
onChange,
onSeparateChange,
setSplitDirection,
value,
}) => {
const { showGrades, separate, splitDirection } = value
const elements = [
<Radio
checked={showGrades}
data-cy="gradeToggle"
label="Show grades"
key="gradeToggle"
onChange={() => onChange({ ...value, showGrades: !showGrades })}
toggle
/>,
<Radio
checked={separate}
data-cy="separateToggle"
label="Separate by semesters"
key="separateToggle"
onChange={() => onSeparateChange(!separate)}
toggle
/>,
<ProviderOrganization availableStats={availableStats} key="providerOrganization" />,
]
if (datasets.filter(dataset => dataset).length > 1) {
elements.push(<DirectionToggle setSplitDirection={setSplitDirection} splitDirection={splitDirection} />)
}
return (
<div>
<Menu style={{ flexWrap: 'wrap' }} secondary>
<Menu.Item>
<Radio
toggle
label="Show grades"
data-cy="gradeToggle"
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Separate by semesters"
data-cy="separateToggle"
checked={separate}
onChange={() => onSeparateChange(!separate)}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode="ATTEMPTS" />
</Menu.Item>
</Menu>
<UnifyRadioButtons availableStats={availableStats} />
<div style={{ alignItems: 'center', display: 'flex' }}>
<SegmentGroup horizontal>
{elements.map(element => (
<Segment key={element.key} style={{ alignItems: 'center', display: 'flex' }}>
{element}
</Segment>
))}
</SegmentGroup>
<HelpButton tab="Tables" viewMode="ATTEMPTS" />
</div>
)
}
@@ -1,46 +1,61 @@
import React from 'react'
import { Menu, Radio } from 'semantic-ui-react'
import { Radio, Segment, SegmentGroup } from 'semantic-ui-react'
import { DirectionToggle } from './common/DirectionToggle'
import { HelpButton } from './common/HelpButton'
import { UnifyRadioButtons } from './common/UnifyRadioButtons'
import { ProviderOrganization } from './common/ProviderOrganization'
export const StudentsTableSettings = ({ availableStats, onChange, onSeparateChange, value }) => {
const { showDetails, showGrades, separate } = value
export const StudentsTableSettings = ({
availableStats,
datasets,
onChange,
onSeparateChange,
setSplitDirection,
value,
}) => {
const { showDetails, showGrades, separate, splitDirection } = value
const elements = [
<Radio
checked={showDetails}
data-cy="detailToggle"
label="Show details"
key="detailToggle"
onChange={() => onChange({ ...value, showDetails: !showDetails })}
toggle
/>,
<Radio
checked={showGrades}
data-cy="gradeToggle"
label="Show grades"
key="gradeToggle"
onChange={() => onChange({ ...value, showGrades: !showGrades })}
toggle
/>,
<Radio
checked={separate}
data-cy="separateToggle"
label="Separate by semesters"
key="separateToggle"
onChange={() => onSeparateChange(!separate)}
toggle
/>,
<ProviderOrganization availableStats={availableStats} key="providerOrganization" />,
]
if (datasets.filter(dataset => dataset).length > 1) {
elements.push(<DirectionToggle setSplitDirection={setSplitDirection} splitDirection={splitDirection} />)
}
return (
<div>
<Menu style={{ flexWrap: 'wrap' }} secondary>
<Menu.Item>
<Radio
toggle
label="Show details"
data-cy="detailToggle"
checked={showDetails}
onChange={() => onChange({ ...value, showDetails: !showDetails })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Show grades"
data-cy="gradeToggle"
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Separate by semesters"
data-cy="separateToggle"
checked={separate}
onChange={() => onSeparateChange(!separate)}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode="STUDENTS" />
</Menu.Item>
</Menu>
<UnifyRadioButtons availableStats={availableStats} />
<div style={{ alignItems: 'center', display: 'flex' }}>
<SegmentGroup horizontal>
{elements.map(element => (
<Segment key={element.key} style={{ alignItems: 'center', display: 'flex' }}>
{element}
</Segment>
))}
</SegmentGroup>
<HelpButton tab="Tables" viewMode="STUDENTS" />
</div>
)
}
@@ -1,12 +1,10 @@
import React from 'react'
import { Icon, Menu } from 'semantic-ui-react'
export const DirectionToggle = ({ datasets, setSplitDirection, splitDirection }) => {
if (datasets.filter(dataset => dataset).length <= 1) return null
export const DirectionToggle = ({ setSplitDirection, splitDirection }) => {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1em' }}>
<label>Split direction: </label>
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'column', gap: '1em' }}>
<label>Split direction</label>
<Menu style={{ margin: 0 }}>
<Menu.Item active={splitDirection === 'row'} onClick={() => setSplitDirection('row')}>
<Icon name="arrows alternate horizontal" />
@@ -1,6 +1,6 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { Popup, Button, Icon } from 'semantic-ui-react'
import { Button, Icon, Popup } from 'semantic-ui-react'
import { courseStatisticsToolTips } from 'common/InfoToolTips'
export const HelpButton = ({ tab, viewMode }) => {
@@ -17,7 +17,7 @@ export const HelpButton = ({ tab, viewMode }) => {
on="click"
wide="very"
trigger={
<Button icon labelPosition="left">
<Button icon labelPosition="left" style={{ height: '50px', marginLeft: '30px' }}>
<Icon name="question circle outline" />
Help
</Button>
@@ -0,0 +1,56 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Checkbox, Segment, SegmentGroup } from 'semantic-ui-react'
import { toggleOpenAndRegularCourses } from 'redux/coursesearch'
export const ProviderOrganization = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const toggleValue = (_event, { value }) => {
const options = {
regularStats: {
regularStats: 'openStats',
openStats: 'unifyStats',
unifyStats: 'openStats',
},
openStats: {
openStats: 'regularStats',
regularStats: 'unifyStats',
unifyStats: 'regularStats',
},
}
return dispatch(toggleOpenAndRegularCourses(options[value][openOrRegular]))
}
return (
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
<label style={{ marginBottom: '10px' }}>Provider organization(s)</label>
<SegmentGroup horizontal style={{ margin: '0' }}>
<Segment>
<Checkbox
checked={openOrRegular === 'regularStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxUniversity"
disabled={!availableStats.university}
label={availableStats.university ? 'University' : 'University (no data)'}
name="radioGroup"
onChange={toggleValue}
value="regularStats"
/>
</Segment>
<Segment>
<Checkbox
checked={openOrRegular === 'openStats' || openOrRegular === 'unifyStats'}
data-cy="providerCheckboxOpenUni"
disabled={!availableStats.open}
label={availableStats.open ? 'Open uni' : 'Open uni (no data)'}
name="radioGroup"
onChange={toggleValue}
value="openStats"
/>
</Segment>
</SegmentGroup>
</div>
)
}
@@ -1,58 +0,0 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Form, Menu, Radio } from 'semantic-ui-react'
import { toggleOpenAndRegularCourses } from 'redux/coursesearch'
export const UnifyRadioButtons = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
const toggleUnifyRadioValue = (event, { value }) => {
dispatch(toggleOpenAndRegularCourses(value))
}
return (
<Menu secondary style={{ marginLeft: '0.5rem' }}>
<Form>
<Form.Group>
<Form.Field>
<b>Provider organization:</b>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.university ? 'university' : 'university (no data)'}
name="radioGroup"
value="regularStats"
checked={openOrRegular === 'regularStats'}
disabled={!availableStats.university}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_regular"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.open ? 'open' : 'open (no data)'}
name="radioGroup"
value="openStats"
checked={openOrRegular === 'openStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.open}
data-cy="unify_radio_open"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.unify ? 'both' : 'both (no data)'}
name="radioGroup"
value="unifyStats"
checked={openOrRegular === 'unifyStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.unify}
data-cy="unify_radio_unify"
/>
</Form.Field>
</Form.Group>
</Form>
</Menu>
)
}
@@ -1,4 +1,4 @@
@media only screen and (min-width: 3240px) {
@media only screen and (width >= 3240px) {
.programmes-overview,
.faculty-overview,
.faculty-courses {
@@ -6,7 +6,7 @@
}
}
@media only screen and (min-width: 3240px) {
@media only screen and (width >= 3240px) {
.programmes-overview .section-container,
.faculty-overview .section-container,
.faculty-courses .section-container {
@@ -155,7 +155,6 @@
display: grid;
grid-template-rows: repeat(4, 30%);
grid-template-columns: repeat(3, 30%);
row-gap: 0;
column-gap: 8px;
gap: 0 8px;
grid-auto-flow: column;
}
@@ -1,8 +1,7 @@
import React, { useContext } from 'react'
import { Segment, Header, Button } from 'semantic-ui-react'
import './filterTray.css'
import { FilterViewContext } from '../FilterViewContext'
import { FilterCard } from '../filters/common/FilterCard'
import { FilterViewContext } from './FilterViewContext'
import { FilterCard } from './filters/common/FilterCard'
export const FilterTray = () => {
const {
@@ -1,210 +0,0 @@
/** #filter-tray is used repeatedly as a selector to achieve higher specificity
over semantic-ui without using !important everywhere. */
.filter-tray-toggle {
transform: rotate(-90deg);
position: fixed;
left: 0;
top: 100%;
transform-origin: top left;
}
.filter-tray-toggle .button {
width: 100vh;
border-radius: 0;
font-size: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 5rem;
padding-right: 5rem;
}
.filter-tray-toggle .button .button-label {
display: flex;
align-items: center;
letter-spacing: 0.06rem;
}
.filter-tray-toggle .button .button-label .icon {
margin-right: 0.6em;
}
.filter-tray-toggle .button .label {
margin-left: 1rem;
}
.filter-tray-toggle .button .button-label > .label {
padding: 0.5em 0.7em 0.3em;
margin-top: -2px;
margin-bottom: -2px;
}
#filter-ribbon-container {
position: fixed;
top: 19px;
right: 253px;
}
#filter-ribbon {
transform: rotate(90deg);
font-size: 10px;
padding-right: 18px;
}
.filter-tray-toggle.inline-toggle button {
background-color: #2d2e2e !important;
}
.filter-tray-toggle.inline-toggle button:hover {
background-color: #202121 !important;
}
#filter-tray {
background-color: #1b1c1d;
height: 100vh;
align-content: flex-start;
justify-content: center;
margin: 0 0 0 3rem;
overflow-y: scroll;
overflow-x: hidden;
padding: 1.5rem 0 10rem;
}
#filter-tray-container > .header {
display: flex;
justify-content: space-between;
min-height: 64px;
padding: 1.1rem 1rem 1rem 1.7rem;
border-bottom: 1px solid #a0a0a0;
align-items: center;
margin-bottom: 0 !important;
margin-top: 63px;
margin-left: 3rem;
width: auto;
color: white;
order: -2;
}
#filter-tray .comparator-input > .label {
padding-top: 0;
padding-bottom: 0;
}
#filter-tray .comparator-input > .label > .dropdown {
text-align: center;
font-size: 1.1rem;
}
#filter-tray > .header > .content {
display: flex;
align-items: center;
}
#filter-tray > .header > .content > .label {
margin-left: 1.5rem;
padding-top: 0.7em;
}
#filter-tray .segmentContainer {
margin-left: 35px;
width: auto;
}
#filter-tray .card {
background-color: rgb(255 255 255 / 8%);
border: none;
box-shadow: none;
margin-top: 0;
}
#filter-tray .card .card-header-text {
flex-grow: 1;
}
#filter-tray > .card > .content > .header {
color: white;
margin: 0;
padding: 0.8rem;
cursor: pointer;
display: flex;
}
#filter-tray .card.active-filter {
order: -1;
}
#filter-tray.cards .content {
padding: 0;
}
#filter-tray.cards .description,
#filter-tray.cards .extra {
padding: 0 0.8rem 0.8rem;
margin: 0;
}
#filter-tray .card .extra {
margin: 0 0 0 auto;
padding-top: 0.8rem;
}
#filter-tray .checkbox {
margin-left: 0.8rem;
}
#filter-tray label {
color: #fff;
font-size: 0.9rem;
}
#filter-tray .card .labeled.input .label {
min-width: 76px;
display: flex;
align-items: center;
}
#filter-close-button {
background: #1b1c1d;
}
#filter-tray .description-text {
color: #9e9e9e;
font-style: italic;
margin: 1rem 0.2rem 0;
}
#filter-tray .card-content {
margin-top: 0.8rem;
}
#filter-tray .total-credits-filter .description {
margin-left: -4px;
}
#filter-tray .total-credits-filter .form {
position: relative;
left: 3px;
}
#filter-tray .button.toggle.active {
background-color: #2085d0 !important;
}
#filter-tray .flex-centered {
display: flex;
justify-content: center;
}
#filter-tray .or::before {
color: #fff;
background-color: #2e2e2f;
font-size: 0.8rem;
}
#filter-tray .filter-option-count {
color: #ababab;
margin-left: 0.6rem;
font-size: 0.8rem;
font-style: italic;
}
@@ -1,44 +0,0 @@
#filter-tray .courses-filter .description {
padding-top: 0;
margin-top: 0;
}
#filter-tray .course-card .header {
color: #fff;
padding: 0.7rem 0.5rem;
padding-bottom: 0;
font-size: 0.95rem;
display: flex;
justify-content: space-between;
align-items: center;
}
#filter-tray .course-card .header > .button {
margin: 0 0 0 0.5rem;
align-self: flex-start;
}
#filter-tray .course-card .description {
display: flex;
align-items: baseline;
color: #fff;
font-size: 0.9rem;
padding: 0.7rem 0.5rem;
}
#filter-tray .course-card .description > .dropdown {
margin-left: 0.8rem;
}
#filter-tray .course-filter-selection {
margin-bottom: 2rem;
}
.hidden-focusable {
height: 0 !important;
width: 0 !important;
border: none !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
}
@@ -5,7 +5,6 @@ import { Dropdown } from 'semantic-ui-react'
import { CourseCard } from './CourseCard'
import { FilterType } from './filterType'
import './courseFilter.css'
import { createFilter } from '../createFilter'
import { useLanguage } from '../../../LanguagePicker/useLanguage'
@@ -5,21 +5,63 @@ import { images, checkUserAccess } from '../../common'
import { useTitle } from '../../common/hooks'
import { Changelog } from './Changelog'
const FrontPageItem = ({ title, content }) => {
return (
<>
<Header as="h3">{title}</Header>
{content}
</>
)
}
export const FrontPage = () => {
const { rights, roles } = useGetAuthorizedUserQuery()
const { rights, roles, iamRights } = useGetAuthorizedUserQuery()
const [showFullChangelog, setShowFullChangelog] = useState(false)
useTitle()
const showItems = {
populations: roles.includes('admin') || rights.length > 0,
studyProgramme: roles.includes('admin') || rights.length > 0,
students: checkUserAccess(['studyGuidanceGroups', 'admin'], roles) || rights.length > 0,
courseStatistics: checkUserAccess(['courseStatistics', 'admin'], roles) || rights.length > 0,
teachers: checkUserAccess(['teachers', 'admin'], roles),
university: true,
feedback: true,
}
const items = [
{
show: true,
title: 'University',
content: <p>View tables and diagrams about study progress of different faculties</p>,
},
{
show: roles.includes('admin') || rights.length > 0 || iamRights.length > 0,
title: 'Programmes',
content: (
<List bulleted>
<List.Item>
<i>Class statistics:</i> View details of a specific year of a study programme
</List.Item>
<List.Item>
<i>Overview:</i> View statistics of a programme across all years
</List.Item>
</List>
),
},
{
show: checkUserAccess(['courseStatistics', 'admin'], roles) || rights.length > 0,
title: 'Courses',
content: <p>View statistics about course attempts, completions and grades</p>,
},
{
show: checkUserAccess(['studyGuidanceGroups', 'admin'], roles) || rights.length > 0,
title: 'Students',
content: <p>View detailed information for a given student</p>,
},
{
show: true,
title: 'Feedback',
content: (
<p>
For questions and suggestions, please use the{' '}
<a href="https://oodikone.helsinki.fi/feedback">feedback form</a> or shoot an email to{' '}
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a>.
</p>
),
},
]
return (
<div>
@@ -50,45 +92,15 @@ export const FrontPage = () => {
<Grid columns="two" style={{ marginTop: '40px' }} divided>
<Grid.Row>
<Grid.Column>
<Header as="h3">University</Header>
<p>View tables and diagrams about study progress of different faculties.</p>
{showItems.populations && (
<>
<Divider section />
<Header as="h3">Study programme</Header>
<List bulleted>
<List.Item>
<i>Search by Class:</i> Query a student population specified by a starting year and a study
right. Oodikone will show you interactive statistics and visualizations for the population to
be explored.
</List.Item>
<List.Item>
<i>Overview:</i> View student progress and annual productivity for a given study programme.
</List.Item>
</List>
</>
{items.map(
(item, index) =>
item.show && (
<React.Fragment key={item.title}>
<FrontPageItem title={item.title} content={item.content} />
{index !== items.length - 1 ? <Divider section /> : null}
</React.Fragment>
)
)}
{showItems.courseStatistics && (
<>
<Divider section />
<Header as="h3">Courses</Header>
<p>View statistics about course attempts, completions and grades.</p>
</>
)}
{showItems.students && (
<>
<Divider section />
<Header as="h3">Student statistics</Header>
<p>View detailed information for a given student.</p>
</>
)}
<Divider section />
<Header as="h3">Feedback</Header>
<p>
For questions and suggestions, please use the{' '}
<a href="https://oodikone.helsinki.fi/feedback">feedback form</a> or shoot an email to{' '}
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a>.
</p>
</Grid.Column>
<Grid.Column style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Changelog showFullChangelog={false} />
@@ -102,7 +114,7 @@ export const FrontPage = () => {
)}
</Container>
<a href="https://toska.dev/">
<Image src={images.toskaLogo} size="medium" centered style={{ bottom: 0 }} />
<Image src={images.toskaLogo} alt="Logo of Toska" size="medium" centered style={{ bottom: 0 }} />
</a>
</div>
)
@@ -2,7 +2,6 @@
min-width: 600px !important;
padding: 5px 0 5px 5px !important;
margin: 0 5px 5px !important;
background-color: var(--lightSilver) !important;
align-self: flex-start;
}
@@ -5,9 +5,6 @@
}
.progressBarHidden {
-webkit-animation-name: hide;
-webkit-animation-duration: 2s;
-webkit-animation-fill-mode: forwards;
animation-name: hide;
animation-duration: 2s;
animation-fill-mode: forwards;
@@ -1,5 +1,3 @@
/* eslint-disable no-continue */
import _ from 'lodash'
import React, { useState, useMemo, useEffect } from 'react'
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
@@ -1,6 +1,5 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'
import React, { createContext, useContext, useState, useEffect } from 'react'
import { Icon, Card, Portal, Popup, Button } from 'semantic-ui-react'
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'
import './style.css'
@@ -61,54 +60,31 @@ const Content = ({ children, ...rest }) => {
export const FigureContainer = ({ children, style }) => {
const [isFullscreen, setFullscreen] = useState(false)
const portalNode = useMemo(
() =>
createHtmlPortalNode({
attributes: {
class: 'ui card fluid',
},
}),
[]
)
useEffect(() => {
if (isFullscreen) {
document.body.classList.add('figure-fullscreen')
portalNode.element.style = `
position: sticky;
margin: 0;
inset: 0;
height: 100vh;
border: none;
border-radius: 0;
`
} else {
document.body.classList.remove('figure-fullscreen')
portalNode.element.style = `
overflow: hidden;
maxHeight: 100%;
`
Object.assign(portalNode.element.style, style)
}
return () => document.body.classList.remove('figure-fullscreen')
}, [isFullscreen, portalNode])
}, [isFullscreen])
return (
<FigureContext.Provider value={{ isFullscreen, setFullscreen }}>
<InPortal node={portalNode}>{children}</InPortal>
{!isFullscreen && <OutPortal node={portalNode} />}
<Portal
open={isFullscreen}
style={{
width: '100% !important',
height: '100% !important',
}}
>
<div style={{ inset: '70px 0px 0px 0px', position: 'absolute', zIndex: 900, height: '100000vh' }}>
{isFullscreen && <OutPortal node={portalNode} />}
</div>
</Portal>
{isFullscreen ? (
<Portal open={isFullscreen}>
<div style={{ inset: '70px 0px 0px 0px', position: 'absolute', zIndex: 3, height: '10000vh' }}>
<Card fluid style={{ position: 'sticky', margin: 0, inset: 0, height: '100vh', zIndex: 2 }}>
{children}
</Card>
</div>
</Portal>
) : (
<Card fluid style={style}>
{children}
</Card>
)}
</FigureContext.Provider>
)
}
@@ -140,7 +140,7 @@ export const createHeaders = (columns, columnDepth, dispatch) => {
const [column] = stack.splice(0, 1)
if (column.parent?.mergeHeader) {
continue // eslint-disable-line
continue
}
const currentDepth = column.depth ?? 0
@@ -257,7 +257,6 @@ export const tableStateReducer = (...args) =>
state.expandedGroups.push(group)
}
},
// eslint-disable-next-line prettier/prettier
})[type]()
})
@@ -112,19 +112,15 @@
position: absolute;
outline: 0;
top: 100%;
min-width: -webkit-max-content;
min-width: -moz-max-content;
margin: 0;
padding: 0;
background: #fff;
font-size: 1em;
text-shadow: none;
text-align: left;
-webkit-box-shadow: 0 2px 3px 0 rgb(34 36 38 / 15%);
box-shadow: 0 2px 3px 0 rgb(34 36 38 / 15%);
border: 1px solid rgb(34 36 38 / 15%);
border-radius: 0.28571429rem;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
z-index: 11;
will-change: transform, opacity;
@@ -144,7 +140,6 @@ i.filter.icon {
position: static;
overflow-y: auto;
border: none;
-webkit-box-shadow: none !important;
box-shadow: none !important;
border-radius: 0 !important;
margin: 0 !important;
@@ -172,9 +167,7 @@ i.filter.icon {
font-size: 1rem;
text-transform: none;
font-weight: 400;
-webkit-box-shadow: none;
box-shadow: none;
-webkit-touch-callout: none;
}
.filter-menu .item:hover {
@@ -9,7 +9,9 @@
.enrollment-label-no-margin {
border-radius: 0.28rem;
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
box-shadow:
0 1px 3px 0 #d4d4d5,
0 0 0 1px #d4d4d5;
display: inline-block;
width: 15px;
height: 15px;
@@ -1,4 +1,4 @@
/* Hide year header from datetime picker in overview */
.studyguidancegroupoverview__yeartagselector .rdtSwitch {
.guidance-group-overview-year-tag-selector .rdtSwitch {
display: none;
}
@@ -1,7 +1,6 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Link, useHistory } from 'react-router-dom'
import { Formik } from 'formik'
import { Form, Button, Icon, Modal } from 'semantic-ui-react'
import { Form, Button, Icon, Modal, Message } from 'semantic-ui-react'
import Datetime from 'react-datetime'
import { textAndDescriptionSearch } from 'common'
@@ -46,53 +45,78 @@ const cellContent = { flexGrow: 1 }
const EditTagModal = ({ group, tagName, toggleEdit, selectFieldItems, open }) => {
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
const { getTextIn } = useLanguage()
const initialState = { [tagName]: '' }
const [formValues, setFormValues] = useState(initialState)
const [formErrors, setFormErrors] = useState({})
const onSubmit = values => {
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
toggleEdit()
useEffect(() => {
setFormValues(initialState)
setFormErrors({})
}, [group, tagName, open])
const validate = values => {
const tags = { studyProgramme: 'Study programme', year: 'Starting year' }
const errors = {}
if (!values[tagName]) {
errors[tagName] = `${tags[tagName]} is required!`
}
return errors
}
const handleChange = (name, value) => {
setFormValues(prev => ({ ...prev, [name]: value }))
}
const handleSubmit = () => {
const errors = validate(formValues)
setFormErrors(errors)
if (Object.keys(errors).length === 0) {
changeStudyGuidanceGroupTags({ groupId: group.id, tags: formValues })
toggleEdit()
}
}
return (
<Modal onClose={toggleEdit} open={open}>
<Formik
initialValues={{ [tagName]: '' }}
onSubmit={values => onSubmit(values)}
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
>
{formik => (
<>
<Modal.Header>{getTextIn(group.name)}</Modal.Header>
<Modal.Content>
<AssociateTagForm group={group} tagName={tagName} selectFieldItems={selectFieldItems} formik={formik} />
</Modal.Content>
<Modal.Actions>
<Button content="Cancel" labelPosition="right" icon="trash" onClick={toggleEdit} negative />
<Button
content="Save"
type="submit"
labelPosition="right"
icon="checkmark"
onClick={formik.handleSubmit}
disabled={isLoading}
positive
/>
</Modal.Actions>
</>
)}
</Formik>
<>
<Modal.Header>{getTextIn(group.name)}</Modal.Header>
<Modal.Content>
<AssociateTagForm
group={group}
tagName={tagName}
selectFieldItems={selectFieldItems}
formValues={formValues}
handleChange={handleChange}
formErrors={formErrors}
/>
</Modal.Content>
<Modal.Actions>
<Button content="Cancel" labelPosition="right" icon="trash" onClick={toggleEdit} negative />
<Button
content="Save"
type="submit"
labelPosition="right"
icon="checkmark"
onClick={handleSubmit}
disabled={isLoading}
positive
/>
</Modal.Actions>
</>
</Modal>
)
}
const AssociateTagForm = ({ group, tagName, selectFieldItems, formik }) => (
const AssociateTagForm = ({ group, tagName, selectFieldItems, formValues, handleChange, formErrors }) => (
<>
<p>
{tagName === 'studyProgramme'
? 'Edit associated study programme for this group:'
: 'Edit associated starting year for this group:'}
</p>
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
<div style={cellContent}>
<Form style={{ alignItems: 'center' }}>
<div>
{tagName === 'studyProgramme' ? (
<Form.Select
name={tagName}
@@ -103,16 +127,16 @@ const AssociateTagForm = ({ group, tagName, selectFieldItems, formik }) => (
}
options={selectFieldItems}
closeOnChange
value={formik.values[tagName]}
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
value={formValues[tagName]}
onChange={(_, { value }) => handleChange(tagName, value)}
/>
) : (
<Datetime
className="studyguidancegroupoverview__yeartagselector"
className="guidance-group-overview-year-tag-selector"
name={tagName}
dateFormat="YYYY"
timeFormat={false}
initialvalue={group.tags?.[tagName]}
initialValue={group.tags?.[tagName]}
inputProps={{ readOnly: true }}
renderYear={(props, year) => {
const shiftBy = 2 // fix to start from 2017 instead of 2019
@@ -121,7 +145,7 @@ const AssociateTagForm = ({ group, tagName, selectFieldItems, formik }) => (
...props,
key: props.key - shiftBy,
className:
`${formik.values[tagName]}` === formattedAndShiftedYear.substring(0, 4)
`${formValues[tagName]}` === formattedAndShiftedYear.substring(0, 4)
? 'rdtYear rdtActive'
: 'rdtYear',
'data-value': props['data-value'] - shiftBy,
@@ -141,12 +165,13 @@ const AssociateTagForm = ({ group, tagName, selectFieldItems, formik }) => (
)
}}
closeOnSelect
value={formik.values[tagName]}
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
value={formValues[tagName]}
onChange={value => handleChange(tagName, value?.format('YYYY'))}
/>
)}
</div>
</Form>
{formErrors[tagName] && <Message negative icon="exclamation circle" header={`${formErrors[tagName]}`} />}
</>
)
@@ -3,6 +3,8 @@ import { Divider, Loader, Message } from 'semantic-ui-react'
import { useGetBasicStatsQuery, useGetCreditStatsQuery, useGetGraduationStatsQuery } from 'redux/studyProgramme'
import { studyProgrammeToolTips } from 'common/InfoToolTips'
import { getGraduationGraphTitle } from 'common'
import { CreditsProduced } from 'components/common/CreditsProduced'
import { LineGraph } from './LineGraph'
import { StackedBarChart } from './StackedBarChart'
import { BarChart } from './BarChart'
@@ -11,11 +13,21 @@ import { BreakdownBarChart } from '../BreakdownBarChart'
import { DataTable } from './DataTable'
import { Toggle } from '../Toggle'
import { InfoBox } from '../../Info/InfoBox'
import { getGraduationGraphTitle } from '../../../common'
import '../studyprogramme.css'
const getDivider = (title, toolTipText) => (
<>
<div className="divider">
<Divider data-cy={`Section-${toolTipText}`} horizontal>
{title}
</Divider>
</div>
<InfoBox content={studyProgrammeToolTips[toolTipText]} />
</>
)
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'
@@ -34,27 +46,27 @@ export const BasicOverview = ({
const [showMedian, setShowMedian] = useState(false)
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
const basics = useGetBasicStatsQuery({ id: studyprogramme, combinedProgramme, yearType, specialGroups: special })
const credits = useGetCreditStatsQuery({ id: studyprogramme, combinedProgramme, yearType, specialGroups: special })
const basics = useGetBasicStatsQuery({
id: studyprogramme,
combinedProgramme,
yearType,
specialGroups: 'SPECIAL_INCLUDED',
})
const credits = useGetCreditStatsQuery({
codes: [studyprogramme, combinedProgramme].filter(Boolean),
isAcademicYear: academicYear,
specialGroups: !specialGroups,
})
const graduations = useGetGraduationStatsQuery({
id: studyprogramme,
combinedProgramme,
yearType,
specialGroups: special,
})
const doCombo = graduations?.data?.doCombo
const timesData = graduations?.data?.graduationTimes
const timesDataSecondProgramme = graduations?.data?.graduationTimesSecondProgramme
const getDivider = (title, toolTipText) => (
<>
<div className="divider">
<Divider data-cy={`Section-${toolTipText}`} horizontal>
{title}
</Divider>
</div>
<InfoBox content={studyProgrammeToolTips[toolTipText]} />
</>
)
const displayMedian = () => (
<>
@@ -167,42 +179,15 @@ export const BasicOverview = ({
</div>
</>
)}
{credits.isSuccess && credits.data && (
{credits?.data?.stats?.[studyprogramme]?.stats && (
<>
{' '}
{getDivider('Credits produced by the studyprogramme', 'CreditsProducedByTheStudyprogramme')}
<div className="section-container">
<StackedBarChart
cypress="CreditsProducedByTheStudyprogramme"
data={credits?.data?.graphStats}
labels={credits?.data?.years}
/>
<DataTable
cypress="CreditsProducedByTheStudyprogramme"
data={credits?.data?.tableStats}
titles={credits?.data?.titles}
/>
</div>
{combinedProgramme && credits?.data?.graphStatsSecondProg?.length > 0 && (
<>
<h4>
{combinedProgramme === 'MH90_001'
? 'Credits produced by the licentiate programme'
: 'Credits produced by the master programme'}
</h4>
<div className="section-container">
<StackedBarChart
cypress="CreditsProducedByTheSecondProg"
data={credits?.data?.graphStatsSecondProg}
labels={credits?.data?.years}
/>
<DataTable
cypress="CreditsProducedByTheSecondProg"
data={credits?.data?.tableStatsSecondProg}
titles={credits?.data?.titles}
/>
</div>
</>
)}
<CreditsProduced
data={credits?.data?.stats?.[studyprogramme]?.stats}
secondData={credits?.data?.stats?.[combinedProgramme]?.stats}
academicYear={academicYear}
/>
</>
)}
{graduations.isSuccess && graduations.data && (
@@ -1,4 +1,4 @@
@media only screen and (min-width: 3240px) {
@media only screen and (width >= 3240px) {
.studytrack-overview,
.studyprogramme-overview,
.studyprogramme-courses {
@@ -6,7 +6,7 @@
}
}
@media only screen and (min-width: 3240px) {
@media only screen and (width >= 3240px) {
.studytrack-overview .section-container,
.studyprogramme-overview .section-container,
.studyprogramme-courses .section-container {
@@ -14,6 +14,10 @@
}
}
body {
overflow-x: auto;
}
.studyprogramme-courses {
margin-top: 2em;
}
@@ -0,0 +1,123 @@
import { DataTable } from 'components/StudyProgramme/BasicOverview/DataTable'
import { StackedBarChart } from 'components/StudyProgramme/BasicOverview/StackedBarChart'
import { Toggle } from 'components/StudyProgramme/Toggle'
import React, { useState } from 'react'
const getFormattedYear = (year, academicYear) => (academicYear ? `${year} - ${year + 1}` : `${year}`)
const makeGraphData = (data, showAll, isAcademicYear) => {
if (!data) return null
const allKeys = [
...new Set(
Object.keys(data)
.flatMap(year => Object.keys(data[year]))
.filter(key => key !== 'total' && key !== 'other')
.filter(key => showAll || !['agreement', 'special', 'separate'].includes(key))
),
]
const names = {
agreement: 'Other university',
special: 'Special studyright: non-degree',
separate: 'Separate studies',
basic: 'Degree students',
'open-uni': 'Open university',
transferred: 'Transferred',
'incoming-exchange': 'Exchange students',
}
const currentYear = new Date().getFullYear()
const years = []
const graphStats = []
for (let year = 2017; year <= currentYear; year++) {
allKeys.forEach(key => {
if (!graphStats.find(k => k.name === names[key])) {
graphStats.push({ name: names[key], data: [] })
}
graphStats.find(k => k.name === names[key]).data.push(data[getFormattedYear(year, isAcademicYear)]?.[key] || 0)
})
years.push(getFormattedYear(year, isAcademicYear))
}
return { data: graphStats, years }
}
const makeTableStats = (data, showAll, isAcademicYear) => {
if (!data) return null
const currentYear = new Date().getFullYear()
const tableStats = []
for (let year = currentYear; year >= 2017; year--) {
const yearData = data[getFormattedYear(year, isAcademicYear)]
if (!yearData) continue
const basic = yearData.basic || 0
const openUni = yearData['open-uni'] || 0
const exchange = yearData['incoming-exchange'] || 0
const special = yearData.special || 0
const other = yearData.other || 0
const agreement = yearData.agreement || 0
const transferred = yearData.transferred || 0
const total = basic + openUni + special + other + agreement
/* TODO: Other-category missing for now, clarify what go in that, and fix those */
const yearStats = [
getFormattedYear(year, isAcademicYear),
Math.round(total),
Math.round(basic),
Math.round(openUni),
Math.round(exchange),
Math.round(transferred),
]
if (showAll) yearStats.push(Math.round(special), Math.round(agreement))
tableStats.push(yearStats)
}
const titles = ['', 'Total', 'Degree students', 'Open university', 'Exchange students', 'Transferred']
if (showAll) titles.push('Special', 'Other university')
return { data: tableStats, titles }
}
export const CreditsProduced = ({ data, secondData, academicYear }) => {
const [showAll, setShowAll] = useState(false)
if (!data) return null
const tableStats = makeTableStats(data, showAll, academicYear)
const creditGraphStats = makeGraphData(data, showAll, academicYear)
const secondTableStats = secondData ? makeTableStats(secondData, showAll, academicYear) : null
const secondCreditGraphStats = secondData ? makeGraphData(secondData, showAll, academicYear) : null
return (
<>
<div>
<Toggle
cypress="showAllCreditsToggle"
firstLabel="Show special categories"
value={showAll}
setValue={setShowAll}
/>
</div>
<div className="section-container">
<StackedBarChart
cypress="CreditsProducedByTheStudyprogramme"
data={creditGraphStats.data}
labels={creditGraphStats.years}
/>
<DataTable cypress="CreditsProducedByTheStudyprogramme" data={tableStats.data} titles={tableStats.titles} />
</div>
{secondData && (
<div>
{' '}
<h4>Credits produced by the licentiate programme</h4>
<div className="section-container">
<StackedBarChart
cypress="CreditsProducedByTheStudyprogramme"
data={secondCreditGraphStats.data}
labels={secondCreditGraphStats.years}
/>
<DataTable
cypress="CreditsProducedByTheStudyprogramme"
data={secondTableStats.data}
titles={secondTableStats.titles}
/>
</div>
</div>
)}
</>
)
}
@@ -1,6 +1,5 @@
import { useLocalStorage } from 'common/hooks'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import reactScrollToComponent from 'react-scroll-to-component'
import { Accordion } from 'semantic-ui-react'
const titleStyle = { paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }
@@ -18,7 +17,7 @@ export const PanelView = ({ panels: initialPanels, viewTitle }) => {
const [newestIndex, setNewestIndex] = useState(null)
useEffect(() => {
if (newestIndex) reactScrollToComponent(refs.current[newestIndex], { align: 'bottom' })
if (newestIndex) refs.current[newestIndex].scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [newestIndex])
const togglePanel = index => {
@@ -7,8 +7,10 @@ const studyprogrammeApi = RTKApi.injectEndpoints({
`/v2/studyprogrammes/${id}/basicstats?year_type=${yearType}&special_groups=${specialGroups}&combined_programme=${combinedProgramme}`,
}),
getCreditStats: builder.query({
query: ({ id, yearType, specialGroups, combinedProgramme }) =>
`/v2/studyprogrammes/${id}/creditstats?year_type=${yearType}&special_groups=${specialGroups}&combined_programme=${combinedProgramme}`,
query: ({ codes, isAcademicYear, specialGroups }) =>
`/v2/studyprogrammes/creditstats?codes=${JSON.stringify(
codes
)}&isAcademicYear=${isAcademicYear}&includeSpecials=${specialGroups}`,
}),
getGraduationStats: builder.query({
query: ({ id, yearType, specialGroups, combinedProgramme }) =>
-4
View File
@@ -1,5 +1,3 @@
@custom-media --mobile (max-width: 768px);
:root {
font-size: 16px;
}
@@ -14,7 +12,6 @@ html {
}
.segmentContainer {
align-content: center;
display: flex;
margin-left: auto;
margin-right: auto;
@@ -54,7 +51,6 @@ html {
.fixed-header thead tr:first-child th,
.fixed-header thead tr:nth-child(2) th {
position: sticky;
position: -webkit-sticky;
top: 0;
z-index: 2;
background-color: white !important;
+1 -1
View File
@@ -18,6 +18,6 @@ export default defineConfig({
},
build: {
outDir: 'build',
sourcemap: true,
sourcemap: 'hidden',
},
})
+4 -46
View File
@@ -7,7 +7,6 @@
"name": "sis-updater-scheduler",
"dependencies": {
"async": "^3.2.3",
"axios": "^0.21.4",
"cron": "^1.8.2",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
@@ -200,14 +199,6 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
"integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ=="
},
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -616,25 +607,6 @@
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -671,27 +643,13 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/gelf-pro": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.3.tgz",
"integrity": "sha512-gohbipAj/okE7pUgRSkIMEiqAS2M3q5YWnoejUDA9qwBeqbJr8LSwe3E7YW2QFK7hi4tZje1ZnKQe0296zdW/w==",
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.11.tgz",
"integrity": "sha512-y6DOxU40U4Sd+ECSLtMMtOTig1gEmnIHnvfocyvH+okc6LSfd7ADirT1tbzpAMzOXIgDG/2j9psZ1ck3HXfjEA==",
"dependencies": {
"async": "2.6.2",
"lodash": "4.17.14"
"lodash": "~4.17.21"
}
},
"node_modules/gelf-pro/node_modules/async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz",
"integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==",
"dependencies": {
"lodash": "^4.17.11"
}
},
"node_modules/gelf-pro/node_modules/lodash": {
"version": "4.17.14",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz",
"integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw=="
},
"node_modules/get-intrinsic": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz",
+5 -1
View File
@@ -7,7 +7,6 @@
},
"dependencies": {
"async": "^3.2.3",
"axios": "^0.21.4",
"cron": "^1.8.2",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
@@ -23,5 +22,10 @@
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.3.4"
}
}
}
@@ -20,6 +20,7 @@ module.exports.EXIT_AFTER_IMMEDIATES = process.env.EXIT_AFTER_IMMEDIATES === 'ye
module.exports.SCHEDULE_IMMEDIATE = process.env.SCHEDULE_IMMEDIATE?.split(',') || []
module.exports.ENABLE_WORKER_REPORTING =
module.exports.EXIT_AFTER_IMMEDIATES || process.env.ENABLE_WORKER_REPORTING === 'yes'
module.exports.SLACK_WEBHOOK = process.env.SLACK_WEBHOOK
// Sentry
module.exports.sentryRelease = process.env.SENTRY_RELEASE || ''
+11 -4
View File
@@ -1,5 +1,4 @@
const axios = require('axios').default
const { NATS_GROUP, SIS_PURGE_CHANNEL, REDIS_LAST_PREPURGE_INFO } = require('./config')
const { NATS_GROUP, SIS_PURGE_CHANNEL, REDIS_LAST_PREPURGE_INFO, SLACK_WEBHOOK } = require('./config')
const { logger } = require('./utils/logger')
const { stan, opts } = require('./utils/stan')
const { set: redisSet, get: redisGet } = require('./utils/redis')
@@ -28,12 +27,20 @@ let collectedPrePurgeTableData = {} // Collect data from nats, since synchronous
let stanChannel // Channel is initialized once for purge
const sendToSlack = async text => {
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK
logger.info('Sending to slack', { text })
if (!SLACK_WEBHOOK) return logger.info('SLACK_WEBHOOK environment variable must be set')
try {
axios.post(SLACK_WEBHOOK, { text })
const res = await fetch(SLACK_WEBHOOK, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
})
if (!res.ok) {
throw new Error('Failed to send to slack')
}
} catch (err) {
logger.info('Failed to send to slack', { err: err.meta })
}
+4 -18
View File
@@ -454,27 +454,13 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/gelf-pro": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.3.tgz",
"integrity": "sha512-gohbipAj/okE7pUgRSkIMEiqAS2M3q5YWnoejUDA9qwBeqbJr8LSwe3E7YW2QFK7hi4tZje1ZnKQe0296zdW/w==",
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/gelf-pro/-/gelf-pro-1.3.11.tgz",
"integrity": "sha512-y6DOxU40U4Sd+ECSLtMMtOTig1gEmnIHnvfocyvH+okc6LSfd7ADirT1tbzpAMzOXIgDG/2j9psZ1ck3HXfjEA==",
"dependencies": {
"async": "2.6.2",
"lodash": "4.17.14"
"lodash": "~4.17.21"
}
},
"node_modules/gelf-pro/node_modules/async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz",
"integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==",
"dependencies": {
"lodash": "^4.17.11"
}
},
"node_modules/gelf-pro/node_modules/lodash": {
"version": "4.17.14",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz",
"integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw=="
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+5
View File
@@ -21,5 +21,10 @@
},
"devDependencies": {
"nodemon": "^3.0.3"
},
"overrides": {
"winston-gelf-transporter": {
"gelf-pro": "^1.3.4"
}
}
}
@@ -535,7 +535,10 @@ const updateEnrollments = async (enrollments, personIdToStudentNumber) => {
studyRightIdToEducationType
)
const mappedEnrollments = enrollments.filter(({ document_state }) => document_state === 'ACTIVE').map(mapEnrollment)
const mappedEnrollments = enrollments
.filter(({ document_state }) => document_state === 'ACTIVE')
.map(mapEnrollment)
.filter(({ studentnumber }) => studentnumber != null)
await bulkCreate(Enrollment, mappedEnrollments)
}