mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b6de49c0 | ||
|
|
c6903ebe25 | ||
|
|
2d2c25b763 | ||
|
|
d232312365 | ||
|
|
f26f114b1d | ||
|
|
7f8a1bdb0d | ||
|
|
8d0dcc5e1a | ||
|
|
c7173b34ec | ||
|
|
361af09dc7 | ||
|
|
f6db93ee67 | ||
|
|
dee45df9b1 | ||
|
|
9d53e3343e | ||
|
|
86083ecc0b | ||
|
|
693de4a505 | ||
|
|
8ad4be048d | ||
|
|
293c104f49 | ||
|
|
6e6fa1bad0 | ||
|
|
59772a0dad | ||
|
|
15a64c8727 | ||
|
|
fc6bd6f866 | ||
|
|
11566d95c3 | ||
|
|
f155979cb8 | ||
|
|
667a403c35 | ||
|
|
34aaba9444 | ||
|
|
38c84a0d6c | ||
|
|
130da21892 | ||
|
|
c160aa85ad | ||
|
|
8247c55545 | ||
|
|
3c6cb9aaac | ||
|
|
313a050688 | ||
|
|
d12de74d1b | ||
|
|
1d10107e0d | ||
|
|
da5cee8e66 | ||
|
|
d0ff477d13 | ||
|
|
5da6949a8a | ||
|
|
025f0bc954 | ||
|
|
8208f1ade5 | ||
|
|
648f11ea88 | ||
|
|
f1435aac7f | ||
|
|
f356d5cb7d | ||
|
|
3bb2c8e88c | ||
|
|
e38b0bb0ae | ||
|
|
77a84f479c | ||
|
|
9b55cf5455 | ||
|
|
47dc180145 | ||
|
|
f0e161359e | ||
|
|
f4bf29c717 | ||
|
|
50a21143e8 | ||
|
|
d208012f07 | ||
|
|
40c3cf7ca1 | ||
|
|
b432e1b359 | ||
|
|
7762b81cb8 | ||
|
|
55c2d94b3f | ||
|
|
b7d3d6580d | ||
|
|
f9525d7757 | ||
|
|
bf28e8e013 | ||
|
|
5bd0d291a6 | ||
|
|
499b459807 | ||
|
|
811641e0c3 | ||
|
|
add0074205 | ||
|
|
586c297ada | ||
|
|
99984f5aa0 | ||
|
|
262c5b253c | ||
|
|
329899b089 | ||
|
|
c9774a80d4 | ||
|
|
40e675b147 | ||
|
|
4c73a1dbed | ||
|
|
21d6187c38 | ||
|
|
8c18bbc942 | ||
|
|
c02fe5af2a | ||
|
|
32b0c8a3db | ||
|
|
ca4bb11d12 | ||
|
|
fed71d4435 | ||
|
|
d23a0e67a4 | ||
|
|
6d37b116cf | ||
|
|
774b883513 | ||
|
|
18aa9ded2d | ||
|
|
f423b48a7b | ||
|
|
97a0725740 | ||
|
|
9789fe79e4 | ||
|
|
3d6cce2593 | ||
|
|
4b9e1850b1 | ||
|
|
9ad7452334 | ||
|
|
2c9bf88270 | ||
|
|
bc36f0da91 | ||
|
|
a3187dcc53 | ||
|
|
757d2ad730 | ||
|
|
1c16621124 | ||
|
|
45cbcd58ad | ||
|
|
92f394b230 | ||
|
|
ac71464055 | ||
|
|
94af09d0d4 | ||
|
|
99684923dc | ||
|
|
ab63820736 | ||
|
|
7cd61602df | ||
|
|
dacce9764e | ||
|
|
1c6176830c | ||
|
|
23a600477f | ||
|
|
0127d41381 | ||
|
|
4a4a6efb0f | ||
|
|
efc773141b | ||
|
|
89aa2b6931 | ||
|
|
7f01fb688b | ||
|
|
ccca69ce82 | ||
|
|
10f09577a0 | ||
|
|
bf38fe33ab | ||
|
|
427d2cdd4d | ||
|
|
a61876657f | ||
|
|
f74f319c89 | ||
|
|
6ebbf32e1f | ||
|
|
395eab9b58 | ||
|
|
d99ff8ee50 | ||
|
|
94042a7ea6 | ||
|
|
d9c869e13f | ||
|
|
8a718554ea | ||
|
|
c8036892c6 | ||
|
|
d664a19c10 | ||
|
|
28e2316f04 | ||
|
|
0e48735748 | ||
|
|
67787daccf | ||
|
|
d7829a0cfc | ||
|
|
2424b115f5 | ||
|
|
3ebe68998b | ||
|
|
6947d1b858 | ||
|
|
629cec7560 | ||
|
|
094f625eb5 | ||
|
|
b7720f620c | ||
|
|
9e5acd3145 | ||
|
|
704265963c | ||
|
|
b4bfcd1292 | ||
|
|
302f0b3b33 | ||
|
|
2ba108a34e | ||
|
|
86d656dc4c | ||
|
|
2e8c9c2a7e | ||
|
|
730ac94c20 | ||
|
|
44584d1f7d | ||
|
|
d93b708029 | ||
|
|
dcbc7a916e | ||
|
|
a7e3e45690 | ||
|
|
2404d9a42c | ||
|
|
340912b5a1 | ||
|
|
7aaf584eaa | ||
|
|
57de5435bd | ||
|
|
1804bccefb | ||
|
|
6461aaff4e | ||
|
|
b32a0fb3fe | ||
|
|
ad0ccbc98b | ||
|
|
a1f3cb8fa8 | ||
|
|
b074b90553 | ||
|
|
ce7a34e1d7 | ||
|
|
314dcc7215 | ||
|
|
7c9c5508d0 | ||
|
|
d6b289eef2 | ||
|
|
eaaa55bba6 | ||
|
|
60fb3a8ae0 | ||
|
|
208bd3511d | ||
|
|
3a9a5a69e4 | ||
|
|
3b8aa5bf56 | ||
|
|
4e373d4ef2 | ||
|
|
219dc7ce47 | ||
|
|
9bc087818a | ||
|
|
ad1998bcd0 | ||
|
|
fb118094a9 | ||
|
|
dc9a7b3920 | ||
|
|
5b5ec30458 | ||
|
|
a7d30769b8 | ||
|
|
442f2685e5 | ||
|
|
ae097d04b6 | ||
|
|
e934157821 | ||
|
|
db6cb155a0 | ||
|
|
8c6ea09791 | ||
|
|
34ee583425 | ||
|
|
592ea364f4 | ||
|
|
2212af8d7e | ||
|
|
27bba9f135 | ||
|
|
42112bb36e | ||
|
|
b1aed16f99 | ||
|
|
238e6ea9ce | ||
|
|
0682d90dce | ||
|
|
bc26f1cf7b | ||
|
|
5b2c3d7fff | ||
|
|
ffd7da54cc | ||
|
|
3b6b387c0d | ||
|
|
a68d1f13c1 | ||
|
|
dbf838f5a5 | ||
|
|
c37c9d0b99 | ||
|
|
24e478e7b4 | ||
|
|
2481713a0a | ||
|
|
843a61d74a | ||
|
|
783c46b62e | ||
|
|
ec55cc101f | ||
|
|
9698329ae2 | ||
|
|
191b8b4c3a | ||
|
|
b0d439c1be | ||
|
|
0f21a53381 | ||
|
|
b2ff61f85b | ||
|
|
ac7e3f2423 | ||
|
|
46f316c82f | ||
|
|
926a0d67d1 | ||
|
|
4e319e801b | ||
|
|
3182fa89d4 | ||
|
|
012686c49b | ||
|
|
9d5a927d86 | ||
|
|
05f6a12871 | ||
|
|
1ed659f93c | ||
|
|
215002a86c | ||
|
|
dea0d29053 | ||
|
|
daac389968 | ||
|
|
3e0ca1827a | ||
|
|
fe2f73bd5c | ||
|
|
d27a931c4e | ||
|
|
7f02d194d4 | ||
|
|
72e7519cb7 | ||
|
|
a39b80946c | ||
|
|
9b7b7c7e2d | ||
|
|
73e69b5f57 | ||
|
|
6a4a7b539b | ||
|
|
a2dab9dbbe | ||
|
|
b06931b403 | ||
|
|
902af27091 | ||
|
|
6d1d8e90c1 | ||
|
|
003ff2665e | ||
|
|
56b87ba3a1 | ||
|
|
ea94c510b0 | ||
|
|
97b82c71bc | ||
|
|
37710702a3 | ||
|
|
06dff4c3bc | ||
|
|
47d8fef876 | ||
|
|
267b79324c | ||
|
|
88fc069110 | ||
|
|
3ea74621db | ||
|
|
e88c544bb4 | ||
|
|
14d63f7009 | ||
|
|
613aa14fd4 | ||
|
|
d35e4309be | ||
|
|
89e261c5f2 | ||
|
|
df8a257030 | ||
|
|
db391fb15d | ||
|
|
7f96288849 | ||
|
|
c64ba8ce29 | ||
|
|
1a1d55bf93 | ||
|
|
44b47331bb | ||
|
|
bcaa0f498f | ||
|
|
59827d440a | ||
|
|
7f9b472e30 | ||
|
|
95141e652e | ||
|
|
cf2a7e56e0 | ||
|
|
e50a6d5577 | ||
|
|
d1c41972f7 | ||
|
|
cebee9d8cf | ||
|
|
3f7b62015e | ||
|
|
e4e5a9d075 | ||
|
|
435209b742 | ||
|
|
866c5a64e4 | ||
|
|
5f20d7a344 | ||
|
|
6dcfadb68e | ||
|
|
192171f270 | ||
|
|
0538329d60 | ||
|
|
5b458933d2 | ||
|
|
fdbed2f1cf | ||
|
|
cc73d5983e | ||
|
|
e69e8dbe02 | ||
|
|
ab460b0e9f | ||
|
|
b1d8e852df | ||
|
|
d5dfed6942 | ||
|
|
5d6c0500a3 | ||
|
|
2b58b9ba91 | ||
|
|
9bb72b2f0e | ||
|
|
ad73cd7d0f | ||
|
|
83551edacf | ||
|
|
a0ee324fe7 | ||
|
|
fb221edd87 | ||
|
|
64c61d1be2 | ||
|
|
216083a0d4 | ||
|
|
a04789094e | ||
|
|
13db9cdb6d | ||
|
|
3b78e7ade3 | ||
|
|
7cbc1dfb26 | ||
|
|
ab37b967ad | ||
|
|
5b15a2516f | ||
|
|
56e6e88a9e | ||
|
|
3ed5aed710 | ||
|
|
a0294b7750 | ||
|
|
9539d6b97b | ||
|
|
578fef70a8 | ||
|
|
31fac70a31 | ||
|
|
9e631d119f | ||
|
|
ef0d95f83f | ||
|
|
e2774bfdc4 | ||
|
|
dc65bb4571 | ||
|
|
48cb58deb7 | ||
|
|
c65e4dadad | ||
|
|
8388914406 | ||
|
|
3e87dd0c79 | ||
|
|
81dda57671 | ||
|
|
7670562b90 | ||
|
|
df632591bb | ||
|
|
ece01ad9d2 | ||
|
|
be1be25d00 | ||
|
|
4c553124bf | ||
|
|
a0e26d361f | ||
|
|
4bf5982062 | ||
|
|
d06d7b120b | ||
|
|
ad3ebe0464 | ||
|
|
76a7d40784 | ||
|
|
ba4808aaf7 | ||
|
|
fdc97a4537 | ||
|
|
73c3882751 | ||
|
|
d04c77e7d2 | ||
|
|
07fb9b7905 | ||
|
|
002d91613a | ||
|
|
0a5332664b | ||
|
|
d72f1a8478 | ||
|
|
246ad05031 | ||
|
|
83b79fc9ea | ||
|
|
e8df57f895 | ||
|
|
040f8cad8f | ||
|
|
3cde980950 | ||
|
|
a2c665a501 | ||
|
|
ee99dcb449 | ||
|
|
b4509f79cd | ||
|
|
c7121795c3 | ||
|
|
21e4e44713 | ||
|
|
37f93b328a | ||
|
|
ce0b42f88b | ||
|
|
dcd8ba77b3 | ||
|
|
6dc3bdb72a | ||
|
|
ef80ee436d | ||
|
|
6ad8981547 | ||
|
|
3169c34864 | ||
|
|
1d769c4fa2 | ||
|
|
11c6ab2db4 | ||
|
|
b48ca23e9d | ||
|
|
14da33f852 | ||
|
|
5e8beae9ed | ||
|
|
9676371823 | ||
|
|
5267cff110 | ||
|
|
647b2197fe | ||
|
|
9f8048c0fb | ||
|
|
3f9a245d16 | ||
|
|
ebfc16a07e | ||
|
|
56ffaa6dd8 | ||
|
|
e142fbb449 | ||
|
|
219608293c | ||
|
|
8faa244c56 | ||
|
|
dea230ecec | ||
|
|
196bea2a58 | ||
|
|
23dceab14c | ||
|
|
7af0f28c14 | ||
|
|
9433aef65a | ||
|
|
ad1455b03e | ||
|
|
4f698b542e | ||
|
|
4b8b042c2f | ||
|
|
f82ddf660c | ||
|
|
54fec454f0 | ||
|
|
610fab1c21 | ||
|
|
2bbc041f22 | ||
|
|
4efde9a997 | ||
|
|
be309972be | ||
|
|
2603ef507a | ||
|
|
6334076698 | ||
|
|
3579580029 | ||
|
|
bede2502f5 | ||
|
|
142f5c444f | ||
|
|
43d11e615b | ||
|
|
2dcdae9796 | ||
|
|
c0308f43fa | ||
|
|
9f6606daa2 | ||
|
|
5b7dda18ca | ||
|
|
47685ac459 | ||
|
|
54467fcdcf | ||
|
|
83673a77f4 | ||
|
|
ce244e1cb2 | ||
|
|
8c20865e7a | ||
|
|
a0fdc68117 | ||
|
|
380c2f8154 | ||
|
|
8922d1065f | ||
|
|
4c7b52cfcb | ||
|
|
07bea28642 | ||
|
|
e13a4da9ea | ||
|
|
3f58a321a5 | ||
|
|
b5cfc051b7 | ||
|
|
eba3a77e2e | ||
|
|
a20bdb5723 |
@@ -1,82 +0,0 @@
|
||||
version: '2'
|
||||
|
||||
checks:
|
||||
argument-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
complex-logic:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
file-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 250
|
||||
method-complexity:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 10
|
||||
method-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 20
|
||||
method-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 25
|
||||
nested-control-flow:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
return-statements:
|
||||
enabled: false
|
||||
similar-code:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: # Language-specific defaults. Overrides affect all languages.
|
||||
identical-code:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: # Language-specific defaults. Overrides affect all languages.
|
||||
|
||||
exclude_patterns:
|
||||
- '.github/'
|
||||
- '.husky/'
|
||||
- '.vscode/'
|
||||
- 'adminer-plugins/'
|
||||
- 'anonyymioodi/'
|
||||
- 'cypress/'
|
||||
- 'documentation/'
|
||||
- 'scripts/'
|
||||
- 'services/backend/src/database/migrations_kone/'
|
||||
- 'services/backend/src/database/migrations_user/'
|
||||
- 'services/backend/src/models/'
|
||||
- 'updater/sis-updater-worker/src/db/migrations/'
|
||||
- 'updater/sis-updater-worker/src/db/models/'
|
||||
- '.codeclimate.yml'
|
||||
- '.env.template'
|
||||
- '.lintstagedrc.js'
|
||||
- '.nycrc'
|
||||
- '.prettierrc'
|
||||
- '.stylelintrc'
|
||||
- 'cypress.config.js'
|
||||
- 'LICENSE'
|
||||
- 'nginx.conf'
|
||||
- 'nyc-config.js'
|
||||
- '**/.dockerignore'
|
||||
- '**/.eslintrc.json'
|
||||
- '**/.gitignore'
|
||||
- '**/dev.Dockerfile'
|
||||
- '**/Dockerfile'
|
||||
- '**/*.css'
|
||||
- '**/*.csv'
|
||||
- '**/*.html'
|
||||
- '**/*.json'
|
||||
- '**/*.md'
|
||||
- '**/*.png'
|
||||
- '**/*.sh'
|
||||
- '**/*.sql'
|
||||
- '**/*.svg'
|
||||
- '**/*.txt'
|
||||
- '**/*.yml'
|
||||
+11
-4
@@ -1,6 +1,13 @@
|
||||
// ! If we don't have this file, running ESLint will fail because of the import/no-unused-modules rule
|
||||
// ! See: https://github.com/import-js/eslint-plugin-import/issues/3079
|
||||
// ! When this issue is resolved, this file can be removed
|
||||
{
|
||||
"ignorePatterns": ["**/node_modules/", "**/dist/", "**/build/"]
|
||||
"ignorePatterns": [
|
||||
"eslint.config.mjs",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/instrumented/**",
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"cypress.config.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: 'Install dependencies'
|
||||
shell: bash
|
||||
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const path = require('path')
|
||||
import { relative } from 'path'
|
||||
|
||||
const cwd = process.cwd()
|
||||
const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodikone`
|
||||
const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file))].join(' ')
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
+2
-1
@@ -6,5 +6,6 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Oodikone
|
||||
[](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
|
||||
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
|
||||
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable import/no-commonjs */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable import/no-unused-modules */
|
||||
/* eslint-disable import-x/no-extraneous-dependencies */
|
||||
/* eslint-disable import-x/no-default-export */
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
experimentalStudio: process.env.NODE_ENV === 'development',
|
||||
projectId: 'c3jsph',
|
||||
defaultCommandTimeout: 30000,
|
||||
requestTimeout: 30000,
|
||||
|
||||
+1027
-909
File diff suppressed because it is too large
Load Diff
+396
-391
@@ -1,464 +1,469 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const MOCKED_DATE = new Date('2024-08-30').getTime()
|
||||
const setClockToMockedDate = () => {
|
||||
const MOCKED_DATE = new Date('2024-08-30')
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
}
|
||||
|
||||
const checkFilteringResult = studentCount => {
|
||||
const checkFilteredStudentCount = studentCount => {
|
||||
cy.contains(`Students (${studentCount})`)
|
||||
}
|
||||
|
||||
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
|
||||
// This function tries to click "x" on all selections inside dropdown matching given attribute
|
||||
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
|
||||
cy.cs(dataCyAttribute).click()
|
||||
}
|
||||
|
||||
const clearSingleDropdownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
|
||||
}
|
||||
|
||||
const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
cy.cs(parentEl)
|
||||
.cs('range-selector-min')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMin => {
|
||||
cy.cs(parentEl)
|
||||
.cs('range-selector-max')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(min)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(max)
|
||||
|
||||
checkFilteringResult(expected)
|
||||
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(initialMin)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(initialMax)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
|
||||
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
|
||||
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
|
||||
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
|
||||
return (filterName, testStepFunctionToRun) => {
|
||||
cy.cs(`${filterName}-filter-card`)
|
||||
.invoke('attr', 'data-open')
|
||||
.then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("Population statistics with a master's programme", () => {
|
||||
const pathToMathMSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"MH50_001"%7D&year=2020'
|
||||
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
|
||||
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 26
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathMSc2020)
|
||||
})
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
|
||||
before(() => cy.init(pathToMathMSc2020))
|
||||
|
||||
it('Study track filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StudyTrack', () => {
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(15)
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
cy.selectFromDropdown('studyTrackFilter', 0)
|
||||
checkFilteredStudentCount(15)
|
||||
|
||||
cy.cs('studyTrackFilter-clear').click()
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
})
|
||||
|
||||
describe('Study right type filter', () => {
|
||||
it('is visible', () => {
|
||||
cy.contains('Study right type')
|
||||
})
|
||||
it('Study right type filter works', () => {
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
|
||||
it('is set to all by default', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
// Bachelor + master
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedBaMaStudents)
|
||||
// Master only
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedMasterOnlyStudents)
|
||||
// All
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
it('works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('bama').click()
|
||||
checkFilteringResult(expectedBaMaStudents)
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('master').click()
|
||||
checkFilteringResult(expectedMasterOnlyStudents)
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics', () => {
|
||||
const pathToMathBSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"KH50_001"%7D&year=2020'
|
||||
describe.skip('Population Statistics', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 27
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
const graduated = 16
|
||||
const transferred = 3
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020)
|
||||
})
|
||||
before(() => cy.init(pathToMathBSc2020))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it("Transfer filter is set to 'not transferred' by default", () => {
|
||||
cy.contains('By default only students who have not transferred to this study programme are shown.')
|
||||
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
|
||||
const graduated = 16
|
||||
getCard().cs('option-graduated-true').click()
|
||||
checkFilteringResult(graduated)
|
||||
getCard().cs('option-graduated-false').click()
|
||||
checkFilteringResult(defaultAmountOfStudents - graduated)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Transfer filter works', () => {
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const transferred = 3
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-have').click()
|
||||
checkFilteringResult(transferred)
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents + transferred)
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-havenot').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Enrollment filter works', () => {
|
||||
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
|
||||
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
|
||||
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
|
||||
checkFilteringResult(25)
|
||||
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credit filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('CreditsEarned', () => {
|
||||
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 23, 30, 21)
|
||||
})
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(14)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(21)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Admission type filter works', () => {
|
||||
runTestStepWithPreAndPostParts('AdmissionType', () => {
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(18)
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
|
||||
})
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(10)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
|
||||
checkFilteringResult(8)
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(6)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
getCard().cs('option-graduated-true').click()
|
||||
testRangeFilter('Age-filter-card', 23, 30, 12)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('"Reset All Filters" button works', { retries: 3 }, () => {
|
||||
cy.cs('Gender-header').click()
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('StartYearAtUni-header').click()
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(8)
|
||||
cy.cs('reset-all-filters').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right type filter is not visible', () => {
|
||||
it.skip('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics with Bachelor + Master', () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
|
||||
const defaultAmountOfStudents = 47
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
// TODO: FilterRadio is not working correcly
|
||||
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2017)
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
getCard().cs('option-graduated-bachelor').click()
|
||||
checkFilteringResult(42)
|
||||
getCard().cs('option-graduated-master').click()
|
||||
checkFilteringResult(18)
|
||||
getCard().cs('option-not-graduated-bachelor').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-not-graduated-master').click()
|
||||
checkFilteringResult(29)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
|
||||
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
|
||||
it('Enrollment filter works', () => {
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
|
||||
checkFilteredStudentCount(25)
|
||||
|
||||
// First two are 0 because there are no semester enrollments for the current semester
|
||||
getCard().cs('option-active').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-active-combined').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-inactive').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-inactive-combined').click()
|
||||
checkFilteringResult(24)
|
||||
getCard().cs('option-activity-status-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Course Statistics', () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
|
||||
const defaultAmountOfStudents = 48
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToLimits2021)
|
||||
cy.cs('enrollmentStatusFilter-clear').click()
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Grade filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(23)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
checkFilteringResult(29)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
})
|
||||
it('Credit filter works', () => {
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
|
||||
|
||||
cy.cs('creditsEarnedFilter-clear').click()
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme defaults to "Attainment" mode', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
})
|
||||
})
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 21)
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(33)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 21, 30, 36)
|
||||
})
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(9)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(39)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(14)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(10)
|
||||
checkFilteringResult(24)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(21)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Admission type filter works', () => {
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
cy.selectFromDropdown('admissionTypeFilter', 0)
|
||||
checkFilteredStudentCount(18)
|
||||
cy.selectFromDropdown('admissionTypeFilter', 1)
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('admissionTypeFilter-clear').click()
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
|
||||
checkFilteredStudentCount(4)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(7)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(23)
|
||||
testRangeFilter('Age-filter-card', 21, 30, 17)
|
||||
cy.cs('gradeFilter-5').click()
|
||||
})
|
||||
})
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 12)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('"Reset All Filters" button works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(8)
|
||||
|
||||
cy.cs('reset-all-filters').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Population Statistics', () => {
|
||||
let runTestStepWithPreAndPostParts
|
||||
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 47
|
||||
|
||||
beforeEach(() => {
|
||||
before(() => cy.init(pathToMathBSc2017))
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(42)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(18)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
|
||||
checkFilteredStudentCount(29)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
|
||||
// Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
// Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
// Non-Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
// Non-Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('studyRightStatusFilter-clear').click()
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
describe.skip('Course Statistics', { testIsolation: false }, () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
|
||||
const defaultAmountOfStudents = 48
|
||||
|
||||
before(() => cy.init(pathToLimits2021))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Grade filter works', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('gradeFilter-header').click()
|
||||
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(23)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
checkFilteredStudentCount(29)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
|
||||
cy.cs('gradeFilter-clear').should('not.exist')
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme filter works and defaults to "Attainment"', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(33)
|
||||
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(39)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 10)
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(17)
|
||||
|
||||
cy.cs('gradeFilter-clear').click()
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
let defaultAmountOfStudents
|
||||
|
||||
before(() => {
|
||||
cy.init('/custompopulation')
|
||||
cy.cs('custom-pop-search-button').click()
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
|
||||
defaultAmountOfStudents = students.length
|
||||
cy.cs('student-number-input').click()
|
||||
cy.cs('student-number-input').type(students.join('{enter}'))
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
})
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 24, 28, 5)
|
||||
})
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 24, 28, 5)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme defaults to "Active Study Right" mode', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
|
||||
})
|
||||
})
|
||||
it('Programme filter works and defaults to "Active Study Right"', () => {
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(3)
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(5)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(3)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(2)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(7)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(5)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,13 @@ const hasLanded = () => {
|
||||
}
|
||||
|
||||
const chooseSemester = (semester, fromOrTo) => {
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`)
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).click()
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
})
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns, mode) => {
|
||||
@@ -74,11 +73,13 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
@@ -92,10 +93,12 @@ describe('When language center is opened', () => {
|
||||
chooseSemester('Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Coloring mode works on semester tab', () => {
|
||||
cy.contains('Compare to average of course').click()
|
||||
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
|
||||
|
||||
+25
-23
@@ -1,21 +1,18 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains('Saved populations')
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
cy.cs('open-uni-search-button').click()
|
||||
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
|
||||
|
||||
cy.get('@search').then($el => {
|
||||
const searchItems = $el.find('div[role=option] > span[class=text]')
|
||||
for (let i = searchItems.length - 1; 0 <= i; i--) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Open uni population tests', () => {
|
||||
@@ -26,18 +23,22 @@ describe('Open uni population tests', () => {
|
||||
})
|
||||
after(() => {
|
||||
cy.init('/openunipopulation', 'admin')
|
||||
cy.url().should('include', '/openunipopulation')
|
||||
cy.contains('Open uni student population')
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
it('Finds a proper population', () => {
|
||||
// TODO: Rewrite the whole component
|
||||
it.skip('Finds a proper population', () => {
|
||||
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="begin-of-search"]').clear().type('01.01.2015')
|
||||
cy.get('[data-cy="end-of-search"]').clear().type('01.01.2020')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
|
||||
cy.get('@textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.cs('begin-of-search').click()
|
||||
cy.cs('begin-of-search').clear()
|
||||
cy.cs('begin-of-search').type('01.01.2015')
|
||||
cy.cs('end-of-search').click()
|
||||
cy.cs('end-of-search').clear()
|
||||
cy.cs('end-of-search').type('01.01.2020')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.contains('Beginning of the search for all fields:')
|
||||
cy.contains('01.01.2015')
|
||||
@@ -79,9 +80,10 @@ describe('Open uni population tests', () => {
|
||||
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="save-search"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('TEST-Avoimen-populaatio')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('button').contains('Search population').as('btn')
|
||||
cy.get('@btn').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
@@ -98,7 +100,7 @@ describe('Open uni population tests', () => {
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
// Now "Class statistics" in UI
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click()
|
||||
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
|
||||
}
|
||||
|
||||
const selectStudyTrack = studyTrack => {
|
||||
cy.contains('Select study track')
|
||||
cy.get('[data-cy=select-study-track]').click()
|
||||
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
|
||||
cy.cs('population-studytrack-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
|
||||
)
|
||||
cy.cs('population-studytrack-selector-parent').click()
|
||||
cy.cs('population-studytrack-selector-parent').within(() => cy.contains(studyTrack).click())
|
||||
}
|
||||
|
||||
describe('Population statistics tests', () => {
|
||||
const getPath = programme => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"${programme}"%7D&year=2020`
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
|
||||
}
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
@@ -23,37 +27,40 @@ describe('Population statistics tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
|
||||
})
|
||||
|
||||
describe('Population search', () => {
|
||||
it('Info box works', () => {
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="PopulationSearch-open-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('contain', 'Tässä osiossa voi tarkastella')
|
||||
cy.get('[data-cy="PopulationSearch-close-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
|
||||
cy.cs('PopulationSearch-info-box-content').should('be.visible')
|
||||
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
})
|
||||
|
||||
it('Form is usable', () => {
|
||||
// TODO: Delete MUI
|
||||
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
|
||||
cy.contains('Search for class')
|
||||
cy.contains('See class').should('be.disabled')
|
||||
cy.contains('Class of')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('.form-control').as('enrollmentSelect')
|
||||
})
|
||||
|
||||
cy.get('@enrollmentSelect')
|
||||
.its(`${[0]}.value`)
|
||||
.then(beforeVal => {
|
||||
cy.get('@enrollmentSelect').click()
|
||||
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
|
||||
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
|
||||
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
|
||||
})
|
||||
cy.cs('population-year-selector').as('yearSelect')
|
||||
cy.cs('population-year-decrement').as('yearDecrement')
|
||||
cy.cs('population-year-increment').as('yearIncrement')
|
||||
|
||||
cy.contains('No study tracks available for this programme')
|
||||
cy.get('@yearSelect').click()
|
||||
cy.contains('2018 - 2019').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
cy.get('@yearDecrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
|
||||
cy.get('@yearIncrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
|
||||
cy.cs('population-studytrack-selector').within(() => {
|
||||
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
|
||||
})
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
@@ -61,21 +68,25 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
|
||||
describe('Correct population is shown for programme', () => {
|
||||
it('without study tracks', () => {
|
||||
cy.contains('Select study programme')
|
||||
// TODO: Delete MUI
|
||||
it.skip('without study tracks', () => {
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('class size 47 students')
|
||||
cy.contains('Class size 47 students')
|
||||
})
|
||||
|
||||
it('with study tracks', () => {
|
||||
// TODO: Delete MUI
|
||||
it.skip('with study tracks', () => {
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
|
||||
cy.contains('studytrack MAST-MSM')
|
||||
cy.contains('class size 1 students')
|
||||
cy.contains('Class size 1 students')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +95,7 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
@@ -128,7 +139,6 @@ describe('Population statistics tests', () => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
@@ -143,8 +153,9 @@ describe('Population statistics tests', () => {
|
||||
}))
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
|
||||
cy.cs('credits-gained-table-All students of the class').within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
|
||||
cy.get('th').eq(1).contains('(49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
@@ -192,7 +203,7 @@ describe('Population statistics tests', () => {
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
cy.cs('credit-statistics-tab').click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
@@ -219,7 +230,7 @@ describe('Population statistics tests', () => {
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains('h5', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
@@ -261,47 +272,39 @@ describe('Population statistics tests', () => {
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
it('Courses data is changed when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('curriculum-picker').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
cy.contains('Students (27)')
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
|
||||
cy.contains('DIGI-100').should('exist')
|
||||
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click({ force: true })
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.contains('DIGI-100').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when filtered students change', () => {
|
||||
it('Courses data is changed when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
})
|
||||
|
||||
cy.contains('Students (16)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,6 +330,7 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
@@ -400,29 +404,23 @@ describe('Population statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
})
|
||||
|
||||
it('Population statistics is visible', () => {
|
||||
cy.get('.card').within(() => {
|
||||
cy.cs('PopulationQueryCard').within(() => {
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
})
|
||||
|
||||
it('Only correct panes are visible', () => {
|
||||
const correctPanes = [
|
||||
'Credit accumulation (for 27 students)',
|
||||
'Credit statistics',
|
||||
'Age distribution',
|
||||
'Courses of class',
|
||||
]
|
||||
cy.get('.accordion.ui.fluid.styled').within(() => {
|
||||
cy.get('.title').should('have.length', correctPanes.length)
|
||||
correctPanes.forEach((pane, index) => {
|
||||
cy.get('.title').eq(index).contains(pane)
|
||||
})
|
||||
it('Only correct panels are visible', () => {
|
||||
cy.cs('panelview-parent').within(() => {
|
||||
cy.cs('Credit accumulation (for 27 students)')
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of class')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,12 +20,13 @@ const typeStudentNumberAndClick = studentNumber => {
|
||||
describe('Students tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init()
|
||||
cy.init('')
|
||||
cy.contains('Students').click()
|
||||
cy.url().should('include', '/students')
|
||||
cy.contains('Show student names')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Students search form is usable', () => {
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
@@ -159,12 +160,10 @@ describe('Students tests', () => {
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
.within(() => cy.get('a').click())
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
|
||||
@@ -66,19 +66,19 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Credit accumulation (for 2 students)')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.cs('Students (2)')
|
||||
cy.contains('Students (2)')
|
||||
cy.cs('Credit statistics').should('not.exist')
|
||||
})
|
||||
|
||||
it('students table has the correct tabs', () => {
|
||||
cy.cs('Students (2)').click()
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
@@ -87,7 +87,7 @@ describe('Study guidance group tests', () => {
|
||||
'Study programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.cs('Students (2)').click()
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
@@ -110,28 +110,31 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.cs('Students (3)')
|
||||
cy.contains('Students (3)')
|
||||
})
|
||||
|
||||
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
cy.cs('Credit accumulation (for 3 students)').click()
|
||||
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="true"]')
|
||||
cy.get('.ui.mini.icon.button.credit-date-filter-input').contains('01.08.2020')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
|
||||
cy.get('.date-picker')
|
||||
.eq(0)
|
||||
.within(() => cy.get('input').should('have.value', '01.08.2020'))
|
||||
cy.contains('Reset All Filters')
|
||||
cy.get('.ui.primary.button').contains('Show all credits').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
})
|
||||
|
||||
it('students table has the correct tabs', () => {
|
||||
cy.cs('Students (3)').click()
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
@@ -151,7 +154,7 @@ describe('Study guidance group tests', () => {
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
]
|
||||
cy.cs('Students (3)').click()
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
/* eslint-disable cypress/unsafe-to-chain-command */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const today = new Date()
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
|
||||
@@ -272,6 +284,10 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
@@ -357,13 +373,13 @@ describe('Study programme overview', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('class size 8 students')
|
||||
cy.contains('Class size 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
cy.contains('Class size 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
@@ -373,9 +389,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 26 students')
|
||||
cy.contains('3 students out of 26 shown')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -437,8 +453,9 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('study-track-select').click()
|
||||
cy.wait('@stQuery')
|
||||
cy.contains('All students of the programme')
|
||||
cy.cs('study-track-select').click()
|
||||
cy.contains('Ekonometria')
|
||||
cy.contains('Tietojenkäsittelyteoria')
|
||||
cy.contains('Tilastotiede')
|
||||
@@ -464,9 +481,9 @@ describe('Study programme overview', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Showing 10 out of 30 students')
|
||||
})
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
@@ -664,6 +681,7 @@ describe('Study programme overview', () => {
|
||||
cy.cs('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
|
||||
@@ -51,7 +51,8 @@ describe('University view', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
// Nothing in this test is explained. Non of these values are verified.
|
||||
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('bachelors-faculty-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
|
||||
+12
-7
@@ -72,14 +72,19 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.visit('/populations')
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
cy.get("div[role='option']").should('have.length', 2)
|
||||
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
cy.contains('h6', 'Degree programme')
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
|
||||
cy.cs('population-programme-selector-parent').within(() =>
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
|
||||
+59
-43
@@ -79,16 +79,19 @@ const userHeaders = [
|
||||
]
|
||||
|
||||
/**
|
||||
Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
|
||||
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
|
||||
*/
|
||||
Cypress.Commands.add('init', (path = '', userId = 'basic') => {
|
||||
cy.intercept('', req => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
|
||||
cy.intercept({ hostname: 'localhost', url: '**', method: '*' }, req => {
|
||||
req.headers = headersToUse
|
||||
})
|
||||
const { baseUrl } = Cypress.config()
|
||||
cy.visit(baseUrl.concat(path))
|
||||
|
||||
// TODO: Add wait for visit url.
|
||||
// This is block by course population query.
|
||||
cy.visit({ method: 'GET', url: path, headers: headersToUse })
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -104,27 +107,59 @@ Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item specified by `index` number from a semantic-ui dropdown with
|
||||
* `data-cy` attribute value `name`.
|
||||
* Select item from FilterSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
|
||||
const subject = cy.wrap(s)
|
||||
const indexes = Array.isArray(index) ? index : [index]
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
|
||||
indexes.forEach(i => {
|
||||
subject.click()
|
||||
|
||||
if (typeof i === 'number') {
|
||||
subject.children('.menu').children().eq(i).click({ force: true })
|
||||
} else {
|
||||
subject.children('.menu').containing(i).eq(0).click({ force: true })
|
||||
}
|
||||
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
|
||||
// Close multiple selection so it does not block elements underneath it.
|
||||
if (Array.isArray(index)) {
|
||||
cy.wrap(s).children('.icon').click({ force: true })
|
||||
}
|
||||
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
|
||||
})
|
||||
|
||||
/**
|
||||
* Set range selector values FilterRange.
|
||||
* If the reset flag is set, the function tries to reset the original values.
|
||||
*/
|
||||
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
|
||||
const minText = min.toString()
|
||||
const maxText = max.toString()
|
||||
const expectedText = expected.toString()
|
||||
|
||||
const parent = `${filter}-filter-card`
|
||||
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeStart')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMin => {
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeEnd')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
|
||||
|
||||
// This should be linked to other checking functions.
|
||||
// Maybe one day.
|
||||
cy.contains(`Students (${expectedText})`)
|
||||
|
||||
if (reset) {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
@@ -142,22 +177,3 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
|
||||
})
|
||||
})
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const today = new Date()
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEmptyYears,
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
require('@cypress/code-coverage/support')
|
||||
require('./commands')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Cypress.on('uncaught:exception', error => {
|
||||
/* returning false here prevents Cypress from failing the test */
|
||||
return true
|
||||
|
||||
+208
-135
@@ -1,183 +1,256 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginCypress from 'eslint-plugin-cypress/flat'
|
||||
import pluginImport from 'eslint-plugin-import'
|
||||
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import pluginReact from 'eslint-plugin-react'
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import tsEslint from 'typescript-eslint'
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
import globals from 'globals'
|
||||
import eslintPluginCypress from 'eslint-plugin-cypress'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import eslintPluginReact from 'eslint-plugin-react'
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginImportX from 'eslint-plugin-import-x'
|
||||
import { createNodeResolver } from 'eslint-plugin-import-x'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
{
|
||||
ignores: ['eslint.config.mjs', '**/build/', '**/dist/', '**/node_modules/'],
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'eslint.config.mjs',
|
||||
'**/build/**',
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/instrumented/**',
|
||||
'**/coverage/**',
|
||||
'nyc-config.cjs',
|
||||
'.lintstagedrc.mjs',
|
||||
'cypress.config.js',
|
||||
]
|
||||
},
|
||||
|
||||
// Global options (excl. e2e)
|
||||
{
|
||||
ignores: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
eslintPluginImportX.flatConfigs.recommended,
|
||||
eslintPluginImportX.flatConfigs.typescript,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.{js,cjs}']
|
||||
}
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'import-x': eslintPluginImportX
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tsEslint.configs.recommendedTypeChecked,
|
||||
...tsEslint.configs.stylisticTypeChecked,
|
||||
pluginCypress.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
pluginImport.flatConfigs.recommended,
|
||||
pluginImport.flatConfigs.typescript,
|
||||
pluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
camelcase: 'off', // TODO: Enable this rule (at least for frontend)
|
||||
'class-methods-use-this': 'error',
|
||||
'consistent-return': 'error',
|
||||
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
|
||||
'import/no-commonjs': 'error',
|
||||
'import/no-default-export': 'error',
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import/no-unused-modules': ['error', { missingExports: true, unusedExports: true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
'no-alert': 'error',
|
||||
'no-async-promise-executor': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-console': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-param-reassign': 'error',
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
|
||||
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
'quotes': ['error', 'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/no-unknown-property': 'off', // TODO: Delete this override
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/prop-types': 'off',
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Most of these errors come from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
|
||||
'import-x/no-commonjs': 'error',
|
||||
'import-x/no-default-export': 'error',
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import-x/no-unused-modules': ['error',
|
||||
{
|
||||
missingExports: true,
|
||||
unusedExports: true,
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
|
||||
}],
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
}],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/class-methods-use-this': 'error',
|
||||
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
|
||||
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/dot-notation': 'error',
|
||||
'@typescript-eslint/prefer-destructuring': ['error',
|
||||
{ VariableDeclarator: { object: true } }
|
||||
],
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
|
||||
'@typescript-eslint/no-implied-eval': 'error',
|
||||
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
|
||||
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
...tsEslint.configs.disableTypeChecked.rules,
|
||||
'dot-notation': 'error',
|
||||
settings: {
|
||||
'import-x/internal-regex': '^@oodikone/shared/',
|
||||
'import-x/resolver-next': [
|
||||
createTypeScriptImportResolver({
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json'
|
||||
],
|
||||
}),
|
||||
createNodeResolver({
|
||||
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
|
||||
})
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['services/backend/src/routes/*.{ts,js}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
}
|
||||
},
|
||||
|
||||
// Disable type-aware linting for javascript files
|
||||
{
|
||||
files: ['{updater,services}/**/*.{js,cjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked]
|
||||
},
|
||||
|
||||
// Frontend camelCase and react
|
||||
{
|
||||
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': eslintPluginReact,
|
||||
},
|
||||
extends: [
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
eslintPluginReactHooks.configs['recommended-latest'],
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'warn',
|
||||
'react/prop-types': 'off', // Typescript but worse
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/function-component-definition': ['error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error',
|
||||
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
|
||||
],
|
||||
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/jsx-tag-spacing': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'react': {
|
||||
'version': 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Cypress should only use rules provided by the plugin below
|
||||
{
|
||||
files: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslintPluginCypress.configs.recommended
|
||||
]
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
{
|
||||
files: [
|
||||
'services/backend/**/*.js',
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'updater/sis-updater-scheduler/**/*.js'
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'import-x/no-commonjs': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Rapodiff
|
||||
{
|
||||
files: ['services/backend/src/rapodiff/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Migration files
|
||||
{
|
||||
files: ['services/**/shared/**/*'],
|
||||
files: [
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'services/backend/src/database/**/*.js'
|
||||
],
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-commonjs': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: See if these can be disabled and or fix
|
||||
{
|
||||
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
|
||||
rules: {
|
||||
// TODO: Most of these overrides should probably be removed
|
||||
'consistent-return': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
'import-x/no-default-export': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@oodikone/shared/',
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: '18.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
|
||||
|
||||
module.exports = {
|
||||
Generated
+2611
-2806
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -49,7 +49,7 @@
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
@@ -64,7 +64,6 @@
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1"
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require('./src/util/sentry') // Sentry must be the first import
|
||||
require('./src/app')
|
||||
const logger = require('./src/util/logger')
|
||||
import './src/util/sentry' // Sentry must be the first import
|
||||
import './src/app'
|
||||
import logger from './src/util/logger'
|
||||
|
||||
process.on('unhandledRejection', reason => {
|
||||
logger.error({ message: 'unhandledRejection: ', meta: reason })
|
||||
Generated
+7
-1
@@ -14,11 +14,11 @@
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
@@ -2339,6 +2339,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
require('express-async-errors')
|
||||
const express = require('express')
|
||||
import 'express-async-errors'
|
||||
import express from 'express'
|
||||
|
||||
const { baseUrl, backendPort } = require('./config')
|
||||
const { initializeDatabaseConnection, dbConnections } = require('./database/connection')
|
||||
const { startCron } = require('./events')
|
||||
const routes = require('./routes').default
|
||||
const logger = require('./util/logger')
|
||||
require('./worker/worker')
|
||||
import { baseUrl, backendPort } from './config'
|
||||
import { initializeDatabaseConnection, dbConnections } from './database/connection'
|
||||
import { startCron } from './events'
|
||||
import routes from './routes'
|
||||
import logger from './util/logger'
|
||||
import './worker/worker'
|
||||
|
||||
initializeDatabaseConnection()
|
||||
.then(() => {
|
||||
dbConnections.connect()
|
||||
void dbConnections.connect()
|
||||
|
||||
dbConnections.on('connect', () => {
|
||||
logger.info('Connected to sis db successfully')
|
||||
@@ -1,105 +0,0 @@
|
||||
// Node env to use
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
const pateToken = process.env.PATE_API_TOKEN || ''
|
||||
|
||||
// Jami
|
||||
const jamiUrl = process.env.JAMI_URL || ''
|
||||
|
||||
// Importer client
|
||||
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
|
||||
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
|
||||
const importerDbApiUser = process.env.IMPORTER_DB_API_USER || ''
|
||||
const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD || ''
|
||||
|
||||
// Sisu
|
||||
const sisUrl = process.env.SIS_HOST
|
||||
const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
|
||||
const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
|
||||
|
||||
// Networking: Urls & ports
|
||||
const baseUrl = isDev ? '/api' : ''
|
||||
const frontUrl = process.env.FRONT_URL
|
||||
const backendPort = 8080
|
||||
|
||||
// System run in whose environment
|
||||
const serviceProvider = process.env.SERVICE_PROVIDER ? process.env.SERVICE_PROVIDER.toLowerCase() : 'toska'
|
||||
|
||||
// Optional logout-url configuration
|
||||
const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
|
||||
|
||||
// Other stuff
|
||||
const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
|
||||
|
||||
const rootOrgId = process.env.ROOT_ORG_ID || 'hy-university-root-id'
|
||||
|
||||
const languageCenterViewEnabled =
|
||||
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
|
||||
|
||||
const concurrentWorkers = process.env.CONCURRENT_WORKERS ? parseInt(process.env.CONCURRENT_WORKERS, 10) : 2
|
||||
|
||||
let DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS, 10)
|
||||
if (Number.isNaN(DB_MAX_CONNECTIONS)) {
|
||||
DB_MAX_CONNECTIONS = 5 // sequelize's default
|
||||
}
|
||||
let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
|
||||
if (DB_MAX_CRON_CONNECTIONS < 1) {
|
||||
DB_MAX_CRON_CONNECTIONS = 1
|
||||
}
|
||||
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
const redis = process.env.REDIS
|
||||
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
|
||||
|
||||
const KONE_PASSWORD = process.env.KONE_PASSWORD || 'postgres'
|
||||
const USER_PASSWORD = process.env.USER_PASSWORD || 'postgres'
|
||||
const SIS_PASSWORD = process.env.SIS_PASSWORD || 'postgres'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_USER,
|
||||
CRYPT_KEY,
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
DB_MAX_CRON_CONNECTIONS,
|
||||
CRON_SCHEDULE,
|
||||
redis,
|
||||
SECRET_TOKEN,
|
||||
DB_SCHEMA_KONE,
|
||||
KONE_PASSWORD,
|
||||
USER_PASSWORD,
|
||||
SIS_PASSWORD,
|
||||
requiredGroup,
|
||||
SIS_DB_URL,
|
||||
SIS_UPDATER_URL,
|
||||
runningInCI,
|
||||
isProduction,
|
||||
pateToken,
|
||||
jamiUrl,
|
||||
baseUrl,
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerUrl,
|
||||
importerToken,
|
||||
importerDbApiUser,
|
||||
importerDbApiPassword,
|
||||
isDev,
|
||||
isStaging,
|
||||
rootOrgId,
|
||||
serviceProvider,
|
||||
configLogoutUrl,
|
||||
sisUrl,
|
||||
sisGrapqlAppAccount,
|
||||
sisGrapqlAppKey,
|
||||
languageCenterViewEnabled,
|
||||
concurrentWorkers,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Node env to use
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
export const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
export const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
export const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
export const pateToken = process.env.PATE_API_TOKEN ?? ''
|
||||
|
||||
// Jami
|
||||
export const jamiUrl = process.env.JAMI_URL ?? ''
|
||||
|
||||
// Importer client
|
||||
export const importerUrl = process.env.IMPORTER_DB_API_URL ?? ''
|
||||
export const importerToken = process.env.IMPORTER_DB_API_TOKEN ?? ''
|
||||
export const importerDbApiUser = process.env.IMPORTER_DB_API_USER ?? ''
|
||||
export const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD ?? ''
|
||||
|
||||
// Sisu
|
||||
export const sisUrl = process.env.SIS_HOST
|
||||
export const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
|
||||
export const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
|
||||
|
||||
// Networking: Urls & ports
|
||||
export const baseUrl = isDev ? '/api' : ''
|
||||
export const frontUrl = process.env.FRONT_URL
|
||||
export const backendPort = 8080
|
||||
|
||||
// System run in whose environment
|
||||
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
|
||||
|
||||
// Optional logout-url configuration
|
||||
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
|
||||
|
||||
// Other stuff
|
||||
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
|
||||
|
||||
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
|
||||
|
||||
export const languageCenterViewEnabled =
|
||||
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
|
||||
|
||||
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
|
||||
|
||||
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
|
||||
export const DB_MAX_CRON_CONNECTIONS = Math.max(1, DB_MAX_CONNECTIONS - 5)
|
||||
export const CRON_SCHEDULE = process.env.CRON_SCHEDULE ?? '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
export const redis = process.env.REDIS
|
||||
export const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE ?? 'public'
|
||||
|
||||
export const KONE_PASSWORD = process.env.KONE_PASSWORD ?? 'postgres'
|
||||
export const USER_PASSWORD = process.env.USER_PASSWORD ?? 'postgres'
|
||||
export const SIS_PASSWORD = process.env.SIS_PASSWORD ?? 'postgres'
|
||||
@@ -1,4 +1,7 @@
|
||||
// Linter was not having any of this so needed to add resolveJsonModule:true to tsconfig
|
||||
// export const { facultyCodes, ignoredFacultyCodes, magicFacultyCode } = require('../environment/organizationConfig.json')
|
||||
|
||||
export { facultyCodes, ignoredFacultyCodes, magicFacultyCode } from '../environment/organizationConfig.json'
|
||||
import organizationConfig from '../environment/organizationConfig.json'
|
||||
|
||||
export const { facultyCodes } = organizationConfig
|
||||
export const { ignoredFacultyCodes } = organizationConfig
|
||||
export const { magicFacultyCode } = organizationConfig
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// This is locked behind admin check.
|
||||
// We probably want to keep it on the server side and not share it.
|
||||
export const roles = [
|
||||
'admin',
|
||||
'courseStatistics',
|
||||
|
||||
@@ -2,7 +2,7 @@ import EventEmitter from 'events'
|
||||
import { Sequelize } from 'sequelize-typescript'
|
||||
import { SequelizeStorage, Umzug } from 'umzug'
|
||||
|
||||
import conf from '../config'
|
||||
import * as conf from '../config'
|
||||
import {
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
@@ -96,7 +96,7 @@ class DbConnection extends EventEmitter {
|
||||
message: `Sis database connection failed! Attempt ${attempt}/${this.RETRY_ATTEMPTS}`,
|
||||
meta: error,
|
||||
})
|
||||
setTimeout(() => this.connect(attempt + 1), 1000 * attempt)
|
||||
setTimeout(() => void this.connect(attempt + 1), 1000 * attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,14 +106,15 @@ const dailyJobs = async () => {
|
||||
export const startCron = () => {
|
||||
if (isProduction && !runningInCI) {
|
||||
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
|
||||
schedule(CRON_SCHEDULE, async () => {
|
||||
schedule(CRON_SCHEDULE, () => {
|
||||
logger.info('Running daily jobs from cron')
|
||||
await dailyJobs()
|
||||
void dailyJobs()
|
||||
})
|
||||
schedule('0 4 * * 3', async () => {
|
||||
schedule('0 4 * * 3', () => {
|
||||
logger.info("Deleting users who haven't logged in for 18 months")
|
||||
const [, result] = (await deleteOutdatedUsers()) as { rowCount: number }[]
|
||||
logger.info(`Deleted ${result.rowCount} users`)
|
||||
|
||||
const deletedOutdatedUsers = deleteOutdatedUsers() as Promise<{ rowCount: number }[]>
|
||||
void deletedOutdatedUsers.then(([_, result]) => logger.info(`Deleted ${result.rowCount} users`))
|
||||
})
|
||||
schedule('0 19 * * 1', () => {
|
||||
logger.info('Updating students whose studyplans have not been updated recently')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-return */
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
|
||||
@@ -66,8 +66,9 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
if (!username) missingHeaders.push('uid')
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
const reqUser = username ?? 'Anonymous user'
|
||||
throw new ApplicationError(
|
||||
`Not enough data in request headers, the following headers were missing: ${missingHeaders.join(', ')}`,
|
||||
`${reqUser} requested ${req.url} without valid request headers. Missing: ${missingHeaders.join(', ')}`,
|
||||
403,
|
||||
{ logoutUrl }
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { orderBy } = require('lodash')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import compression from 'compression'
|
||||
import cors from 'cors'
|
||||
import express, { Express } from 'express'
|
||||
import { Express, json as jsonExpress } from 'express'
|
||||
|
||||
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
|
||||
import accessLogger from './middleware/accessLogger'
|
||||
@@ -39,11 +39,11 @@ import usersFd from './routes/usersFd'
|
||||
|
||||
const routes = (app: Express, url: string) => {
|
||||
app.use(cors({ credentials: true, origin: frontUrl }))
|
||||
app.use(express.json())
|
||||
app.use(jsonExpress())
|
||||
app.use(compression())
|
||||
|
||||
app.use(headersMiddleware)
|
||||
app.use(currentUserMiddleware)
|
||||
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
app.use(accessLogger)
|
||||
app.use(`${url}/login`, login)
|
||||
app.use(url, courses)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
|
||||
|
||||
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
|
||||
const { user } = req
|
||||
if (hasFullAccessToStudentData(user.roles)) {
|
||||
const result = await getCloseToGraduationData()
|
||||
|
||||
@@ -1,101 +1,109 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types/course'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetCoursesRequest extends Request {
|
||||
query: {
|
||||
name: string
|
||||
code: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
type CoursesMultiReqBody = never
|
||||
type CoursesMultiResBody = { courses: CourseWithSubsId[] }
|
||||
type CoursesMultiQuery = {
|
||||
name: string
|
||||
code: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) => {
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses = {}
|
||||
const courses: CourseWithSubsId[] = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? (course.subsId?.toString() ?? '') : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
}
|
||||
)
|
||||
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
type CourseYearlyStatsReqBody = never
|
||||
type CourseYearlyStatsQuery = {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific study programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send('Missing required query parameters')
|
||||
}
|
||||
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific study programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
res.json(results)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import { CurriculumPeriodModel } from '../models'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const result = await CurriculumPeriodModel.findAll()
|
||||
type CurriculumPeriodsResBody = CurriculumPeriod[]
|
||||
|
||||
router.get<never, CurriculumPeriodsResBody>('/', async (_req, res) => {
|
||||
const result: CurriculumPeriod[] = await CurriculumPeriodModel.findAll({
|
||||
raw: true,
|
||||
})
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
getCustomPopulationSearchesByUser,
|
||||
createCustomPopulationSearch,
|
||||
@@ -9,65 +11,92 @@ import {
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
type GetCustomPopulationSearchResBody = CustomPopulationSearch[]
|
||||
|
||||
router.get<never, GetCustomPopulationSearchResBody>('/', async (req, res) => {
|
||||
const { id } = req.user
|
||||
const customPopulationSearches = await getCustomPopulationSearchesByUser(id)
|
||||
res.json(customPopulationSearches)
|
||||
})
|
||||
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const { name, students } = req.body
|
||||
const { id } = req.user
|
||||
type PostCustomPopulationSearchReqBody = {
|
||||
name: string
|
||||
students: string[]
|
||||
}
|
||||
type PostCustomPopulationSearchResBody = CustomPopulationSearch
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
router.post<never, CanError<PostCustomPopulationSearchResBody>, PostCustomPopulationSearchReqBody>(
|
||||
'/',
|
||||
async (req, res) => {
|
||||
const { name, students } = req.body
|
||||
const { id } = req.user
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
}
|
||||
if (students && !Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
|
||||
res.json(customPopulationSearch)
|
||||
}
|
||||
if (students && !Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
)
|
||||
|
||||
type PutCustomPopulationSearchReqBody = {
|
||||
students: string[]
|
||||
}
|
||||
type PutCustomPopulationSearchResBody = CustomPopulationSearch
|
||||
|
||||
router.put<never, CanError<PutCustomPopulationSearchResBody>, PutCustomPopulationSearchReqBody>(
|
||||
'/:id',
|
||||
async (req, res) => {
|
||||
const { students } = req.body
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
if (!students) {
|
||||
return res.status(400).json({ error: 'Students missing' })
|
||||
}
|
||||
if (!Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
|
||||
if (!updatedPopulationSearch) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(updatedPopulationSearch)
|
||||
}
|
||||
)
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
|
||||
res.json(customPopulationSearch)
|
||||
})
|
||||
type DeleteCustomPopulationSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type DeleteCustomPopulationSearchResBody = string
|
||||
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
const { students } = req.body
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
router.delete<never, CanError<DeleteCustomPopulationSearchResBody>, never, DeleteCustomPopulationSearchParams>(
|
||||
'/:id',
|
||||
async (req, res) => {
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
|
||||
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
|
||||
|
||||
if (!deletedSuccessfully) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(id)
|
||||
}
|
||||
if (!students) {
|
||||
return res.status(400).json({ error: 'Students missing' })
|
||||
}
|
||||
if (!Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
|
||||
if (!updatedPopulationSearch) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(updatedPopulationSearch)
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
|
||||
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
|
||||
|
||||
if (!deletedSuccessfully) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(id)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -193,6 +193,7 @@ router.get(
|
||||
|
||||
const { graduated, special_groups: specialGroups } = req.query
|
||||
|
||||
/* Fetch cached data if exists */
|
||||
const data = await getFacultyStudentStats(code, specialGroups, graduated)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
@@ -203,9 +204,10 @@ router.get(
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
let updatedStats: any = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
|
||||
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
|
||||
|
||||
if (updatedStats) {
|
||||
updatedStats = await setFacultyStudentStats(updatedStats, specialGroups, graduated)
|
||||
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { omit } from 'lodash'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { FormattedUser } from '../types/user'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
type LoginReqBody = never
|
||||
type LoginResBody = {
|
||||
user: Omit<FormattedUser, 'studentsUserCanAccess'>
|
||||
logoutUrl?: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
|
||||
const { user, logoutUrl } = req
|
||||
|
||||
if (!user) {
|
||||
@@ -13,7 +21,7 @@ router.get('/', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
res.send({
|
||||
user: omit(user, ['studentsUserCanAccess']),
|
||||
user: omitKeys(user, ['studentsUserCanAccess']),
|
||||
logoutUrl,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,100 +1,47 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection, uniq } from 'lodash'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
type PopulationstatisticsQuery,
|
||||
type PopulationstatisticsReqBody,
|
||||
type PopulationstatisticsResBody,
|
||||
type PopulationstatisticsbycourseResBody,
|
||||
type PopulationstatisticsbycourseReqBody,
|
||||
type PopulationstatisticsbycourseParams,
|
||||
type PostByStudentNumbersResBody,
|
||||
type PostByStudentNumbersReqBody,
|
||||
type PopulationstatisticsStudyprogrammesResBody,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
} from '@oodikone/shared/routes/populations'
|
||||
import { GenderCode, Unification, Unarray } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRightModel } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { encrypt, type EncrypterData } from '../services/encrypt'
|
||||
import { encrypt } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
|
||||
import { getStudentTags } from '../services/populations/getStudentData'
|
||||
import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
import { ParsedCourse } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type EncryptedStudent = EncrypterData
|
||||
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
|
||||
return (student as EncryptedStudent)?.encryptedData !== undefined
|
||||
}
|
||||
|
||||
export type PopulationstatisticsCoursesResBody = CanError<Bottlenecks>
|
||||
export type PopulationstatisticsCoursesReqBody = {
|
||||
// NOTE: Encrypted students have their iv in selectedStudents
|
||||
selectedStudents: string[] | EncryptedStudent[]
|
||||
selectedStudentsByYear?: { [year: string]: string[] }
|
||||
courses?: string[]
|
||||
}
|
||||
|
||||
// NOTE: POST instead of GET because of too long params and "sensitive" data
|
||||
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
|
||||
'/v4/populationstatistics/courses',
|
||||
async (req, res) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
|
||||
|
||||
const hasFullAccess = hasFullAccessToStudentData(roles)
|
||||
|
||||
const isEncrypted = selectedStudents.some(isEncryptedStudent)
|
||||
const hasAccessToStudents =
|
||||
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
|
||||
|
||||
if (!hasAccessToStudents && !hasFullAccess) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
|
||||
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
|
||||
}
|
||||
|
||||
const requiredFields = [selectedStudents, selectedCourses]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
|
||||
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
|
||||
}
|
||||
|
||||
const result = await bottlenecksOf(
|
||||
selectedStudents as string[],
|
||||
selectedStudentsByYear,
|
||||
selectedCourses,
|
||||
isEncrypted
|
||||
)
|
||||
|
||||
return res.json(result)
|
||||
}
|
||||
)
|
||||
|
||||
export type PopulationstatisticsResBody = CanError<{ students: any }>
|
||||
export type PopulationstatisticsReqBody = never
|
||||
export type PopulationstatisticsQuery = {
|
||||
semesters: string[]
|
||||
studentStatuses?: string[]
|
||||
// NOTE: This param is a JSON -object
|
||||
studyRights: string
|
||||
year: string
|
||||
months?: string
|
||||
years?: string[]
|
||||
}
|
||||
|
||||
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
|
||||
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
|
||||
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
|
||||
|
||||
const months = req.query.months ?? '12'
|
||||
|
||||
// NOTE: `year` isn't needed anymore if `years` is defined
|
||||
const requiredFields = [year, semesters, requestedStudyRightsJSON]
|
||||
const requiredFields = [years, semesters, programme]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
|
||||
}
|
||||
|
||||
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
@@ -107,75 +54,39 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
|
||||
await safeJSONParse(requestedStudyRightsJSON)
|
||||
if (requestedStudyRights === null) {
|
||||
return res.status(400).json({ error: 'Invalid studyrights value!' })
|
||||
}
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
|
||||
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme!)
|
||||
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
|
||||
|
||||
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
let result
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiYearStudents = Promise.all(
|
||||
req.query.years.map(year => {
|
||||
const yearAsNumber = +year
|
||||
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
|
||||
return optimizedStatisticsOf({
|
||||
...req.query,
|
||||
userId,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months: monthsForCurrentYear,
|
||||
})
|
||||
})
|
||||
)
|
||||
const { startDate, endDate } = parseDateRangeFromParams({
|
||||
...req.query,
|
||||
years: req.query.years,
|
||||
})
|
||||
|
||||
const populationStudentsMerger = (multiYearStudents: any) => {
|
||||
const samples = { students: [], courses: [] as any[] }
|
||||
const uniqueCourseCodes = new Set<string>()
|
||||
const studentNumbers = await getStudentNumbersWithStudyRights({
|
||||
programmeCodes: [programme],
|
||||
startDate,
|
||||
endDate,
|
||||
studentStatuses,
|
||||
})
|
||||
|
||||
for (const year of multiYearStudents) {
|
||||
samples.students = samples.students.concat(year.students)
|
||||
for (const course of year.courses) {
|
||||
if (!uniqueCourseCodes.has(course.code)) {
|
||||
uniqueCourseCodes.add(course.code)
|
||||
samples.courses.push(course)
|
||||
}
|
||||
}
|
||||
}
|
||||
const studyRights = [programme]
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
result = populationStudentsMerger(await multiYearStudents)
|
||||
} else {
|
||||
result = await optimizedStatisticsOf({
|
||||
...req.query,
|
||||
userId,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months,
|
||||
})
|
||||
}
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
|
||||
|
||||
// Obfuscate if user has only limited study programme rights and there are any students
|
||||
if (
|
||||
'students' in result &&
|
||||
!hasFullAccessToStudentData(userRoles) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
|
||||
) {
|
||||
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
@@ -202,54 +113,38 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
|
||||
}
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
query: {
|
||||
coursecodes: string
|
||||
from: string
|
||||
to: string
|
||||
separate: string
|
||||
unifyCourses: UnifyStatus
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
|
||||
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
|
||||
router.get<
|
||||
never,
|
||||
CanError<PopulationstatisticsbycourseResBody>,
|
||||
PopulationstatisticsbycourseReqBody,
|
||||
PopulationstatisticsbycourseParams
|
||||
>('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
|
||||
|
||||
if (!coursecodes || !from || !to) {
|
||||
if (!coursecodeJSON || !from || !to) {
|
||||
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
|
||||
}
|
||||
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(JSON.parse(coursecodes), Unification.REGULAR)
|
||||
const isSeparate = separate === 'true'
|
||||
const coursecodes = JSON.parse(coursecodeJSON)
|
||||
|
||||
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
|
||||
const separate = separateString === 'true'
|
||||
const requestedYearsToCreatePopulationFrom = Math.ceil(separate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
|
||||
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
|
||||
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
|
||||
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
|
||||
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
|
||||
}
|
||||
|
||||
const studentNumbers = await findByCourseAndSemesters(
|
||||
JSON.parse(coursecodes),
|
||||
Number(from),
|
||||
Number(to),
|
||||
separate,
|
||||
unifyCourses
|
||||
)
|
||||
const result: any = await optimizedStatisticsOf(
|
||||
{
|
||||
// Useless, because studentnumbers are already filtered above by from & to.
|
||||
// We should probably refactor this to avoid more confusement.
|
||||
userId,
|
||||
year: '1900',
|
||||
studyRights: undefined,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
let courseProviders: string[] = []
|
||||
if (!hasFullAccessToStudentData(roles)) {
|
||||
courseProviders = await getCourseProvidersForCourses(JSON.parse(coursecodes))
|
||||
}
|
||||
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
|
||||
const studyRights = []
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
const result: any = await statisticsOf(studentNumbers, studyRights, tagList)
|
||||
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
|
||||
? await getCourseProvidersForCourses(coursecodes)
|
||||
: []
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
|
||||
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
@@ -295,80 +190,71 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
interface PostByStudentNumbersRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
tags?: {
|
||||
studyProgramme: string | null
|
||||
year: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
router.post<never, PostByStudentNumbersResBody, PostByStudentNumbersReqBody>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
|
||||
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
|
||||
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
|
||||
|
||||
const result = await optimizedStatisticsOf(
|
||||
{
|
||||
userId,
|
||||
year: tags?.year ?? '1900',
|
||||
studyRights: studyProgrammeCode,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
},
|
||||
filteredStudentNumbers
|
||||
)
|
||||
const { startDate } = tags?.year
|
||||
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
|
||||
: { startDate: undefined }
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
|
||||
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
})
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
|
||||
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
const allRights = uniq(programmeRights.map(programme => programme.code))
|
||||
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
|
||||
allRights.push('KH90_001', 'MH90_001')
|
||||
}
|
||||
const filteredProgrammes = hasFullAccessToStudentData(roles)
|
||||
? programmes
|
||||
: programmes.filter(programme => allRights.includes(programme.code))
|
||||
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
|
||||
(acc, curr) => {
|
||||
acc[curr.code] = curr
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
res.json(formattedProgrammes)
|
||||
})
|
||||
|
||||
interface GetMaxYearsRequest extends Request {
|
||||
query: {
|
||||
courseCodes: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/v3/populationstatistics/maxYearsToCreatePopulationFrom',
|
||||
async (req: GetMaxYearsRequest, res: Response) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
|
||||
return res.json({
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
unifyCourses: maxYearsToCreatePopulationFromBoth,
|
||||
})
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
}
|
||||
)
|
||||
|
||||
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
|
||||
'/v3/populationstatistics/studyprogrammes',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
const allRights = uniq(programmeRights.map(programme => programme.code))
|
||||
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
|
||||
allRights.push('KH90_001', 'MH90_001')
|
||||
}
|
||||
const filteredProgrammes = hasFullAccessToStudentData(roles)
|
||||
? programmes
|
||||
: programmes.filter(programme => allRights.includes(programme.code))
|
||||
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
|
||||
(acc, curr) => {
|
||||
acc[curr.code] = curr
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
res.json(formattedProgrammes)
|
||||
}
|
||||
)
|
||||
|
||||
router.get<
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
|
||||
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
|
||||
return res.json({
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
unifyCourses: maxYearsToCreatePopulationFromBoth,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -12,12 +12,13 @@ router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) =
|
||||
|
||||
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
|
||||
const { code, periodIds } = req.params
|
||||
const result = await getCoursesAndModules(code, periodIds)
|
||||
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
|
||||
|
||||
res.json({
|
||||
defaultProgrammeCourses: result.defaultProgrammeCourses.courses,
|
||||
defaultProgrammeModules: result.defaultProgrammeCourses.modules,
|
||||
secondProgrammeCourses: result.secondProgrammeCourses?.courses,
|
||||
secondProgrammeModules: result.secondProgrammeCourses?.modules,
|
||||
defaultProgrammeCourses: defaultProgrammeCourses.courses,
|
||||
defaultProgrammeModules: defaultProgrammeCourses.modules,
|
||||
secondProgrammeCourses: secondProgrammeCourses.courses,
|
||||
secondProgrammeModules: secondProgrammeCourses.modules,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { FormattedUser } from 'src/types'
|
||||
import { roles } from '../config/roles'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
@@ -11,103 +13,120 @@ import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
|
||||
|
||||
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
|
||||
const results = await userService.findAll()
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
type GetUserRolesResBody = readonly string[]
|
||||
|
||||
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
interface UidRequest extends Request {
|
||||
params: {
|
||||
uid: string
|
||||
type GetUserByIdParams = { uid: string }
|
||||
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
|
||||
|
||||
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
'/:uid',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { data: user, error } = await tryCatch(userService.findOne(uid))
|
||||
if (error) return res.status(400).json({ error: error.message })
|
||||
|
||||
return res.json(user)
|
||||
}
|
||||
)
|
||||
|
||||
type ModifyUserRolesReqBody = {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
type ModifyUserRolesResBody = never
|
||||
|
||||
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
|
||||
'/modify-roles',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
)
|
||||
|
||||
type UserEmailPreviewResBody = {
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
|
||||
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { data: user, error } = await tryCatch(userService.findOne(uid))
|
||||
if (error) return res.status(400).json({ error: error.message })
|
||||
|
||||
return res.json(user)
|
||||
})
|
||||
|
||||
interface ModifyRolesRequest extends Request {
|
||||
body: {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
|
||||
return res.status(204).end()
|
||||
})
|
||||
|
||||
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
|
||||
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
|
||||
res.json({ subject: accessMessageSubject, html: accessMessageText })
|
||||
})
|
||||
|
||||
interface EmailRequest extends Request {
|
||||
body: {
|
||||
email: string
|
||||
type PostUserEmailReqBody = { email: string }
|
||||
type PostUserEmailResBody = never
|
||||
|
||||
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
|
||||
'/email',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
type ElementsParams = { uid: string }
|
||||
type ElementsReqBody = { codes: string[] }
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
type PostUserUIDElementsResBody = void
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
interface ElementsRequest extends Request {
|
||||
params: {
|
||||
uid: string
|
||||
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, true)
|
||||
res.status(204).end()
|
||||
}
|
||||
body: {
|
||||
codes: string[]
|
||||
)
|
||||
|
||||
type DeleteUserUIDElementsResBody = void
|
||||
|
||||
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, false)
|
||||
res.status(204).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, true)
|
||||
res.status(204).end()
|
||||
})
|
||||
type ChangeLanguageReqBody = { language: Language }
|
||||
type PostChangeLanguageResBody = void
|
||||
|
||||
router.delete('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, false)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
interface ChangeLanguageRequest extends Request {
|
||||
body: {
|
||||
language: Language
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
|
||||
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
const { language } = req.body
|
||||
|
||||
if (!LANGUAGE_CODES.includes(language)) {
|
||||
return res.status(400).json('Invalid language')
|
||||
return res.status(400).json({ error: 'Invalid language' })
|
||||
}
|
||||
|
||||
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { Graduated, SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
@@ -49,7 +47,7 @@ export const setBasicStats = async (data, yearType: YearType, specialGroups: Spe
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
@@ -84,7 +82,7 @@ export const setCreditStats = async (data, isAcademicYear: boolean, specialGroup
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!isRelevantProgramme(id) && !isFaculty(id)) {
|
||||
return dataToRedis
|
||||
@@ -117,7 +115,7 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
@@ -141,16 +139,16 @@ export const getStudyTrackStats = async (
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(dataFromRedis)
|
||||
return JSON.parse(dataFromRedis) as StudyTrackStats & { status: string; lastUpdated: string }
|
||||
}
|
||||
|
||||
export const setStudyTrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
export const setStudyTrackStats = async (data: StudyTrackStats, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { Name, EnrollmentState } from '@oodikone/shared/types'
|
||||
|
||||
const lengthOf = (obj: object) => Object.keys(obj).length
|
||||
const percentageOf = (num: number, denom: number) => {
|
||||
if (denom === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round(((100 * num) / denom) * 100) / 100
|
||||
}
|
||||
|
||||
const fall: string[] = []
|
||||
const spring: string[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
fall[i] = `${i}-FALL`
|
||||
spring[i] = `${i}-SPRING`
|
||||
}
|
||||
|
||||
const initializePassingSemesters = () => {
|
||||
const initialValue = 0
|
||||
const passingSemesters: Record<string, number> = {
|
||||
BEFORE: initialValue,
|
||||
LATER: initialValue,
|
||||
}
|
||||
for (let i = 0; i < 7; i++) {
|
||||
passingSemesters[fall[i]] = initialValue
|
||||
passingSemesters[spring[i]] = initialValue
|
||||
}
|
||||
return passingSemesters
|
||||
}
|
||||
|
||||
const getFilteredEnrolledNoGrade = (enrollments: DynamicEnrollments, allStudents: string[]) => {
|
||||
const enrolledStudents = [...(enrollments[EnrollmentState.ENROLLED] ?? [])]
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const student of enrolledStudents) {
|
||||
if (allStudents.includes(student)) {
|
||||
continue
|
||||
}
|
||||
result[student] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type DynamicEnrollments = {
|
||||
[state in EnrollmentState]?: Set<string>
|
||||
} & {
|
||||
semesters: {
|
||||
[semester: string]: {
|
||||
[state in EnrollmentState]?: Set<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Stats = {
|
||||
students: number
|
||||
passed: number
|
||||
failed: number
|
||||
attempts: number
|
||||
improvedPassedGrade: number
|
||||
percentage: number | undefined
|
||||
passedOfPopulation: number | undefined
|
||||
triedOfPopulation: number | undefined
|
||||
perStudent: number | undefined
|
||||
passingSemesters: Record<string, number>
|
||||
passingSemestersCumulative?: Record<string, number>
|
||||
totalStudents?: number
|
||||
totalEnrolledNoGrade?: number
|
||||
percentageWithEnrollments?: number
|
||||
}
|
||||
|
||||
type Students = {
|
||||
all: Record<string, boolean>
|
||||
passed: Record<string, boolean>
|
||||
failed: Record<string, boolean>
|
||||
improvedPassedGrade: Record<string, boolean>
|
||||
markedToSemester: Record<string, boolean>
|
||||
enrolledNoGrade: Record<string, boolean>
|
||||
}
|
||||
|
||||
type Course = {
|
||||
code: string
|
||||
name: Name
|
||||
substitutions: string[]
|
||||
}
|
||||
|
||||
type Grades = {
|
||||
[grade: string]: {
|
||||
count: number
|
||||
status: {
|
||||
passingGrade: boolean
|
||||
improvedGrade: boolean
|
||||
failingGrade: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type CourseStatistics = {
|
||||
stats: Stats
|
||||
students: Students
|
||||
course: Course
|
||||
grades: Grades
|
||||
}
|
||||
|
||||
export class CourseStatsCounter {
|
||||
private course: Course
|
||||
private students: Students
|
||||
private stats: Stats
|
||||
private enrollments: DynamicEnrollments
|
||||
private grades: Grades
|
||||
|
||||
constructor(code: string, name: Name) {
|
||||
this.course = {
|
||||
code,
|
||||
name,
|
||||
substitutions: [],
|
||||
}
|
||||
this.students = {
|
||||
all: {},
|
||||
passed: {},
|
||||
failed: {},
|
||||
improvedPassedGrade: {},
|
||||
markedToSemester: {},
|
||||
enrolledNoGrade: {},
|
||||
}
|
||||
this.stats = {
|
||||
students: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
attempts: 0,
|
||||
improvedPassedGrade: 0,
|
||||
percentage: undefined,
|
||||
passedOfPopulation: undefined,
|
||||
triedOfPopulation: undefined,
|
||||
perStudent: undefined,
|
||||
passingSemesters: initializePassingSemesters(),
|
||||
}
|
||||
this.enrollments = {
|
||||
semesters: {},
|
||||
}
|
||||
this.grades = {}
|
||||
}
|
||||
|
||||
private markAttempt() {
|
||||
this.stats.attempts += 1
|
||||
}
|
||||
|
||||
private markToAll(studentNumber: string) {
|
||||
this.students.all[studentNumber] = true
|
||||
}
|
||||
|
||||
private markPassedSemester(semester: string) {
|
||||
this.stats.passingSemesters[semester]++
|
||||
}
|
||||
|
||||
private markToSemester(studentNumber: string) {
|
||||
this.students.markedToSemester[studentNumber] = true
|
||||
}
|
||||
|
||||
public markCredit(
|
||||
studentNumber: string,
|
||||
grade: string,
|
||||
passed: boolean,
|
||||
failed: boolean,
|
||||
improved: boolean,
|
||||
semester: string
|
||||
) {
|
||||
this.markAttempt()
|
||||
this.markGrade(grade, passed, failed, improved)
|
||||
this.markToAll(studentNumber)
|
||||
if (passed) {
|
||||
if (!this.students.markedToSemester[studentNumber]) {
|
||||
this.markPassedSemester(semester)
|
||||
this.markToSemester(studentNumber)
|
||||
}
|
||||
this.markPassingGrade(studentNumber)
|
||||
} else if (improved) {
|
||||
this.markImprovedGrade(studentNumber)
|
||||
} else if (failed) {
|
||||
this.markFailedGrade(studentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
|
||||
this.enrollments[state] ??= new Set()
|
||||
this.enrollments[state].add(studentNumber)
|
||||
this.enrollments.semesters[semester] ??= {}
|
||||
this.enrollments.semesters[semester][state] ??= new Set()
|
||||
this.enrollments.semesters[semester][state].add(studentNumber)
|
||||
}
|
||||
|
||||
private failedBefore(studentNumber: string) {
|
||||
return this.students.failed[studentNumber] !== undefined
|
||||
}
|
||||
|
||||
private passedBefore(studentNumber: string) {
|
||||
return this.students.passed[studentNumber] !== undefined
|
||||
}
|
||||
|
||||
private removeFromFailed(studentNumber: string) {
|
||||
delete this.students.failed[studentNumber]
|
||||
}
|
||||
|
||||
private markPassingGrade(studentNumber: string) {
|
||||
this.students.passed[studentNumber] = true
|
||||
if (this.failedBefore(studentNumber)) {
|
||||
this.removeFromFailed(studentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private markImprovedGrade(studentNumber: string) {
|
||||
this.removeFromFailed(studentNumber)
|
||||
this.students.improvedPassedGrade[studentNumber] = true
|
||||
this.students.passed[studentNumber] = true
|
||||
}
|
||||
|
||||
private markFailedGrade(studentNumber: string) {
|
||||
if (this.passedBefore(studentNumber)) {
|
||||
this.removeFromFailed(studentNumber)
|
||||
} else {
|
||||
this.students.failed[studentNumber] = true
|
||||
}
|
||||
}
|
||||
|
||||
private markGrade(grade: string, passingGrade: boolean, failingGrade: boolean, improvedGrade: boolean) {
|
||||
const gradeCount = this.grades[grade] ? this.grades[grade].count || 0 : 0
|
||||
this.grades[grade] = { count: gradeCount + 1, status: { passingGrade, improvedGrade, failingGrade } }
|
||||
}
|
||||
|
||||
public addCourseSubstitutions(substitutions: string[]) {
|
||||
this.course.substitutions = substitutions
|
||||
}
|
||||
|
||||
private getPassingSemestersCumulative() {
|
||||
const { passingSemesters } = this.stats
|
||||
const attemptStats: Record<string, number> = {}
|
||||
|
||||
attemptStats.BEFORE = passingSemesters.BEFORE
|
||||
attemptStats['0-FALL'] = passingSemesters.BEFORE + passingSemesters['0-FALL']
|
||||
attemptStats['0-SPRING'] = attemptStats['0-FALL'] + passingSemesters['0-SPRING']
|
||||
|
||||
for (let i = 1; i < 7; i++) {
|
||||
attemptStats[fall[i]] = attemptStats[spring[i - 1]] + passingSemesters[fall[i]]
|
||||
attemptStats[spring[i]] = attemptStats[fall[i]] + passingSemesters[spring[i]]
|
||||
}
|
||||
|
||||
attemptStats.LATER = attemptStats['6-SPRING'] + passingSemesters.LATER
|
||||
|
||||
return attemptStats
|
||||
}
|
||||
|
||||
public getFinalStats(populationCount: number): CourseStatistics {
|
||||
const { stats, students, course, grades } = this
|
||||
const allStudents = Object.keys(students.all)
|
||||
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, allStudents)
|
||||
|
||||
students.all = { ...students.all, ...filteredEnrolledNoGrade }
|
||||
students.enrolledNoGrade = filteredEnrolledNoGrade
|
||||
stats.students = lengthOf(students.all)
|
||||
stats.passed = lengthOf(students.passed)
|
||||
stats.failed = lengthOf(students.failed)
|
||||
stats.improvedPassedGrade = lengthOf(students.improvedPassedGrade)
|
||||
stats.percentage = percentageOf(stats.passed, stats.students)
|
||||
stats.passedOfPopulation = percentageOf(stats.passed, populationCount)
|
||||
stats.triedOfPopulation = percentageOf(stats.students, populationCount)
|
||||
stats.perStudent = percentageOf(stats.attempts, stats.passed + stats.failed) / 100
|
||||
stats.passingSemestersCumulative = this.getPassingSemestersCumulative()
|
||||
stats.totalStudents = stats.students
|
||||
stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade)
|
||||
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
|
||||
|
||||
return { stats, students, course, grades }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { max, min } from 'lodash'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { getSemesterNameByCode } from '../semesters'
|
||||
import { getSemesterNames } from '../semesters'
|
||||
import { FormattedProgramme, OrganizationDetails } from './helpers'
|
||||
|
||||
type Programme = {
|
||||
name: Name
|
||||
students: { [yearCode: number]: string[] }
|
||||
passed: { [yearCode: number]: string[] }
|
||||
credits: { [yearCode: number]: number }
|
||||
students: Record<number, string[]>
|
||||
passed: Record<number, string[]>
|
||||
credits: Record<number, number>
|
||||
}
|
||||
|
||||
type Faculty = {
|
||||
@@ -22,11 +22,11 @@ type FacultyYearStats = {
|
||||
year: string
|
||||
allStudents: string[]
|
||||
allPassed: string[]
|
||||
faculties: { [facultyCode: string]: Faculty }
|
||||
faculties: Record<string, Faculty>
|
||||
allCredits: number
|
||||
}
|
||||
|
||||
type Grades = { [grade: string]: string[] }
|
||||
type Grades = Record<string, string[]>
|
||||
|
||||
type GroupAttempts = {
|
||||
grades: Grades
|
||||
@@ -37,7 +37,7 @@ type GroupAttempts = {
|
||||
}
|
||||
|
||||
type GroupStudents = {
|
||||
grades: { [studentNumber: string]: { grade: string; passed: boolean } }
|
||||
grades: Record<string, { grade: string; passed: boolean }>
|
||||
studentNumbers: string[]
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ type Student = {
|
||||
}
|
||||
|
||||
export class CourseYearlyStatsCounter {
|
||||
private groups: { [groupCode: number]: Group }
|
||||
private programmes: { [code: string]: Programme }
|
||||
private facultyStats: { [yearCode: number]: FacultyYearStats }
|
||||
private groups: Record<number, Group>
|
||||
private programmes: Record<string, Programme>
|
||||
private facultyStats: Record<number, FacultyYearStats>
|
||||
private obfuscated: boolean
|
||||
private students: Map<string, Student>
|
||||
|
||||
@@ -215,11 +215,12 @@ export class CourseYearlyStatsCounter {
|
||||
enrollment => enrollment.studentNumber === studentNumber
|
||||
)
|
||||
if (!oldEnrollment) {
|
||||
return this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
|
||||
this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
|
||||
} else {
|
||||
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
|
||||
.filter(enrollment => enrollment.studentNumber !== studentNumber)
|
||||
.concat([enrollment])
|
||||
}
|
||||
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
|
||||
.filter(enrollment => enrollment.studentNumber !== studentNumber)
|
||||
.concat([enrollment])
|
||||
}
|
||||
|
||||
private markCreditToAttempts(studentNumber: string, passed: boolean, grade: string, groupCode: number) {
|
||||
@@ -301,16 +302,21 @@ export class CourseYearlyStatsCounter {
|
||||
return `${startYear}-${endYear}`
|
||||
}
|
||||
|
||||
for (let i = first + 1; i <= last; i++) {
|
||||
if (this.groups[i]) {
|
||||
continue
|
||||
}
|
||||
const groups = Object.keys(this.groups)
|
||||
const a = first
|
||||
const b = last + 1
|
||||
const range = [...Array(b - a).keys()].map(v => v + a)
|
||||
const diff = range.filter(v => !groups.includes(v.toString()))
|
||||
|
||||
const semesters = await getSemesterNames(diff)
|
||||
|
||||
diff.forEach(i => {
|
||||
const previous = this.groups[i - 1]
|
||||
const semester = await getSemesterNameByCode(i)
|
||||
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
|
||||
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
|
||||
}
|
||||
const semester = semesters[i]
|
||||
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester
|
||||
|
||||
this.initGroup(i, name, previous.coursecode, i)
|
||||
})
|
||||
}
|
||||
|
||||
private async parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import { EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import {
|
||||
CreditModel,
|
||||
StudentModel,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
} from '../../models'
|
||||
import { Unification } from '../../types'
|
||||
import { getIsOpen } from './helpers'
|
||||
|
||||
export const getCreditsForCourses = async (codes: string[], unification: Unification) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { Unification } from '../../types'
|
||||
import { Name, Unification } from '@oodikone/shared/types'
|
||||
|
||||
export type FormattedProgramme = {
|
||||
code: string
|
||||
@@ -12,10 +11,10 @@ export type FormattedProgramme = {
|
||||
}
|
||||
|
||||
export const getIsOpen = (unification: Unification) => {
|
||||
const options = {
|
||||
const options: Record<Unification, object> = {
|
||||
open: { [Op.eq]: true },
|
||||
regular: { [Op.eq]: false },
|
||||
unify: { [Op.in]: [false, true] },
|
||||
unify: { [Op.ne]: null },
|
||||
}
|
||||
return options[unification]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import { Op } from 'sequelize'
|
||||
import { Op, fn as dbFn, col as dbCol } from 'sequelize'
|
||||
|
||||
import { Name, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import { dateIsBetween } from '@oodikone/shared/util/datetime'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
|
||||
import { Unification } from '../../types'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import { dateIsBetween } from '../../util/datetime'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
@@ -261,59 +260,31 @@ const getYearlyStatsOfNew = async (
|
||||
}
|
||||
|
||||
export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unification: Unification) => {
|
||||
const maxAttainmentDate = new Date(
|
||||
Math.max(
|
||||
...(
|
||||
await CourseModel.findAll({
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
},
|
||||
attributes: ['max_attainment_date'],
|
||||
})
|
||||
).map(course => new Date(course.max_attainment_date).getTime())
|
||||
)
|
||||
)
|
||||
|
||||
const attainmentThreshold = new Date(maxAttainmentDate.getFullYear(), 0, 1)
|
||||
attainmentThreshold.setFullYear(attainmentThreshold.getFullYear() - 6)
|
||||
|
||||
const credits = await CreditModel.findAll({
|
||||
const lastAttainmentDate = (await CourseModel.findOne({
|
||||
attributes: [[dbFn('MAX', dbCol('max_attainment_date')), 'date']],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gt]: attainmentThreshold,
|
||||
},
|
||||
code: { [Op.in]: courseCodes },
|
||||
},
|
||||
raw: true,
|
||||
})) as { date: Date } | null
|
||||
|
||||
if (lastAttainmentDate === null) return 0
|
||||
|
||||
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
|
||||
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
|
||||
|
||||
const attainmentsWithinThreshold = await CreditModel.count({
|
||||
attributes: [[dbFn('COUNT', dbCol('id')), 'numAttainments']],
|
||||
where: {
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
attainment_date: { [Op.gt]: attainmentDateThreshold },
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
})
|
||||
|
||||
const yearlyStudents = Object.values(
|
||||
credits.reduce(
|
||||
(res, credit) => {
|
||||
const attainmentYear = new Date(credit.attainment_date).getFullYear()
|
||||
if (!res[attainmentYear]) {
|
||||
res[attainmentYear] = 0
|
||||
}
|
||||
res[attainmentYear]++
|
||||
return res
|
||||
},
|
||||
{} as Record<number, number>
|
||||
)
|
||||
)
|
||||
|
||||
// ? What should this be called?
|
||||
const magicNumber = 1200 // * Lower this value to get a smaller result if necessary
|
||||
const maxYearsToCreatePopulationFrom = Math.max(
|
||||
Math.floor(magicNumber / (yearlyStudents.reduce((acc, curr) => acc + curr, 0) / yearlyStudents.length)),
|
||||
1
|
||||
)
|
||||
|
||||
return maxYearsToCreatePopulationFrom
|
||||
// MAGIC NUMBER
|
||||
const maxAllowedAttainments = 1000000 // * Lower this value to get a smaller result if necessary
|
||||
return Math.max(1, maxAllowedAttainments / attainmentsWithinThreshold)
|
||||
}
|
||||
|
||||
export const getCourseYearlyStats = async (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
|
||||
import type { EncrypterData } from '@oodikone/shared/types'
|
||||
import { CRYPT_KEY } from '../config'
|
||||
|
||||
const algorithm = 'aes-256-cbc'
|
||||
@@ -17,7 +18,6 @@ export const encrypt = (text: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
export type EncrypterData = { iv: string; encryptedData: string }
|
||||
export const decrypt = (data: EncrypterData) => {
|
||||
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
|
||||
const iv = Buffer.from(data.iv, 'hex')
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { groupBy, orderBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { QueryTypes } from 'sequelize'
|
||||
|
||||
import { ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { ProgrammeModuleWithRelevantAttributes } from '@oodikone/shared/types'
|
||||
import { serviceProvider } from '../../config'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
@@ -21,11 +20,6 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
|
||||
return { startDate: new Date('1800-08-01'), endDate: new Date('1801-08-01') }
|
||||
}
|
||||
|
||||
type ProgrammeModuleWithRelevantAttributes = Pick<ProgrammeModule, 'code' | 'name' | 'degreeProgrammeType'> & {
|
||||
curriculumPeriodIds: string[]
|
||||
progId: string
|
||||
}
|
||||
|
||||
// Some programme modules are not directly associated to a faculty (organization).
|
||||
// Some have intermediate organizations, such as department, so the connection must be digged up
|
||||
export const getDegreeProgrammesOfOrganization = async (organizationId: string, onlyCurrentProgrammes: boolean) => {
|
||||
@@ -86,7 +80,8 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
.flat()
|
||||
const isRelevantProgramme =
|
||||
!onlyCurrentProgrammes ||
|
||||
(onlyCurrentProgrammes && yearsOfProgramme.some(year => moment().isBetween(year.startDate, year.endDate)))
|
||||
(onlyCurrentProgrammes &&
|
||||
yearsOfProgramme.some(year => year.startDate <= new Date() && new Date() <= year.endDate))
|
||||
|
||||
if (isRelevantProgramme) {
|
||||
relevantProgrammes.push({
|
||||
@@ -101,7 +96,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
return relevantProgrammes
|
||||
}
|
||||
|
||||
export type ProgrammesOfOrganization = Awaited<ReturnType<typeof getDegreeProgrammesOfOrganization>>
|
||||
export type ProgrammesOfOrganization = ProgrammeModuleWithRelevantAttributes[]
|
||||
|
||||
export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurrentProgrammes: boolean) => {
|
||||
const organization = await OrganizationModel.findOne({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cloneDeep, omit } from 'lodash'
|
||||
|
||||
import { Name, DegreeProgrammeType } from '@oodikone/shared/types'
|
||||
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
|
||||
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
|
||||
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
|
||||
@@ -8,7 +8,7 @@ import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../studyProgramme/studyTrackStats'
|
||||
import type { ProgrammesOfOrganization } from './faculty'
|
||||
|
||||
export type LevelGraduationStats = Omit<GraduationTimes['medians'][number], 'y'> & { median: number }
|
||||
export type LevelGraduationStats = Omit<Unarray<GraduationTimes['medians']>, 'y'> & { median: number }
|
||||
|
||||
type ProgrammeStats = {
|
||||
data: Array<Omit<LevelGraduationStats, 'times'> & { code: string }>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType, DegreeProgrammeType } from '@oodikone/shared/types'
|
||||
import { redisClient } from '../redis'
|
||||
import { FacultyProgressData } from './facultyStudentProgress'
|
||||
@@ -83,7 +81,7 @@ export const setBasicStats = async (
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') {
|
||||
@@ -134,7 +132,7 @@ export const setThesisWritersStats = async (
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
@@ -183,7 +181,7 @@ export const setGraduationStats = async (data: GraduationData, programmeFilter:
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') {
|
||||
@@ -215,7 +213,7 @@ export const setFacultyProgressStats = async (
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') {
|
||||
@@ -256,7 +254,7 @@ export const setFacultyStudentStats = async (
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import { Graduated, SpecialGroups } from '@oodikone/shared/types'
|
||||
import { Graduated, SpecialGroups, Unarray } from '@oodikone/shared/types'
|
||||
import { rootOrgId } from '../../config'
|
||||
import { Unarray } from '../../types'
|
||||
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
@@ -61,8 +60,8 @@ const calculateProgressStats = (
|
||||
const allYears = Object.keys(creditCounts)
|
||||
const limitsForYears = allYears.reduce<Record<string, ReturnType<typeof createLimits>>>((acc, year) => {
|
||||
const startDate = new Date(`${year.slice(0, 4)}-08-01`)
|
||||
const lastDayOfMonth = moment().endOf('month')
|
||||
const months = Math.round(moment.duration(moment(lastDayOfMonth).diff(moment(startDate))).asMonths())
|
||||
const lastDayOfMonth = dayjs().endOf('month')
|
||||
const months = Math.round(dayjs(lastDayOfMonth).diff(startDate, 'months', true))
|
||||
const goalMonths = goalMonthsForLevels[level]
|
||||
|
||||
acc[year] = createLimits(Math.min(months, goalMonths), 0, level === 'bcMs' ? 'master' : level)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Unarray } from '../../types'
|
||||
import { Unarray } from '@oodikone/shared/types'
|
||||
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getPercentage, tableTitles } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
@@ -8,14 +8,18 @@ import type { ProgrammesOfOrganization } from './faculty'
|
||||
type StudyTrackStats = Awaited<ReturnType<typeof getStudyTrackStatsForStudyProgramme>>
|
||||
|
||||
const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats[]) => {
|
||||
const facultyTableStats: Record<string, Array<string | number>> = {}
|
||||
const facultyTableStats: Record<string, (string | number)[]> = {}
|
||||
const facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>> = {}
|
||||
const programmeTableStats: Record<string, Record<string, Array<string | number>>> = {}
|
||||
const programmeTableStats: Record<string, Record<string, (string | number)[]>> = {}
|
||||
|
||||
for (const programmeCode of programmeCodes) {
|
||||
const statsForProgramme = stats.find(programmeStats => programmeStats.id === programmeCode)?.mainStatsByTrack?.[
|
||||
programmeCode
|
||||
]
|
||||
|
||||
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === programmeCode)
|
||||
?.otherCountriesCount?.[programmeCode]
|
||||
|
||||
for (const prog of programmeCodes) {
|
||||
const statsForProgramme = stats.find(programmeStats => programmeStats.id === prog)?.mainStatsByTrack?.[prog]
|
||||
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === prog)
|
||||
?.otherCountriesCount?.[prog]
|
||||
if (!statsForProgramme) continue
|
||||
|
||||
for (const yearStats of statsForProgramme) {
|
||||
@@ -34,11 +38,11 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
|
||||
}
|
||||
}
|
||||
|
||||
if (!(prog in programmeTableStats)) {
|
||||
programmeTableStats[prog] = {}
|
||||
if (!(programmeCode in programmeTableStats)) {
|
||||
programmeTableStats[programmeCode] = {}
|
||||
}
|
||||
if (!(yearStats[0] in programmeTableStats[prog])) {
|
||||
programmeTableStats[prog][yearStats[0]] = yearStats.slice(1)
|
||||
if (!(yearStats[0] in programmeTableStats[programmeCode])) {
|
||||
programmeTableStats[programmeCode][yearStats[0]] = yearStats.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +51,7 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
|
||||
if (!(year in facultyTableStatsExtra)) {
|
||||
facultyTableStatsExtra[year] = {}
|
||||
}
|
||||
facultyTableStatsExtra[year][prog] = countryStats
|
||||
facultyTableStatsExtra[year][programmeCode] = countryStats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +68,8 @@ export const combineFacultyStudents = async (
|
||||
const programmeCodes = programmes.map(programme => programme.code)
|
||||
const newStats: StudyTrackStats[] = []
|
||||
|
||||
for (const studyProgramme of programmeCodes) {
|
||||
const statsFromRedis = await getStudyTrackStats(studyProgramme, null, graduated, specialGroups)
|
||||
for (const studyProgrammeCode of programmeCodes) {
|
||||
const statsFromRedis = await getStudyTrackStats(studyProgrammeCode, null, graduated, specialGroups)
|
||||
if (statsFromRedis) {
|
||||
newStats.push(statsFromRedis)
|
||||
if (!years.length) {
|
||||
@@ -73,9 +77,9 @@ export const combineFacultyStudents = async (
|
||||
}
|
||||
continue
|
||||
}
|
||||
const studyRightsOfProgramme = await getStudyRightsInProgramme(studyProgramme, false, true)
|
||||
const studyRightsOfProgramme = await getStudyRightsInProgramme(studyProgrammeCode, false, true)
|
||||
const updatedStats = await getStudyTrackStatsForStudyProgramme({
|
||||
studyProgramme,
|
||||
studyProgramme: studyProgrammeCode,
|
||||
settings: {
|
||||
graduated: graduated === 'GRADUATED_INCLUDED',
|
||||
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
|
||||
|
||||
@@ -73,5 +73,6 @@ export const updateSearch = async (userId: string, id: string, courseCodes: stri
|
||||
return updated
|
||||
} catch (error) {
|
||||
logger.error(`Couldn't update open uni search instance: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import moment from 'moment'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { SISStudyRightModel } from '../../models'
|
||||
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
|
||||
@@ -11,11 +11,10 @@ type CourseInfo = {
|
||||
}
|
||||
}
|
||||
|
||||
type StudentStats = {
|
||||
[studentNumber: string]: {
|
||||
courseInfo: {
|
||||
[courseCode: string]: CourseInfo
|
||||
}
|
||||
type StudentStats = Record<
|
||||
string,
|
||||
{
|
||||
courseInfo: Record<string, CourseInfo>
|
||||
email: string
|
||||
secondaryEmail: string
|
||||
totals: {
|
||||
@@ -24,7 +23,7 @@ type StudentStats = {
|
||||
unfinished: number
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
const uniq = objects => [...new Set(objects)]
|
||||
|
||||
@@ -47,14 +46,14 @@ const getAllCourseCodes = (courseCodes: string[]) => {
|
||||
}
|
||||
|
||||
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
|
||||
return moment(studyRight.startDate).isBetween(startDate, moment())
|
||||
return startDate <= studyRight.startDate && studyRight.startDate <= new Date()
|
||||
}
|
||||
|
||||
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
|
||||
return moment(studyRight.startDate).isSameOrBefore(startDate) && moment(studyRight.endDate).isSameOrAfter(moment())
|
||||
return studyRight.startDate <= startDate && new Date() <= studyRight.endDate
|
||||
}
|
||||
|
||||
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => moment(studyRight.endDate).isSameOrBefore(moment())
|
||||
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => studyRight.endDate <= new Date()
|
||||
|
||||
const getEmptyCourseInfo = (): CourseInfo => ({
|
||||
status: {
|
||||
@@ -65,7 +64,7 @@ const getEmptyCourseInfo = (): CourseInfo => ({
|
||||
})
|
||||
|
||||
const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
|
||||
if (!courseInfo.status.passed || moment(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
|
||||
if (!courseInfo.status.passed || dayjs(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
|
||||
courseInfo.status.passed = attainmentDate
|
||||
}
|
||||
}
|
||||
@@ -73,7 +72,7 @@ const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
|
||||
const updateFailedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
|
||||
if (
|
||||
!courseInfo.status.passed &&
|
||||
(!courseInfo.status.failed || moment(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
|
||||
(!courseInfo.status.failed || dayjs(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
|
||||
) {
|
||||
courseInfo.status.failed = attainmentDate
|
||||
}
|
||||
@@ -83,7 +82,7 @@ const updateUnfinishedStatus = (courseInfo: CourseInfo, enrollmentDateTime: Date
|
||||
if (
|
||||
!courseInfo.status.passed &&
|
||||
!courseInfo.status.failed &&
|
||||
(!courseInfo.status.unfinished || moment(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
|
||||
(!courseInfo.status.unfinished || dayjs(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
|
||||
) {
|
||||
courseInfo.status.unfinished = enrollmentDateTime
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { getPassingSemester } from '../../util/semester'
|
||||
import { type CourseStatistics, CourseStatsCounter } from '../courses/courseStatsCounter'
|
||||
import { encrypt, decrypt, EncrypterData } from '../encrypt'
|
||||
import { findCourses, parseCreditInfo } from './shared'
|
||||
|
||||
export type Bottlenecks = {
|
||||
allStudents: number
|
||||
coursestatistics: CourseStatistics[]
|
||||
}
|
||||
|
||||
export const bottlenecksOf = async (
|
||||
selectedStudents: string[],
|
||||
selectedStudentsByYear: { [year: string]: string[] },
|
||||
selectedCourses: string[],
|
||||
isEncrypted = false
|
||||
) => {
|
||||
const encryptStudentNumbers = (bottlenecks: Bottlenecks) => {
|
||||
for (const course of Object.keys(bottlenecks.coursestatistics)) {
|
||||
const encryptedStudentStats = {}
|
||||
for (const data of Object.keys(bottlenecks.coursestatistics[course].students)) {
|
||||
encryptedStudentStats[data] = {}
|
||||
const studentnumbers = Object.keys(bottlenecks.coursestatistics[course].students[data])
|
||||
studentnumbers.forEach(studentnumber => {
|
||||
encryptedStudentStats[data][encrypt(studentnumber).encryptedData] =
|
||||
bottlenecks.coursestatistics[course].students[data][studentnumber]
|
||||
})
|
||||
}
|
||||
bottlenecks.coursestatistics[course].students = encryptedStudentStats
|
||||
}
|
||||
}
|
||||
|
||||
// HACK: Encrypted students can't be decrypted if passed as strings
|
||||
if (isEncrypted) selectedStudents = (selectedStudents as unknown as EncrypterData[]).map(decrypt)
|
||||
|
||||
const courses = await findCourses(selectedStudents, selectedCourses)
|
||||
|
||||
// HACK: If year isn't present or is invalid, return MAGIC_NUMBER.
|
||||
// The new degree programs start on 2017.
|
||||
const getYear = (studentnumber: string): number => {
|
||||
const MAGIC_NUMBER = 2017
|
||||
for (const year in selectedStudentsByYear) {
|
||||
if (selectedStudentsByYear[year].includes(studentnumber)) {
|
||||
return parseInt(year, 10) || MAGIC_NUMBER
|
||||
}
|
||||
}
|
||||
|
||||
return MAGIC_NUMBER
|
||||
}
|
||||
|
||||
const selectedStudentCount = selectedStudents.length
|
||||
const stats = {} as Record<string, CourseStatsCounter>
|
||||
|
||||
for (const course of courses) {
|
||||
const { code: mainCode, name: mainName } = courses.find(({ code }) => code == course?.main_course_code) ?? course
|
||||
const coursestats = stats[mainCode] ?? new CourseStatsCounter(mainCode, mainName)
|
||||
|
||||
coursestats.addCourseSubstitutions(course.substitutions)
|
||||
course.enrollments?.forEach(({ studentnumber, state, enrollment_date_time }) => {
|
||||
// HACK: date should already be Date object
|
||||
const initialDate = new Date(enrollment_date_time)
|
||||
const semester = getPassingSemester(getYear(studentnumber), initialDate)
|
||||
coursestats.markEnrollment(studentnumber, state, semester)
|
||||
})
|
||||
course.credits
|
||||
?.map(parseCreditInfo)
|
||||
.forEach(({ studentnumber, passingGrade, improvedGrade, failingGrade, grade, date }) => {
|
||||
// HACK: date should already be Date object
|
||||
const initialDate = new Date(date)
|
||||
const semester = getPassingSemester(getYear(studentnumber), initialDate)
|
||||
coursestats.markCredit(studentnumber, grade, passingGrade, failingGrade, improvedGrade, semester)
|
||||
})
|
||||
|
||||
stats[mainCode] = coursestats
|
||||
}
|
||||
|
||||
const bottlenecks: Bottlenecks = {
|
||||
allStudents: selectedStudentCount,
|
||||
coursestatistics: Object.values(stats).map(coursestatistics =>
|
||||
coursestatistics.getFinalStats(selectedStudentCount)
|
||||
),
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
encryptStudentNumbers(bottlenecks)
|
||||
}
|
||||
|
||||
return bottlenecks
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { col, Op, where } from 'sequelize'
|
||||
|
||||
import { Credit, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import {
|
||||
Name,
|
||||
CreditTypeCode,
|
||||
DegreeProgrammeType,
|
||||
EnrollmentType,
|
||||
ExtentCode,
|
||||
SemesterEnrollment,
|
||||
} from '@oodikone/shared/types'
|
||||
import { CloseToGraduationData, AttainmentDates } from '@oodikone/shared/routes/populations'
|
||||
import { Name, CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode } from '@oodikone/shared/types'
|
||||
import {
|
||||
CourseModel,
|
||||
CreditModel,
|
||||
@@ -25,50 +19,6 @@ import { getCurriculumVersion } from './shared'
|
||||
|
||||
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
|
||||
|
||||
type AttainmentDates = {
|
||||
latestTotal?: Date
|
||||
latestHops?: Date
|
||||
earliestHops?: Date
|
||||
}
|
||||
|
||||
type AccumulatorType = {
|
||||
student: {
|
||||
studentNumber: string
|
||||
name: string
|
||||
sis_person_id: string
|
||||
email: string
|
||||
phoneNumber: string
|
||||
secondaryEmail: string
|
||||
preferredLanguage: string
|
||||
}
|
||||
studyright: {
|
||||
startDate: Date
|
||||
semesterEnrollments: SemesterEnrollment[] | null
|
||||
isBaMa: boolean
|
||||
}
|
||||
thesisInfo: {
|
||||
grade: string
|
||||
attainmentDate: Date
|
||||
courseCode: string
|
||||
} | null
|
||||
programme: {
|
||||
code: string
|
||||
name: Name
|
||||
studyTrack: Name | null
|
||||
startedAt: Date
|
||||
degreeProgrammeType: DegreeProgrammeType
|
||||
}
|
||||
faculty: Name
|
||||
attainmentDates: AttainmentDates
|
||||
numberOfAbsentSemesters: number
|
||||
numberOfUsedSemesters: number
|
||||
curriculumPeriod: string | null
|
||||
credits: {
|
||||
hops: number
|
||||
all: number
|
||||
}
|
||||
}
|
||||
|
||||
const findThesisAndLatestAndEarliestAttainments = (
|
||||
studyPlan: Studyplan,
|
||||
attainments: Credit[],
|
||||
@@ -114,7 +64,7 @@ const formatStudent = (student: Student, facultyMap: Record<string, Name>, curre
|
||||
preferredLanguage,
|
||||
secondary_email: secondaryEmail,
|
||||
} = student
|
||||
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
|
||||
return student.studyplans.reduce<CloseToGraduationData[]>((acc, studyPlan) => {
|
||||
const { studyRight } = studyPlan
|
||||
const {
|
||||
id: studyRightId,
|
||||
@@ -326,7 +276,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ bachelor: [] as AccumulatorType[], masterAndLicentiate: [] as AccumulatorType[] }
|
||||
{ bachelor: [] as CloseToGraduationData[], masterAndLicentiate: [] as CloseToGraduationData[] }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,129 +1,81 @@
|
||||
import { Name, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { CreditModel, SISStudyRightModel, SISStudyRightElementModel } from '../../models'
|
||||
import { ParsedCourse } from '../../types'
|
||||
import { dateYearsFromNow, dateDaysFromNow } from '../../util/datetime'
|
||||
import { SISStudyRight } from '@oodikone/shared/models'
|
||||
import { FormattedStudent, CriteriaYear, Name, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { dateYearsFromNow, dateDaysFromNow, dayInMilliseconds } from '@oodikone/shared/util/datetime'
|
||||
import { CreditModel } from '../../models'
|
||||
import { hasTransferredFromOrToProgramme } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import type { TaggetStudentData, StudentStudyPlan } from './getStudentData'
|
||||
import type { StudentEnrollmentObject, StudentCreditObject, AnonymousCredit } from './optimizedStatisticsOf'
|
||||
import type { StudentStudyPlan, StudentStudyRight, TaggetStudentData } from './getStudentData'
|
||||
import { getCurriculumVersion } from './shared'
|
||||
import type { AnonymousCredit, AnonymousEnrollment } from './statisticsOf'
|
||||
|
||||
type CoursesSatisfied = Record<string, string | null>
|
||||
const yearMap: [string, keyof ProgressCriteria['courses']][] = [
|
||||
['year1', 'yearOne'],
|
||||
['year2', 'yearTwo'],
|
||||
['year3', 'yearThree'],
|
||||
['year4', 'yearFour'],
|
||||
['year5', 'yearFive'],
|
||||
['year6', 'yearSix'],
|
||||
]
|
||||
|
||||
type CriteriaYear = {
|
||||
credits: boolean
|
||||
totalSatisfied: number
|
||||
coursesSatisfied: CoursesSatisfied
|
||||
}
|
||||
const getTransferSource = (code: string, studyRights: StudentStudyRight[]): [boolean, string | undefined] => {
|
||||
if (code) {
|
||||
const correctStudyRight = studyRights.find(({ studyRightElements }) =>
|
||||
studyRightElements.some(element => element.code === code)
|
||||
)!
|
||||
const correctStudyRightElement = correctStudyRight?.studyRightElements?.find(element => element.code === code)
|
||||
|
||||
const getCreditAmount = (course: ParsedCourse, hops: StudentStudyPlan[], courseCode: string, startDate: string) => {
|
||||
const courseDate = new Date(course.date)
|
||||
const startDateFromString = new Date(startDate)
|
||||
if (correctStudyRight && correctStudyRightElement) {
|
||||
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
|
||||
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
|
||||
|
||||
const creditAmounts = [0, 0, 0, 0, 0, 0]
|
||||
const [, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
|
||||
correctStudyRight as SISStudyRight,
|
||||
correctStudyRightElement
|
||||
)
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
if (
|
||||
courseDate > startDateFromString &&
|
||||
courseDate < dateYearsFromNow(startDateFromString, i) &&
|
||||
hops.length > 0 &&
|
||||
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
|
||||
) {
|
||||
creditAmounts[i - 1] = course.credits
|
||||
}
|
||||
}
|
||||
return creditAmounts
|
||||
}
|
||||
if (hasTransferredToProgramme) {
|
||||
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
|
||||
const otherStudyRightEndDate = new Date(element.endDate)
|
||||
|
||||
const updateCourseByYear = (
|
||||
criteria: ProgressCriteria,
|
||||
course: ParsedCourse,
|
||||
criteriaChecked: Record<string, CriteriaYear>,
|
||||
correctCode: string
|
||||
) => {
|
||||
const yearMap: {
|
||||
criteriaYear: keyof ProgressCriteria['courses']
|
||||
yearToAdd: string
|
||||
}[] = [
|
||||
{ criteriaYear: 'yearOne', yearToAdd: 'year1' },
|
||||
{ criteriaYear: 'yearTwo', yearToAdd: 'year2' },
|
||||
{ criteriaYear: 'yearThree', yearToAdd: 'year3' },
|
||||
{ criteriaYear: 'yearFour', yearToAdd: 'year4' },
|
||||
{ criteriaYear: 'yearFive', yearToAdd: 'year5' },
|
||||
{ criteriaYear: 'yearSix', yearToAdd: 'year6' },
|
||||
]
|
||||
return studyRightStartDate.getTime() - otherStudyRightEndDate.getTime() < dayInMilliseconds
|
||||
})?.code
|
||||
|
||||
yearMap.forEach(
|
||||
({ criteriaYear, yearToAdd }: { criteriaYear: keyof ProgressCriteria['courses']; yearToAdd: string }) => {
|
||||
const mainCourseCodes = criteria.courses[criteriaYear]
|
||||
|
||||
// Each mainCode will have their substitutions fetched.
|
||||
// Therefore theses will always be valid keys to the allCourses -object.
|
||||
const substitutions = mainCourseCodes.flatMap(mainCode => criteria.allCourses[mainCode])
|
||||
|
||||
const yearSet = new Set([...mainCourseCodes, ...substitutions])
|
||||
|
||||
if (yearSet?.has(course.course_code)) {
|
||||
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
|
||||
if (!currentDate || new Date(currentDate) > new Date(course.date)) {
|
||||
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
|
||||
}
|
||||
return [true, transferredFromProgramme]
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return [false, undefined]
|
||||
}
|
||||
|
||||
const updateCreditCriteriaInfo = (
|
||||
criteria: ProgressCriteria,
|
||||
criteriaYear: string,
|
||||
criteriaChecked: Record<string, CriteriaYear>,
|
||||
yearToAdd: string,
|
||||
academicYears: Record<string, number>
|
||||
) => {
|
||||
if (criteriaChecked[yearToAdd]) {
|
||||
criteriaChecked[yearToAdd].totalSatisfied +=
|
||||
Object.values(criteriaChecked?.[yearToAdd].coursesSatisfied).filter(course => course !== null).length || 0
|
||||
}
|
||||
if (academicYears[yearToAdd] >= criteria?.credits[criteriaYear] && criteria?.credits[criteriaYear] > 0) {
|
||||
criteriaChecked[yearToAdd].credits = true
|
||||
criteriaChecked[yearToAdd].totalSatisfied += 1
|
||||
const getCriteriaBase = (criteria: ProgressCriteria): [boolean, Record<string, CriteriaYear>] => {
|
||||
const thereAreCriteriaCourses = !!Object.values(criteria.courses).flatMap(val => val).length
|
||||
const thereAreCriteriaCredits = !!Object.values(criteria.credits).reduce((acc, cur) => acc + cur, 0)
|
||||
|
||||
const createEmptyCriteriaYear = (criteria: ProgressCriteria, year: keyof ProgressCriteria['courses']) => ({
|
||||
credits: false,
|
||||
totalSatisfied: 0,
|
||||
coursesSatisfied: Object.fromEntries(criteria.courses[year].map(course => [course, null])),
|
||||
})
|
||||
|
||||
const criteriaChecked: Record<string, CriteriaYear> = {
|
||||
year1: createEmptyCriteriaYear(criteria, 'yearOne'),
|
||||
year2: createEmptyCriteriaYear(criteria, 'yearTwo'),
|
||||
year3: createEmptyCriteriaYear(criteria, 'yearThree'),
|
||||
year4: createEmptyCriteriaYear(criteria, 'yearFour'),
|
||||
year5: createEmptyCriteriaYear(criteria, 'yearFive'),
|
||||
year6: createEmptyCriteriaYear(criteria, 'yearSix'),
|
||||
}
|
||||
|
||||
return [thereAreCriteriaCourses || thereAreCriteriaCredits, criteriaChecked]
|
||||
}
|
||||
|
||||
export const formatStudentForAPI = (
|
||||
student: TaggetStudentData,
|
||||
enrollments: StudentEnrollmentObject,
|
||||
credits: StudentCreditObject,
|
||||
const getProgressCriteria = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
criteria: ProgressCriteria,
|
||||
code: string,
|
||||
optionData: Record<string, { name: Name }>
|
||||
credits: AnonymousCredit[],
|
||||
hops: StudentStudyPlan | undefined
|
||||
) => {
|
||||
const startDateFromISO = new Date(startDate)
|
||||
const endDateFromISO = new Date(endDate)
|
||||
|
||||
const {
|
||||
firstnames,
|
||||
lastname,
|
||||
studentnumber,
|
||||
dateofuniversityenrollment,
|
||||
creditcount,
|
||||
abbreviatedname,
|
||||
email,
|
||||
secondary_email,
|
||||
phone_number,
|
||||
studyRights,
|
||||
studyplans,
|
||||
updatedAt,
|
||||
gender_code,
|
||||
tags,
|
||||
birthdate,
|
||||
sis_person_id,
|
||||
citizenships,
|
||||
} = student
|
||||
|
||||
const started = dateofuniversityenrollment
|
||||
const starting = +startDateFromISO <= +started && +started <= +endDateFromISO
|
||||
|
||||
const criteriaCoursesBySubstitutions: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(criteria.allCourses).flatMap(([courseCode, substitutionCodes]) => [
|
||||
@@ -132,48 +84,78 @@ export const formatStudentForAPI = (
|
||||
])
|
||||
)
|
||||
|
||||
const correctStudyplan = studyplans.filter(plan => plan.programme_code === code)
|
||||
const [thereAreCriteria, criteriaChecked] = getCriteriaBase(criteria)
|
||||
if (!thereAreCriteria) return criteriaChecked
|
||||
|
||||
const option = studentnumber in optionData ? optionData[studentnumber] : null
|
||||
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
|
||||
|
||||
let transferredStudyright = false
|
||||
let transferSource: string | null = null
|
||||
|
||||
if (code) {
|
||||
const correctStudyRight = studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(element => element.code === code)
|
||||
)
|
||||
if (correctStudyRight) {
|
||||
// NOTE: This must never be undefined
|
||||
const correctStudyRightElement = correctStudyRight.studyRightElements.find(element => element.code === code)
|
||||
const [, hasTransferredToProgramme] = hasTransferredFromOrToProgramme(
|
||||
correctStudyRight as SISStudyRightModel,
|
||||
correctStudyRightElement as SISStudyRightElementModel
|
||||
)
|
||||
if (hasTransferredToProgramme) {
|
||||
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
|
||||
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
|
||||
const studyRightEndDate = new Date(element.endDate)
|
||||
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
|
||||
return (
|
||||
studyRightStartDate.getFullYear() === studyRightEndDate.getFullYear() &&
|
||||
studyRightStartDate.getMonth() === studyRightEndDate.getMonth() &&
|
||||
studyRightStartDate.getDate() === studyRightEndDate.getDate()
|
||||
)
|
||||
})?.code
|
||||
if (transferredFromProgramme) {
|
||||
transferredStudyright = true
|
||||
transferSource = transferredFromProgramme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseCourse = (credit: AnonymousCredit, normalizeDate: boolean): ParsedCourse => {
|
||||
const attainmentDateNormalized =
|
||||
normalizeDate && credit.attainment_date < startDateFromISO
|
||||
const courses = credits.map(({ attainment_date, course_code, credits, credittypecode }) => ({
|
||||
course_code,
|
||||
credits,
|
||||
credittypecode,
|
||||
date:
|
||||
attainment_date < startDateFromISO
|
||||
? dateDaysFromNow(startDateFromISO, 1).toISOString()
|
||||
: credit.attainment_date.toISOString()
|
||||
: attainment_date.toISOString(),
|
||||
}))
|
||||
|
||||
courses
|
||||
.filter(({ course_code }) => !!criteriaCoursesBySubstitutions[course_code])
|
||||
.filter(({ credittypecode }) => CreditModel.passed({ credittypecode }) || CreditModel.improved({ credittypecode }))
|
||||
.forEach(course => {
|
||||
const courseDate = new Date(course.date)
|
||||
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
|
||||
|
||||
yearMap.forEach(([yearToAdd, criteriaYear]) => {
|
||||
if (criteria.courses[criteriaYear].includes(correctCode)) {
|
||||
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
|
||||
if (!currentDate || courseDate < new Date(currentDate)) {
|
||||
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
courses.forEach(course => {
|
||||
const courseDate = new Date(course.date)
|
||||
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
|
||||
|
||||
if (
|
||||
startDateFromISO < courseDate &&
|
||||
!!hops &&
|
||||
(hops.included_courses.includes(course.course_code) || hops.included_courses.includes(correctCode))
|
||||
)
|
||||
Object.keys(academicYears)
|
||||
.filter((_, index) => courseDate < dateYearsFromNow(startDateFromISO, index + 1))
|
||||
.forEach(year => (academicYears[year] += course.credits))
|
||||
})
|
||||
|
||||
yearMap.forEach(([yearToAdd, criteriaYear]) => {
|
||||
// UPDATE CREDIT CRITERIA
|
||||
if (!!criteria.credits[criteriaYear] && criteria.credits[criteriaYear] < academicYears[yearToAdd]) {
|
||||
criteriaChecked[yearToAdd].credits = true
|
||||
}
|
||||
})
|
||||
|
||||
return criteriaChecked
|
||||
}
|
||||
|
||||
export const formatStudentForAPI = (
|
||||
code: string,
|
||||
startDate: string,
|
||||
student: TaggetStudentData,
|
||||
credits: AnonymousCredit[],
|
||||
enrollments: AnonymousEnrollment[],
|
||||
optionData: Name | undefined,
|
||||
criteria: ProgressCriteria
|
||||
): FormattedStudent => {
|
||||
const { studentnumber, studyRights, studyplans } = student
|
||||
|
||||
const hops = studyplans.find(plan => plan.programme_code === code)
|
||||
const [transferredStudyright, transferSource] = getTransferSource(code, studyRights)
|
||||
|
||||
const courses = credits.map(credit => {
|
||||
const attainmentDateNormalized = credit.attainment_date.toISOString()
|
||||
const passed =
|
||||
CreditModel.passed({ credittypecode: credit.credittypecode }) ||
|
||||
CreditModel.improved({ credittypecode: credit.credittypecode })
|
||||
@@ -189,83 +171,36 @@ export const formatStudentForAPI = (
|
||||
language: credit.language,
|
||||
studyright_id: credit.studyright_id,
|
||||
}
|
||||
}
|
||||
|
||||
const toProgressCriteria = () => {
|
||||
const createEmptyCriteriaYear = (
|
||||
criteria: ProgressCriteria,
|
||||
year: keyof ProgressCriteria['courses']
|
||||
): CriteriaYear => ({
|
||||
credits: false,
|
||||
totalSatisfied: 0,
|
||||
coursesSatisfied: Object.fromEntries(criteria.courses[year].map(course => [course, null])),
|
||||
})
|
||||
|
||||
const criteriaChecked = {
|
||||
year1: createEmptyCriteriaYear(criteria, 'yearOne'),
|
||||
year2: createEmptyCriteriaYear(criteria, 'yearTwo'),
|
||||
year3: createEmptyCriteriaYear(criteria, 'yearThree'),
|
||||
year4: createEmptyCriteriaYear(criteria, 'yearFour'),
|
||||
year5: createEmptyCriteriaYear(criteria, 'yearFive'),
|
||||
year6: createEmptyCriteriaYear(criteria, 'yearSix'),
|
||||
}
|
||||
|
||||
const academicYears = { year1: 0, year2: 0, year3: 0, year4: 0, year5: 0, year6: 0 }
|
||||
|
||||
if (criteria.courses || criteria.credits) {
|
||||
const courses = credits[studentnumber]?.map(credit => parseCourse(credit, true)) ?? []
|
||||
|
||||
courses
|
||||
.filter(course => course.passed)
|
||||
.forEach(course => {
|
||||
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
|
||||
|
||||
updateCourseByYear(criteria, course, criteriaChecked, correctCode)
|
||||
|
||||
const creditAmounts = getCreditAmount(course, correctStudyplan, correctCode, startDate)
|
||||
|
||||
Object.keys(academicYears).forEach((year, index) => {
|
||||
academicYears[year] += creditAmounts[index]
|
||||
})
|
||||
})
|
||||
|
||||
updateCreditCriteriaInfo(criteria, 'yearOne', criteriaChecked, 'year1', academicYears)
|
||||
updateCreditCriteriaInfo(criteria, 'yearTwo', criteriaChecked, 'year2', academicYears)
|
||||
updateCreditCriteriaInfo(criteria, 'yearThree', criteriaChecked, 'year3', academicYears)
|
||||
updateCreditCriteriaInfo(criteria, 'yearFour', criteriaChecked, 'year4', academicYears)
|
||||
updateCreditCriteriaInfo(criteria, 'yearFive', criteriaChecked, 'year5', academicYears)
|
||||
updateCreditCriteriaInfo(criteria, 'yearSix', criteriaChecked, 'year6', academicYears)
|
||||
}
|
||||
|
||||
return criteriaChecked
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
firstnames,
|
||||
lastname,
|
||||
studyRights,
|
||||
started,
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
started: student.dateofuniversityenrollment,
|
||||
studentNumber: studentnumber,
|
||||
credits: creditcount || 0,
|
||||
courses: credits[studentnumber]?.map(credit => parseCourse(credit, false)) ?? [],
|
||||
enrollments: enrollments[studentnumber],
|
||||
name: abbreviatedname,
|
||||
gender_code,
|
||||
email,
|
||||
secondaryEmail: secondary_email,
|
||||
phoneNumber: phone_number,
|
||||
updatedAt,
|
||||
tags,
|
||||
credits: student.creditcount ?? 0,
|
||||
|
||||
hopsCredits: hops?.completed_credits ?? 0,
|
||||
name: student.abbreviatedname,
|
||||
gender_code: student.gender_code,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
phoneNumber: student.phone_number,
|
||||
updatedAt: student.updatedAt,
|
||||
tags: student.tags,
|
||||
studyrightStart: startDate,
|
||||
starting,
|
||||
option,
|
||||
birthdate,
|
||||
studyplans,
|
||||
sis_person_id,
|
||||
citizenships,
|
||||
criteriaProgress: toProgressCriteria(),
|
||||
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
|
||||
option: optionData ? { name: optionData } : null,
|
||||
birthdate: student.birthdate,
|
||||
sis_person_id: student.sis_person_id,
|
||||
citizenships: student.citizenships,
|
||||
criteriaProgress: getProgressCriteria(startDate, criteria, credits, hops),
|
||||
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
|
||||
|
||||
transferredStudyright,
|
||||
transferSource,
|
||||
studyRights,
|
||||
studyplans,
|
||||
courses,
|
||||
enrollments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { literal, Op, type WhereOptions } from 'sequelize'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import { serviceProvider } from '../../config'
|
||||
import {
|
||||
CourseModel,
|
||||
CreditModel,
|
||||
EnrollmentModel,
|
||||
StudentModel,
|
||||
@@ -13,8 +14,8 @@ import {
|
||||
} from '../../models'
|
||||
import { TagModel, TagStudentModel } from '../../models/kone'
|
||||
|
||||
type StudentTags = TagStudentModel & {
|
||||
tag: Pick<TagModel, 'tag_id' | 'tagname' | 'personal_user_id'>
|
||||
export type StudentTags = TagStudent & {
|
||||
tag: Pick<Tag, 'tag_id' | 'tagname' | 'personal_user_id'>
|
||||
}
|
||||
|
||||
export type TaggetStudentData = StudentData & {
|
||||
@@ -37,79 +38,33 @@ export const getStudentTags = async (studyRights: string[], studentNumbers: stri
|
||||
},
|
||||
})
|
||||
|
||||
const studentTagList: Record<string, TagStudentModel[]> = Object.fromEntries(studentNumbers.map(n => [n, []]))
|
||||
const studentTagList: Record<string, StudentTags[]> = Object.fromEntries(studentNumbers.map(n => [n, []]))
|
||||
studentTags.forEach(studentTag => studentTagList[studentTag.studentnumber].push(studentTag))
|
||||
|
||||
return studentTagList
|
||||
}
|
||||
|
||||
export const creditFilterBuilder = async (
|
||||
studentNumbers: string[],
|
||||
studyRights: string[],
|
||||
attainmentDateFrom: string,
|
||||
endDate: Date
|
||||
): Promise<WhereOptions> => {
|
||||
const includedCoursesInStudyPlans: Array<Pick<StudyplanModel, 'included_courses'>> = await StudyplanModel.findAll({
|
||||
attributes: ['included_courses'],
|
||||
where: {
|
||||
studentnumber: { [Op.in]: studentNumbers },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const uniqueCourses = new Set(includedCoursesInStudyPlans.flatMap(plan => plan.included_courses))
|
||||
|
||||
// takes into account possible progress tests taken earlier than the start date
|
||||
const courseCodes =
|
||||
['320001', 'MH30_001'].includes(studyRights[0]) && serviceProvider !== 'fd'
|
||||
? [...uniqueCourses, '375063', '339101']
|
||||
: [...uniqueCourses]
|
||||
|
||||
return {
|
||||
student_studentnumber: { [Op.in]: studentNumbers },
|
||||
[Op.or]: [{ attainment_date: { [Op.between]: [attainmentDateFrom, endDate] } }, { course_code: courseCodes }],
|
||||
}
|
||||
}
|
||||
|
||||
export type StudentCourse = Pick<CourseModel, 'code' | 'name'>
|
||||
export const getCourses = (creditFilter: WhereOptions): Promise<Array<StudentCourse>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: [[literal('DISTINCT ON("code") code'), 'code'], 'name'],
|
||||
include: [
|
||||
{
|
||||
model: CreditModel,
|
||||
attributes: [],
|
||||
where: creditFilter,
|
||||
},
|
||||
],
|
||||
raw: true,
|
||||
})
|
||||
|
||||
export type StudentEnrollment = Pick<
|
||||
EnrollmentModel,
|
||||
'course_code' | 'state' | 'enrollment_date_time' | 'studentnumber' | 'semestercode' | 'studyright_id'
|
||||
Enrollment,
|
||||
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber'
|
||||
>
|
||||
export const getEnrollments = (
|
||||
studentNumbers: string[],
|
||||
startDate: string,
|
||||
endDate: Date
|
||||
): Promise<Array<StudentEnrollment>> =>
|
||||
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> =>
|
||||
EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'studentnumber', 'semestercode', 'studyright_id'],
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: {
|
||||
[Op.between]: [startDate, endDate],
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
type StudentPersonalData = Pick<
|
||||
StudentModel,
|
||||
Student,
|
||||
| 'firstnames'
|
||||
| 'lastname'
|
||||
| 'studentnumber'
|
||||
@@ -127,19 +82,19 @@ type StudentPersonalData = Pick<
|
||||
>
|
||||
|
||||
type StudentStudyRightElement = Pick<
|
||||
SISStudyRightElementModel,
|
||||
SISStudyRightElement,
|
||||
'code' | 'name' | 'studyTrack' | 'graduated' | 'startDate' | 'endDate' | 'phase' | 'degreeProgrammeType'
|
||||
>
|
||||
|
||||
type StudentStudyRight = Pick<
|
||||
SISStudyRightModel,
|
||||
export type StudentStudyRight = Pick<
|
||||
SISStudyRight,
|
||||
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate' | 'tvex'
|
||||
> & {
|
||||
studyRightElements: Array<StudentStudyRightElement>
|
||||
}
|
||||
|
||||
export type StudentStudyPlan = Pick<
|
||||
StudyplanModel,
|
||||
Studyplan,
|
||||
| 'included_courses'
|
||||
| 'programme_code'
|
||||
| 'includedModules'
|
||||
@@ -223,7 +178,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
|
||||
}).then(data => data.map(x => x.get({ plain: true })))
|
||||
|
||||
export type StudentCredit = Pick<
|
||||
CreditModel,
|
||||
Credit,
|
||||
| 'grade'
|
||||
| 'credits'
|
||||
| 'credittypecode'
|
||||
@@ -234,8 +189,29 @@ export type StudentCredit = Pick<
|
||||
| 'language'
|
||||
| 'studyright_id'
|
||||
>
|
||||
export const getCredits = (creditFilter: WhereOptions): Promise<Array<StudentCredit>> =>
|
||||
CreditModel.findAll({
|
||||
|
||||
export const getCredits = async (
|
||||
studentNumbers: string[],
|
||||
studyRights: string[],
|
||||
attainmentDateFrom: string
|
||||
): Promise<Array<StudentCredit>> => {
|
||||
const includedCoursesInStudyPlans: Array<Pick<Studyplan, 'included_courses'>> = await StudyplanModel.findAll({
|
||||
attributes: ['included_courses'],
|
||||
where: {
|
||||
studentnumber: { [Op.in]: studentNumbers },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const uniqueCourses = new Set(includedCoursesInStudyPlans.flatMap(plan => plan.included_courses))
|
||||
|
||||
// takes into account possible progress tests taken earlier than the start date
|
||||
const courseCodes =
|
||||
['320001', 'MH30_001'].includes(studyRights[0]) && serviceProvider !== 'fd'
|
||||
? [...uniqueCourses, '375063', '339101']
|
||||
: [...uniqueCourses]
|
||||
|
||||
return CreditModel.findAll({
|
||||
attributes: [
|
||||
'grade',
|
||||
'credits',
|
||||
@@ -247,6 +223,10 @@ export const getCredits = (creditFilter: WhereOptions): Promise<Array<StudentCre
|
||||
'language',
|
||||
'studyright_id',
|
||||
],
|
||||
where: creditFilter,
|
||||
where: {
|
||||
student_studentnumber: { [Op.in]: studentNumbers },
|
||||
[Op.or]: [{ attainment_date: { [Op.gte]: attainmentDateFrom } }, { course_code: courseCodes }],
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { formatToArray, omitKeys } from '@oodikone/shared/util'
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
import { dateMonthsFromNow } from '../../util/datetime'
|
||||
import { SemesterStart } from '../../util/semester'
|
||||
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
|
||||
import { formatStudentForAPI } from './formatStatisticsForApi'
|
||||
import {
|
||||
type StudentEnrollment,
|
||||
type StudentCredit,
|
||||
getStudents,
|
||||
getStudentTags,
|
||||
creditFilterBuilder,
|
||||
getCourses,
|
||||
getEnrollments,
|
||||
getCredits,
|
||||
} from './getStudentData'
|
||||
import { getOptionsForStudents } from './shared'
|
||||
import { getStudentNumbersWithStudyRights } from './studentNumbersWithStudyRights'
|
||||
|
||||
export type OptimizedStatisticsQuery = {
|
||||
userId: string
|
||||
semesters: string[]
|
||||
studentStatuses?: string[]
|
||||
studyRights?: string | string[]
|
||||
year: string
|
||||
months?: string
|
||||
}
|
||||
|
||||
export type ParsedQueryParams = {
|
||||
startDate: string
|
||||
endDate: string
|
||||
includeExchangeStudents: boolean
|
||||
includeNondegreeStudents: boolean
|
||||
includeTransferredStudents: boolean
|
||||
studyRights: string[]
|
||||
months?: string
|
||||
}
|
||||
|
||||
const parseQueryParams = (query: OptimizedStatisticsQuery): ParsedQueryParams => {
|
||||
const { semesters, studentStatuses, studyRights, months, year } = query
|
||||
const yearAsNumber = +year
|
||||
|
||||
const hasFall = semesters.includes('FALL')
|
||||
const hasSpring = semesters.includes('SPRING')
|
||||
|
||||
const startDate = hasFall
|
||||
? new Date(`${yearAsNumber}-${SemesterStart.FALL}`).toISOString()
|
||||
: new Date(`${yearAsNumber + 1}-${SemesterStart.SPRING}`).toISOString()
|
||||
|
||||
const endDate = hasSpring
|
||||
? new Date(`${yearAsNumber + 1}-${SemesterStart.FALL}`).toISOString()
|
||||
: new Date(`${yearAsNumber + 1}-${SemesterStart.SPRING}`).toISOString()
|
||||
|
||||
const includeExchangeStudents = !!studentStatuses?.includes('EXCHANGE')
|
||||
const includeNondegreeStudents = !!studentStatuses?.includes('NONDEGREE')
|
||||
const includeTransferredStudents = !!studentStatuses?.includes('TRANSFERRED')
|
||||
|
||||
return {
|
||||
includeExchangeStudents,
|
||||
includeNondegreeStudents,
|
||||
includeTransferredStudents,
|
||||
// Remove falsy values so the query doesn't break
|
||||
studyRights: formatToArray(studyRights).filter(Boolean) as string[],
|
||||
months,
|
||||
startDate,
|
||||
endDate,
|
||||
}
|
||||
}
|
||||
|
||||
export type AnonymousEnrollment = Omit<StudentEnrollment, 'studentnumber'>
|
||||
export type AnonymousCredit = Omit<StudentCredit, 'student_studentnumber'>
|
||||
|
||||
export type StudentEnrollmentObject = Record<StudentEnrollment['studentnumber'], AnonymousEnrollment[]>
|
||||
export type StudentCreditObject = Record<StudentCredit['student_studentnumber'], AnonymousCredit[]>
|
||||
|
||||
export const optimizedStatisticsOf = async (query: OptimizedStatisticsQuery, studentNumberList?: string[]) => {
|
||||
const { userId } = query
|
||||
const {
|
||||
studyRights,
|
||||
startDate,
|
||||
months,
|
||||
endDate,
|
||||
includeExchangeStudents,
|
||||
includeNondegreeStudents,
|
||||
includeTransferredStudents: includeTransferredOutStudents,
|
||||
} = parseQueryParams(query)
|
||||
|
||||
const studentNumbers =
|
||||
studentNumberList ??
|
||||
(await getStudentNumbersWithStudyRights({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
includeExchangeStudents,
|
||||
includeNondegreeStudents,
|
||||
includeTransferredOutStudents,
|
||||
}))
|
||||
|
||||
const code = studyRights[0] ?? ''
|
||||
const degreeProgrammeType = await getDegreeProgrammeType(code)
|
||||
|
||||
const creditsFilter = await creditFilterBuilder(
|
||||
studentNumbers,
|
||||
studyRights,
|
||||
startDate,
|
||||
dateMonthsFromNow(startDate, months)
|
||||
)
|
||||
const [students, courses, enrollments, credits] = await Promise.all([
|
||||
getStudents(studentNumbers),
|
||||
getCourses(creditsFilter),
|
||||
getEnrollments(studentNumbers, startDate, dateMonthsFromNow(startDate, months)),
|
||||
getCredits(creditsFilter),
|
||||
])
|
||||
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
|
||||
const optionData = await getOptionsForStudents(studentNumbers, code, degreeProgrammeType)
|
||||
const criteria = await getCriteria(code)
|
||||
|
||||
const creditsByStudent: StudentCreditObject = Object.fromEntries(studentNumbers.map(n => [n, []]))
|
||||
credits.forEach(credit => {
|
||||
const { student_studentnumber: studentnumber } = credit
|
||||
creditsByStudent[studentnumber].push(omitKeys(credit, ['student_studentnumber']))
|
||||
})
|
||||
|
||||
const enrollmentsByStudent: StudentEnrollmentObject = Object.fromEntries(studentNumbers.map(n => [n, []]))
|
||||
enrollments.forEach(enrollment => {
|
||||
const { studentnumber } = enrollment
|
||||
enrollmentsByStudent[studentnumber].push(omitKeys(enrollment, ['studentnumber']))
|
||||
})
|
||||
|
||||
return {
|
||||
students: students
|
||||
.map(student => ({ ...student, tags: tagList[student.studentnumber] }))
|
||||
.map(student =>
|
||||
formatStudentForAPI(
|
||||
student,
|
||||
enrollmentsByStudent,
|
||||
creditsByStudent,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
code,
|
||||
optionData
|
||||
)
|
||||
),
|
||||
courses,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,41 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import type { CourseStats } from '@oodikone/shared/routes/populations'
|
||||
import { Name, DegreeProgrammeType, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { CreditModel, EnrollmentModel, SISStudyRightModel, SISStudyRightElementModel } from '../../models'
|
||||
import { SISStudyRightModel, SISStudyRightElementModel, CreditModel, CourseModel } from '../../models'
|
||||
import { getPassingSemester, SemesterStart } from '../../util/semester'
|
||||
import { StudentCredit, StudentEnrollment } from './getStudentData'
|
||||
|
||||
const { sequelize } = dbConnections
|
||||
type QueryParams = {
|
||||
semesters: string[]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
export const getCurriculumVersion = (curriculumPeriodId: string) => {
|
||||
type ParsedQueryParams = {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
export const parseDateRangeFromParams = (query: QueryParams): ParsedQueryParams => {
|
||||
const { semesters, years } = query
|
||||
const startingYear = Math.min(...years.map(y => +y))
|
||||
const endingYear = Math.max(...years.map(y => +y))
|
||||
|
||||
const hasFall = semesters.includes('FALL')
|
||||
const hasSpring = semesters.includes('SPRING')
|
||||
|
||||
const startDate = hasFall
|
||||
? new Date(`${startingYear}-${SemesterStart.FALL}`).toISOString()
|
||||
: new Date(`${startingYear + 1}-${SemesterStart.SPRING}`).toISOString()
|
||||
|
||||
const endDate = hasSpring
|
||||
? new Date(`${endingYear + 1}-${SemesterStart.FALL}`).toISOString()
|
||||
: new Date(`${endingYear + 1}-${SemesterStart.SPRING}`).toISOString()
|
||||
|
||||
return { startDate, endDate }
|
||||
}
|
||||
export const getCurriculumVersion = (curriculumPeriodId: string | undefined) => {
|
||||
if (!curriculumPeriodId) {
|
||||
return null
|
||||
}
|
||||
@@ -23,7 +51,7 @@ export const getOptionsForStudents = async (
|
||||
studentNumbers: string[],
|
||||
code: string,
|
||||
degreeProgrammeType: DegreeProgrammeType | null
|
||||
): Promise<Record<string, { name: Name }>> => {
|
||||
): Promise<Record<string, Name>> => {
|
||||
if (!code || !studentNumbers.length) {
|
||||
return {}
|
||||
} else if (
|
||||
@@ -64,141 +92,206 @@ export const getOptionsForStudents = async (
|
||||
[levelIsMasters ? 'desc' : 'asc']
|
||||
)
|
||||
|
||||
return [studyRight.studentNumber, { name: latestProgramme?.name }] as [string, { name: Name }]
|
||||
return [studyRight.studentNumber, latestProgramme?.name]
|
||||
})
|
||||
.filter(([_, { name }]) => !!name)
|
||||
.filter(([_, name]) => !!name)
|
||||
)
|
||||
}
|
||||
|
||||
export type EnrollmentsQueryResult = Array<{
|
||||
code: string
|
||||
name: Name
|
||||
substitutions: string[]
|
||||
main_course_code: string
|
||||
enrollments: Array<Pick<EnrollmentModel, 'studentnumber' | 'state' | 'enrollment_date_time'>> | null
|
||||
}>
|
||||
|
||||
type CreditPick = Pick<
|
||||
CreditModel,
|
||||
'grade' | 'student_studentnumber' | 'attainment_date' | 'credittypecode' | 'course_code'
|
||||
>
|
||||
|
||||
export type CoursesQueryResult = Array<
|
||||
EnrollmentsQueryResult[number] & {
|
||||
credits: Array<CreditPick>
|
||||
}
|
||||
>
|
||||
|
||||
const findDefinedCourseStatsForStudents = `
|
||||
SELECT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
course.main_course_code,
|
||||
enrollment.data AS enrollments,
|
||||
credit.data AS credits
|
||||
FROM course
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'grade', grade,
|
||||
'student_studentnumber', student_studentnumber,
|
||||
'attainment_date', attainment_date,
|
||||
'credittypecode', credittypecode,
|
||||
'course_code', course_code
|
||||
)) AS data
|
||||
FROM credit
|
||||
WHERE student_studentnumber IN (:studentnumbers)
|
||||
GROUP BY course_code
|
||||
) AS credit
|
||||
ON credit.course_code = course.code
|
||||
WHERE
|
||||
(
|
||||
course.code IN (:courseCodes)
|
||||
OR (
|
||||
SELECT
|
||||
JSONB_AGG(DISTINCT alt_code)
|
||||
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
|
||||
WHERE code IN (:courseCodes)
|
||||
) ? course.code
|
||||
) AND (enrollment.data IS NOT NULL OR credit.data IS NOT NULL)
|
||||
`
|
||||
|
||||
const findAllCourseStatsForStudents = `
|
||||
SELECT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
course.main_course_code,
|
||||
enrollment.data AS enrollments,
|
||||
credit.data AS credits
|
||||
FROM course
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'grade', grade,
|
||||
'student_studentnumber', student_studentnumber,
|
||||
'attainment_date', attainment_date,
|
||||
'credittypecode', credittypecode,
|
||||
'course_code', course_code
|
||||
)) AS data
|
||||
FROM credit
|
||||
WHERE student_studentnumber IN (:studentnumbers)
|
||||
GROUP BY course_code
|
||||
) AS credit
|
||||
ON credit.course_code = course.code
|
||||
WHERE
|
||||
enrollment.data IS NOT NULL OR credit.data IS NOT NULL
|
||||
`
|
||||
|
||||
export const findCourses = async (studentNumbers: string[], courses: string[] = []) => {
|
||||
return sequelize.query(courses.length ? findDefinedCourseStatsForStudents : findAllCourseStatsForStudents, {
|
||||
replacements: {
|
||||
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
|
||||
courseCodes: courses.length ? courses : ['DUMMY'],
|
||||
enrollmentState: EnrollmentState.ENROLLED,
|
||||
export const getCourses = (courses: string[]): Promise<Array<Pick<CourseModel, 'code' | 'name'>>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
}) as Promise<CoursesQueryResult>
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const defaultCourse = {
|
||||
attempts: 0,
|
||||
enrollments: {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
semesters: {
|
||||
BEFORE: {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'0-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'0-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'1-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'1-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'2-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'2-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'3-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'3-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'4-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'4-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'5-FALL': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
'5-SPRING': {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
LATER: {
|
||||
[EnrollmentState.ENROLLED]: new Set<string>(),
|
||||
[EnrollmentState.REJECTED]: new Set<string>(),
|
||||
},
|
||||
},
|
||||
},
|
||||
grades: {},
|
||||
students: {
|
||||
all: new Set<string>(),
|
||||
passed: new Set<string>(),
|
||||
failed: new Set<string>(),
|
||||
improvedPassedGrade: new Set<string>(),
|
||||
markedToSemester: new Set<string>(),
|
||||
enrolledNoGrade: new Set<string>(),
|
||||
},
|
||||
|
||||
stats: {
|
||||
passingSemesters: {
|
||||
BEFORE: 0,
|
||||
'0-FALL': 0,
|
||||
'0-SPRING': 0,
|
||||
'1-FALL': 0,
|
||||
'1-SPRING': 0,
|
||||
'2-FALL': 0,
|
||||
'2-SPRING': 0,
|
||||
'3-FALL': 0,
|
||||
'3-SPRING': 0,
|
||||
'4-FALL': 0,
|
||||
'4-SPRING': 0,
|
||||
'5-FALL': 0,
|
||||
'5-SPRING': 0,
|
||||
LATER: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const parseCreditInfo = (credit: CreditPick) => {
|
||||
return {
|
||||
studentnumber: credit.student_studentnumber,
|
||||
grade: credit.grade,
|
||||
passingGrade: CreditModel.passed(credit),
|
||||
failingGrade: CreditModel.failed(credit),
|
||||
improvedGrade: CreditModel.improved(credit),
|
||||
date: credit.attainment_date,
|
||||
export const parseCourseData = async (
|
||||
studentStartingYears: Map<string, number>,
|
||||
enrollments: StudentEnrollment[],
|
||||
credits: StudentCredit[]
|
||||
): Promise<CourseStats[]> => {
|
||||
const getYear = (studentnumber: string) => studentStartingYears.get(studentnumber)!
|
||||
|
||||
const coursestats = new Map<string, typeof defaultCourse>()
|
||||
for (const enrollment of enrollments) {
|
||||
const { course_code, studentnumber, state, enrollment_date_time } = enrollment
|
||||
|
||||
// We cannot display these
|
||||
if (course_code === null) continue
|
||||
|
||||
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
|
||||
const course = coursestats.get(course_code)!
|
||||
|
||||
const initialDate = new Date(enrollment_date_time)
|
||||
const semester = getPassingSemester(getYear(studentnumber), initialDate)
|
||||
|
||||
course.students.all.add(studentnumber)
|
||||
course.students.enrolledNoGrade.add(studentnumber)
|
||||
course.enrollments[state].add(studentnumber)
|
||||
course.enrollments.semesters[semester][state].add(studentnumber)
|
||||
}
|
||||
|
||||
for (const credit of credits) {
|
||||
const { course_code, student_studentnumber: studentnumber, grade, attainment_date: date } = credit
|
||||
|
||||
// We cannot display these
|
||||
if (course_code === null) continue
|
||||
|
||||
const passingGrade = CreditModel.passed(credit)
|
||||
const failingGrade = CreditModel.failed(credit)
|
||||
const improvedGrade = CreditModel.improved(credit)
|
||||
|
||||
if (!coursestats.has(course_code)) coursestats.set(course_code, structuredClone(defaultCourse))
|
||||
const course = coursestats.get(course_code)!
|
||||
|
||||
course.attempts += 1
|
||||
const gradeCount = course.grades[grade]?.count ?? 0
|
||||
course.grades[grade] = { count: gradeCount + 1, status: { passingGrade, failingGrade, improvedGrade } }
|
||||
|
||||
course.students.all.add(studentnumber)
|
||||
course.students.enrolledNoGrade.delete(studentnumber)
|
||||
if (passingGrade) {
|
||||
if (!course.students.markedToSemester.has(studentnumber)) {
|
||||
course.students.markedToSemester.add(studentnumber)
|
||||
|
||||
const semester = getPassingSemester(getYear(studentnumber), new Date(date))
|
||||
course.stats.passingSemesters[semester]++
|
||||
}
|
||||
|
||||
course.students.passed.add(studentnumber)
|
||||
course.students.failed.delete(studentnumber)
|
||||
} else if (improvedGrade) {
|
||||
course.students.improvedPassedGrade.add(studentnumber)
|
||||
course.students.passed.add(studentnumber)
|
||||
course.students.failed.delete(studentnumber)
|
||||
} else if (failingGrade && !course.students.passed.has(studentnumber)) {
|
||||
course.students.failed.add(studentnumber)
|
||||
}
|
||||
}
|
||||
|
||||
const courses = await getCourses(Array.from(coursestats.keys()))
|
||||
const courseMap = new Map(courses.map(({ code, name }) => [code, name]))
|
||||
|
||||
return Array.from(coursestats.entries()).map(([code, { attempts, enrollments, grades, students, stats }]) => {
|
||||
return {
|
||||
course: {
|
||||
code,
|
||||
name: courseMap.get(code) ?? { en: code },
|
||||
substitutions: [],
|
||||
},
|
||||
attempts,
|
||||
enrollments: {
|
||||
[EnrollmentState.ENROLLED]: Array.from(enrollments[EnrollmentState.ENROLLED]),
|
||||
[EnrollmentState.REJECTED]: Array.from(enrollments[EnrollmentState.REJECTED]),
|
||||
semesters: Object.fromEntries(
|
||||
Object.entries(enrollments.semesters).map(([key, val]) => [
|
||||
key,
|
||||
{
|
||||
[EnrollmentState.ENROLLED]: Array.from(val[EnrollmentState.ENROLLED]),
|
||||
[EnrollmentState.REJECTED]: Array.from(val[EnrollmentState.REJECTED]),
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
grades,
|
||||
students: Object.fromEntries(Object.entries(students).map(([key, val]) => [key, Array.from(val)])),
|
||||
stats,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
|
||||
import { formatStudentForAPI } from './formatStatisticsForApi'
|
||||
import {
|
||||
type StudentEnrollment,
|
||||
type StudentCredit,
|
||||
getStudents,
|
||||
getEnrollments,
|
||||
getCredits,
|
||||
StudentTags,
|
||||
} from './getStudentData'
|
||||
import { getOptionsForStudents, parseCourseData } from './shared'
|
||||
|
||||
export type OptimizedStatisticsQuery = {
|
||||
userId: string
|
||||
semesters: string[]
|
||||
studentStatuses?: string[]
|
||||
studyRights?: string | string[]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
export type AnonymousEnrollment = Omit<StudentEnrollment, 'studentnumber'>
|
||||
export type AnonymousCredit = Omit<StudentCredit, 'student_studentnumber'>
|
||||
|
||||
export type StudentEnrollmentObject = Map<StudentEnrollment['studentnumber'], AnonymousEnrollment[]>
|
||||
export type StudentCreditObject = Map<StudentCredit['student_studentnumber'], AnonymousCredit[]>
|
||||
|
||||
export const statisticsOf = async (
|
||||
studentNumbers: string[],
|
||||
studyRights: string[],
|
||||
tagList: Record<string, StudentTags[]>,
|
||||
startDate?: string
|
||||
) => {
|
||||
const code = studyRights[0] ?? ''
|
||||
const mockedStartDate = startDate ?? new Date(1900).toISOString()
|
||||
|
||||
const [enrollments, credits, students] = await Promise.all([
|
||||
getEnrollments(studentNumbers, mockedStartDate),
|
||||
getCredits(studentNumbers, studyRights, mockedStartDate),
|
||||
getStudents(studentNumbers),
|
||||
])
|
||||
|
||||
const studentStartingYears = new Map(
|
||||
students.map(({ studentnumber, studyRights }) => [
|
||||
studentnumber,
|
||||
studyRights
|
||||
?.flatMap(({ studyRightElements }) => studyRightElements)
|
||||
.find(element => element.code === code)
|
||||
?.startDate.getFullYear() ?? +mockedStartDate,
|
||||
])
|
||||
)
|
||||
|
||||
const formattedCoursestats = await parseCourseData(studentStartingYears, enrollments, credits)
|
||||
|
||||
const creditsByStudent: StudentCreditObject = new Map<string, AnonymousCredit[]>(studentNumbers.map(n => [n, []]))
|
||||
credits.forEach(credit =>
|
||||
creditsByStudent.get(credit.student_studentnumber)!.push(omitKeys(credit, ['student_studentnumber']))
|
||||
)
|
||||
|
||||
const enrollmentsByStudent: StudentEnrollmentObject = new Map<string, AnonymousEnrollment[]>(
|
||||
studentNumbers.map(n => [n, []])
|
||||
)
|
||||
enrollments.forEach(enrollment =>
|
||||
enrollmentsByStudent.get(enrollment.studentnumber)!.push(omitKeys(enrollment, ['studentnumber']))
|
||||
)
|
||||
|
||||
const criteria = await getCriteria(code)
|
||||
const optionData = await getOptionsForStudents(studentNumbers, code, await getDegreeProgrammeType(code))
|
||||
|
||||
return {
|
||||
coursestatistics: formattedCoursestats,
|
||||
students: students.map(student =>
|
||||
formatStudentForAPI(
|
||||
code,
|
||||
mockedStartDate,
|
||||
{ ...student, tags: tagList[student.studentnumber] },
|
||||
creditsByStudent.get(student.studentnumber) ?? [],
|
||||
enrollmentsByStudent.get(student.studentnumber) ?? [],
|
||||
optionData?.[student.studentnumber],
|
||||
criteria
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,11 @@ type StudentStudyRights = Pick<SISStudyRightModel, 'studentNumber' | 'studyRight
|
||||
studyRightElements: Array<Pick<SISStudyRightElementModel, 'code' | 'endDate' | 'startDate' | 'phase'>>
|
||||
}
|
||||
|
||||
export const getStudentNumbersWithStudyRights = async ({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
includeExchangeStudents,
|
||||
includeNondegreeStudents,
|
||||
includeTransferredOutStudents,
|
||||
}) => {
|
||||
export const getStudentNumbersWithStudyRights = async ({ programmeCodes, startDate, endDate, studentStatuses }) => {
|
||||
const includeExchangeStudents = !!studentStatuses?.includes('EXCHANGE')
|
||||
const includeNondegreeStudents = !!studentStatuses?.includes('NONDEGREE')
|
||||
const includeTransferredOutStudents = !!studentStatuses?.includes('TRANSFERRED')
|
||||
|
||||
const filteredExtents = [
|
||||
ExtentCode.STUDIES_FOR_SECONDARY_SCHOOL_STUDENTS,
|
||||
...(!includeExchangeStudents ? exchangeStudents : []),
|
||||
@@ -42,7 +39,7 @@ export const getStudentNumbersWithStudyRights = async ({
|
||||
model: SISStudyRightElementModel,
|
||||
attributes: [],
|
||||
where: {
|
||||
code: { [Op.in]: studyRights },
|
||||
code: { [Op.in]: programmeCodes },
|
||||
startDate: { [Op.gte]: startDate, [Op.lt]: endDate },
|
||||
},
|
||||
},
|
||||
@@ -73,7 +70,7 @@ export const getStudentNumbersWithStudyRights = async ({
|
||||
if (includeTransferredOutStudents) return true
|
||||
|
||||
// NOTE: We know that this will always return a value because of the first query in the function
|
||||
const element = student.studyRightElements.find(element => studyRights.includes(element.code))!
|
||||
const element = student.studyRightElements.find(element => programmeCodes.includes(element.code))!
|
||||
const [hasTransferredFromProgramme, _] = hasTransferredFromOrToProgramme(student, element)
|
||||
|
||||
return !hasTransferredFromProgramme
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { CurriculumOption, Name } from '@oodikone/shared/types'
|
||||
import { ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { CurriculumOption, DegreeProgrammeType, Name } from '@oodikone/shared/types'
|
||||
import { dbConnections } from '../database/connection'
|
||||
import { CurriculumPeriodModel, ProgrammeModuleModel } from '../models'
|
||||
import { ExcludedCourseModel } from '../models/kone'
|
||||
@@ -46,27 +47,14 @@ export const getCurriculumOptions = async (code: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
type ModuleWithChildren = Pick<
|
||||
ProgrammeModuleModel,
|
||||
| 'id'
|
||||
| 'code'
|
||||
| 'name'
|
||||
| 'type'
|
||||
| 'order'
|
||||
| 'organization_id'
|
||||
| 'valid_from'
|
||||
| 'valid_to'
|
||||
| 'group_id'
|
||||
| 'curriculum_period_ids'
|
||||
> & {
|
||||
parent_id: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
study_level: string | null
|
||||
degree_programme_type: string | null
|
||||
module_order: number | null
|
||||
parent_name: Name
|
||||
type ModuleWithChildren = Pick<ProgrammeModule, 'id' | 'type' | 'code' | 'name'> & {
|
||||
// TODO: This is supposed to be in the ProgrammeModule
|
||||
degree_programme_type: DegreeProgrammeType | null
|
||||
|
||||
module_order: number
|
||||
parent_name: Name | null
|
||||
parent_code: string | null
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_ids: string[]) => {
|
||||
@@ -75,15 +63,47 @@ const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_i
|
||||
try {
|
||||
const result: ModuleWithChildren[] = await connection.query(
|
||||
`WITH RECURSIVE children as (
|
||||
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS parent_name, NULL AS parent_code, NULL as parent_id FROM programme_modules pm
|
||||
WHERE pm.code = ? AND ARRAY[?]::text[] && curriculum_period_ids
|
||||
SELECT
|
||||
DISTINCT pm.*,
|
||||
0 AS module_order,
|
||||
NULL::jsonb AS parent_name,
|
||||
NULL AS parent_code,
|
||||
NULL as parent_id
|
||||
FROM programme_modules pm
|
||||
WHERE pm.code = :code
|
||||
AND ARRAY[:curriculum_period_ids]::text[] && curriculum_period_ids
|
||||
UNION ALL
|
||||
SELECT pm.*, c.order AS module_order, c.name AS parent_name, c.code AS parent_code, c.id as parent_id
|
||||
SELECT
|
||||
pm.*,
|
||||
c.order AS module_order,
|
||||
c.name AS parent_name,
|
||||
c.code AS parent_code,
|
||||
c.id as parent_id
|
||||
FROM children c, programme_modules pm, programme_module_children pmc
|
||||
WHERE c.id = pmc.parent_id AND pm.group_id = pmc.child_id AND (ARRAY[?]::text[] && pm.curriculum_period_ids OR pm.type = 'course' OR pm.code is null)
|
||||
WHERE c.id = pmc.parent_id
|
||||
AND pm.group_id = pmc.child_id
|
||||
AND (
|
||||
ARRAY[:curriculum_period_ids]::text[] && pm.curriculum_period_ids
|
||||
OR pm.type = 'course'
|
||||
OR pm.code is null
|
||||
)
|
||||
GROUP BY pm.id, c.name, c.code, c.order, c.id
|
||||
) SELECT * FROM children`,
|
||||
{ replacements: [code, curriculum_period_ids, curriculum_period_ids], type: QueryTypes.SELECT }
|
||||
) SELECT
|
||||
id,
|
||||
type,
|
||||
module_order,
|
||||
parent_code,
|
||||
parent_name,
|
||||
parent_id,
|
||||
|
||||
code,
|
||||
name,
|
||||
degree_programme_type
|
||||
FROM children`,
|
||||
{
|
||||
replacements: { code, curriculum_period_ids },
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
@@ -94,11 +114,11 @@ const recursivelyGetModuleAndChildren = async (code: string, curriculum_period_i
|
||||
}
|
||||
|
||||
const modifyParent = (course: ModuleWithChildren, moduleMap: Record<string, ModuleWithChildren>) => {
|
||||
let parent = moduleMap[course.parent_id]
|
||||
let parent = moduleMap[course.parent_id!]
|
||||
const parents: any[] = []
|
||||
while (parent) {
|
||||
parents.push(parent)
|
||||
parent = moduleMap[parent.parent_id]
|
||||
parent = moduleMap[parent.parent_id!]
|
||||
}
|
||||
|
||||
const skip = 0
|
||||
@@ -117,12 +137,13 @@ const modifyParent = (course: ModuleWithChildren, moduleMap: Record<string, Modu
|
||||
const labelProgrammes = (modules: ModuleWithChildren[], excludedCourses: ExcludedCourseModel[]) => {
|
||||
return modules.map(module => {
|
||||
const label = {
|
||||
id: module.parent_name.fi,
|
||||
label: `${module.parent_code}\n${module.parent_name.fi}`,
|
||||
id: module.parent_name?.fi,
|
||||
label: `${module.parent_code}\n${module.parent_name?.fi}`,
|
||||
orderNumber: module.module_order,
|
||||
}
|
||||
const foundCourse = excludedCourses.find(course => course.course_code === module.code)
|
||||
const visible = { visibility: !foundCourse, id: foundCourse?.id ?? null }
|
||||
|
||||
return { ...module, label, visible }
|
||||
})
|
||||
}
|
||||
@@ -157,15 +178,36 @@ const getCoursesAndModulesForProgramme = async (code: string, periodIds: string)
|
||||
index
|
||||
)
|
||||
|
||||
return { courses: labelProgrammes(modifiedCourses, excludedCourses), modules }
|
||||
return {
|
||||
courses: labelProgrammes(modifiedCourses, excludedCourses).map(mod => ({
|
||||
code: mod.code,
|
||||
name: mod.name,
|
||||
degree_programme_type: mod.degree_programme_type,
|
||||
module_order: mod.module_order,
|
||||
parent_code: mod.parent_code,
|
||||
parent_name: mod.parent_name,
|
||||
label: mod.label,
|
||||
visible: mod.visible,
|
||||
})),
|
||||
modules: modules.map(mod => ({
|
||||
code: mod.code,
|
||||
name: mod.name,
|
||||
degree_programme_type: mod.degree_programme_type,
|
||||
module_order: mod.module_order,
|
||||
parent_code: mod.parent_code,
|
||||
parent_name: mod.parent_name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const getCoursesAndModules = async (code: string, periodIds: string) => {
|
||||
const defaultProgrammeCourses = await getCoursesAndModulesForProgramme(code, periodIds)
|
||||
|
||||
if (code in combinedStudyProgrammes) {
|
||||
const secondProgramme = combinedStudyProgrammes[code as keyof typeof combinedStudyProgrammes]
|
||||
const secondProgrammeCourses = await getCoursesAndModulesForProgramme(secondProgramme, periodIds)
|
||||
return { defaultProgrammeCourses, secondProgrammeCourses }
|
||||
}
|
||||
|
||||
return { defaultProgrammeCourses, secondProgrammeCourses: { courses: [], modules: [] } }
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { dateMinFromList, dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import { SemesterModel } from '../models'
|
||||
import { dateMinFromList, dateMaxFromList } from '../util/datetime'
|
||||
|
||||
type SemestersAndYears = {
|
||||
years: {
|
||||
[yearcode: string]: {
|
||||
years: Record<
|
||||
string,
|
||||
{
|
||||
yearcode: number
|
||||
yearname: string
|
||||
startdate: Date
|
||||
enddate: Date
|
||||
}
|
||||
}
|
||||
semesters: {
|
||||
[semestercode: string]: {
|
||||
>
|
||||
semesters: Record<
|
||||
string,
|
||||
{
|
||||
semestercode: number
|
||||
name: Name
|
||||
yearcode: number
|
||||
startdate: Date
|
||||
enddate: Date
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const getSemesterNameByCode = async (semesterCode: number) => {
|
||||
const semester = await SemesterModel.findOne({
|
||||
attributes: ['name'],
|
||||
export const getSemesterNames = async (semesterCodes: number[]) => {
|
||||
const semesters = await SemesterModel.findAll({
|
||||
attributes: ['name', 'semestercode'],
|
||||
where: {
|
||||
semestercode: semesterCode,
|
||||
semestercode: { [Op.in]: semesterCodes },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
return semester!
|
||||
|
||||
return Object.fromEntries(semesters?.map(({ name, semestercode }) => [semestercode, name]) ?? [])
|
||||
}
|
||||
|
||||
export const getCurrentSemester = async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import type { Credit, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import { EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { dbConnections } from '../database/connection'
|
||||
import {
|
||||
StudentModel,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
SISStudyRightElementModel,
|
||||
} from '../models'
|
||||
import { TagModel, TagStudentModel } from '../models/kone'
|
||||
import { UnifyStatus } from '../types'
|
||||
import { splitByEmptySpace } from '../util'
|
||||
|
||||
const { sequelize } = dbConnections
|
||||
@@ -217,7 +217,7 @@ const formatSharedStudentData = ({
|
||||
updatedAt,
|
||||
createdAt,
|
||||
sis_person_id,
|
||||
}: Student) => {
|
||||
}: Student): Omit<StudentPageStudent, 'tags'> => {
|
||||
const toCourse = ({ grade, credits, credittypecode, is_open, attainment_date, course, isStudyModule }: Credit) => {
|
||||
return {
|
||||
course: {
|
||||
@@ -239,8 +239,8 @@ const formatSharedStudentData = ({
|
||||
.map(toCourse)
|
||||
|
||||
return {
|
||||
firstnames,
|
||||
lastname,
|
||||
firstNames: firstnames,
|
||||
lastName: lastname,
|
||||
studyRights: studyRights || [],
|
||||
studentNumber: studentnumber,
|
||||
started: dateofuniversityenrollment,
|
||||
@@ -250,18 +250,24 @@ const formatSharedStudentData = ({
|
||||
email,
|
||||
updatedAt: updatedAt || createdAt,
|
||||
studyplans: studyplans || [],
|
||||
sis_person_id,
|
||||
sisPersonId: sis_person_id,
|
||||
}
|
||||
}
|
||||
|
||||
export const withStudentNumber = async (studentNumber: string, userId: string) => {
|
||||
export const withStudentNumber = async (studentNumber: string, userId: string): Promise<StudentPageStudent | null> => {
|
||||
const student = await byStudentNumber(studentNumber)
|
||||
if (student == null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...formatSharedStudentData(student),
|
||||
tags: student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId),
|
||||
tags: student.tags
|
||||
.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
.map(tag => ({
|
||||
programme: tag.programme,
|
||||
tagId: tag.tag_id,
|
||||
tag: tag.tag,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,44 +2,13 @@ import * as Sentry from '@sentry/node'
|
||||
import { AxiosError } from 'axios'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
|
||||
import { StudyGuidanceGroupTagModel } from '../models/kone'
|
||||
import { getImporterClient } from '../util/importerClient'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
type ValidityPeriod = { startDate?: string; endDate?: string }
|
||||
|
||||
type ResponsibilityInfo = {
|
||||
text: string | null
|
||||
roleUrn: string
|
||||
personId: string
|
||||
validityPeriod: ValidityPeriod
|
||||
}
|
||||
|
||||
type Member = {
|
||||
id: string
|
||||
personGroupId: string
|
||||
personGroupName: Name
|
||||
personId: string
|
||||
personFirstNames: string
|
||||
personLastName: string
|
||||
personStudentNumber: string
|
||||
validityPeriod: ValidityPeriod
|
||||
personPrimaryEmail: string
|
||||
}
|
||||
|
||||
type StudyGuidanceGroup = {
|
||||
id: string
|
||||
name: Name
|
||||
responsibilityInfos: ResponsibilityInfo[]
|
||||
type: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
members: Member[]
|
||||
}
|
||||
|
||||
const getGroupsFromImporter = async (sisPersonId: string) => {
|
||||
if (!importerClient || !sisPersonId) {
|
||||
return []
|
||||
@@ -66,14 +35,6 @@ const getGroupsFromImporter = async (sisPersonId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
type Tags = { studyProgramme?: string | null; year?: string | null }
|
||||
|
||||
type TagsByGroupId = {
|
||||
[groupId: string]: Tags
|
||||
}
|
||||
|
||||
type GroupsWithTags = StudyGuidanceGroup & { tags: Tags }
|
||||
|
||||
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
|
||||
const tags = await StudyGuidanceGroupTagModel.findAll()
|
||||
const tagsByGroupId = tags.reduce((acc, curr) => {
|
||||
|
||||
@@ -15,9 +15,7 @@ import { getStudyRightsInProgramme } from './studyRightFinders'
|
||||
|
||||
export const getDateOfFirstSemesterPresent = (
|
||||
semesterEnrollments: SemesterEnrollment[] | null,
|
||||
semesters: {
|
||||
[semestercode: string]: { semestercode: number; name: Name; yearcode: number; startdate: Date; enddate: Date }
|
||||
},
|
||||
semesters: Record<string, { semestercode: number; name: Name; yearcode: number; startdate: Date; enddate: Date }>,
|
||||
currentSemester: number,
|
||||
since: Date
|
||||
) => {
|
||||
|
||||
@@ -67,46 +67,40 @@ const makeYearlyPromises = (
|
||||
type: 'passed' | 'notCompleted' | 'ownStudents' | 'withoutStudyRight' | 'otherStudents' | 'transfer',
|
||||
programmeCourses: string[],
|
||||
studyProgramme?: string
|
||||
): Promise<Students[]>[] => {
|
||||
return years.map(
|
||||
year =>
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
new Promise(async res => {
|
||||
const from = getFrom(academicYear, year)
|
||||
const to = getTo(academicYear, year)
|
||||
let result: Students[] | null = null
|
||||
) => {
|
||||
return years.map(async year => {
|
||||
const from = getFrom(academicYear, year)
|
||||
const to = getTo(academicYear, year)
|
||||
let result: Students[] | null = null
|
||||
|
||||
switch (type) {
|
||||
case 'passed':
|
||||
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'notCompleted':
|
||||
result = await getNotCompletedForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'ownStudents':
|
||||
result = await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
|
||||
break
|
||||
case 'withoutStudyRight':
|
||||
result = await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'otherStudents':
|
||||
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
|
||||
break
|
||||
case 'transfer':
|
||||
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
default:
|
||||
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
}
|
||||
switch (type) {
|
||||
case 'passed':
|
||||
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'notCompleted':
|
||||
result = await getNotCompletedForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'ownStudents':
|
||||
result = await getOwnStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
|
||||
break
|
||||
case 'withoutStudyRight':
|
||||
result = await getStudentsWithoutStudyRightForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
case 'otherStudents':
|
||||
result = await getOtherStudentsForProgrammeCourses(from, to, programmeCourses, studyProgramme)
|
||||
break
|
||||
case 'transfer':
|
||||
result = await getTransferStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
break
|
||||
default:
|
||||
result = await getStudentsForProgrammeCourses(from, to, programmeCourses)
|
||||
}
|
||||
|
||||
res(
|
||||
result.map(course => {
|
||||
course.year = year
|
||||
return course
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
return result.map(course => {
|
||||
course.year = year
|
||||
return course
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type Attempt = {
|
||||
|
||||
@@ -70,6 +70,7 @@ const createCriteria = async (
|
||||
return await formatCriteria(createdCriteria)
|
||||
} catch (error) {
|
||||
logger.error(`Creating criteria failed: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +101,7 @@ export const saveYearlyCreditCriteria = async (studyProgramme: string, credits:
|
||||
return await formatCriteria(updatedCriteria)
|
||||
} catch (error) {
|
||||
logger.error(`Updating yearly credit criteria failed: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ export const saveYearlyCourseCriteria = async (studyProgramme: string, courses:
|
||||
return await formatCriteria(updatedCriteria)
|
||||
} catch (error) {
|
||||
logger.error(`Updating yearly credit criteria failed: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { indexOf, orderBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import {
|
||||
GraduationStatistics,
|
||||
@@ -49,15 +49,12 @@ const calculateAbsenceInMonths = (
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) => {
|
||||
const absenceStart = moment(absence.startdate)
|
||||
const absenceEnd = moment(absence.enddate)
|
||||
|
||||
if (absenceStart.isAfter(endDate) || absenceEnd.isBefore(startDate)) {
|
||||
if (endDate < absence.startdate || absence.enddate < startDate) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
|
||||
return Math.round(absenceEnd.diff(absenceStart, 'months', true))
|
||||
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
|
||||
}
|
||||
|
||||
export const calculateDurationOfStudies = (
|
||||
@@ -73,7 +70,7 @@ export const calculateDurationOfStudies = (
|
||||
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
|
||||
0
|
||||
)
|
||||
return Math.round(moment(graduationDate).subtract(monthsToSubtract, 'months').diff(moment(startDate), 'months', true))
|
||||
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
|
||||
}
|
||||
|
||||
export type GraduationTimes = {
|
||||
@@ -238,9 +235,7 @@ type QueryParameters = {
|
||||
includeAllSpecials: boolean
|
||||
}
|
||||
|
||||
type ProgrammeWithYears = {
|
||||
[key: string]: number | string | Name
|
||||
} & StudyTrack
|
||||
type ProgrammeWithYears = Record<string, number | string | Name> & StudyTrack
|
||||
|
||||
const getProgrammesBeforeStarting = async ({
|
||||
studyProgramme,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
import { serviceProvider } from '../../config'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { ProgrammeModuleModel, SISStudyRightModel } from '../../models'
|
||||
import { ProgrammeModuleModel } from '../../models'
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
|
||||
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
|
||||
@@ -198,10 +198,7 @@ export const getGoal = async (programme?: string) => {
|
||||
|
||||
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
|
||||
|
||||
export const getStudyRightElementsWithPhase = (
|
||||
studyRight: Pick<SISStudyRightModel, 'studyRightElements'>,
|
||||
phase: Phase
|
||||
) => {
|
||||
export const getStudyRightElementsWithPhase = (studyRight: Pick<SISStudyRight, 'studyRightElements'>, phase: Phase) => {
|
||||
return orderBy(
|
||||
studyRight.studyRightElements.filter(element => element.phase === phase),
|
||||
['startDate'],
|
||||
@@ -210,8 +207,8 @@ export const getStudyRightElementsWithPhase = (
|
||||
}
|
||||
|
||||
export const hasTransferredFromOrToProgramme = (
|
||||
studyRight: Pick<SISStudyRightModel, 'studyRightElements'>,
|
||||
studyRightElement: SISStudyRightElement
|
||||
studyRight: Pick<SISStudyRight, 'studyRightElements'>,
|
||||
studyRightElement: Pick<SISStudyRightElement, 'code' | 'phase' | 'startDate' | 'endDate'>
|
||||
): [boolean, boolean] => {
|
||||
const studyRightElementsWithSamePhase = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
|
||||
const hasTransferredToProgramme =
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Credit, SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import {
|
||||
GraduationTimes,
|
||||
@@ -31,9 +29,7 @@ import {
|
||||
import { getStudyTracksForProgramme } from './studyRightFinders'
|
||||
|
||||
const getCreditCount = (credits: Credit[], startDate: Date) =>
|
||||
credits
|
||||
.filter(credit => moment(credit.attainment_date).isSameOrAfter(startDate))
|
||||
.reduce((prev, curr) => prev + curr.credits, 0)
|
||||
credits.filter(credit => startDate <= credit.attainment_date).reduce((prev, curr) => prev + curr.credits, 0)
|
||||
|
||||
const getGraduationTimeStats = async (
|
||||
studyProgramme: string,
|
||||
@@ -120,12 +116,9 @@ const combineStats = (
|
||||
for (const year of Object.keys(yearlyStats)) {
|
||||
for (const programmeOrStudyTrack of Object.keys(yearlyStats[year])) {
|
||||
const yearStats = yearlyStats[year][programmeOrStudyTrack]
|
||||
if (!mainStatsByYear[year]) {
|
||||
mainStatsByYear[year] = []
|
||||
}
|
||||
if (!mainStatsByTrack[programmeOrStudyTrack]) {
|
||||
mainStatsByTrack[programmeOrStudyTrack] = []
|
||||
}
|
||||
|
||||
mainStatsByYear[year] ??= []
|
||||
mainStatsByTrack[programmeOrStudyTrack] ??= []
|
||||
|
||||
const yearArray = [
|
||||
yearStats.all,
|
||||
@@ -201,13 +194,11 @@ const getMainStatsByTrackAndYear = async (
|
||||
startedInProgramme: Date,
|
||||
studyRightElement: SISStudyRightElement
|
||||
) => {
|
||||
if (!yearlyStats[year]) {
|
||||
yearlyStats[year] = {}
|
||||
}
|
||||
if (!yearlyStats[year][programmeOrStudyTrack]) {
|
||||
yearlyStats[year][programmeOrStudyTrack] = getEmptyYear()
|
||||
}
|
||||
yearlyStats[year] ??= {}
|
||||
yearlyStats[year][programmeOrStudyTrack] ??= getEmptyYear()
|
||||
yearlyStats[year][programmeOrStudyTrack].all += 1
|
||||
|
||||
/* Started studying col */
|
||||
if (!hasTransferredToProgramme) {
|
||||
const firstPresentAt = getDateOfFirstSemesterPresent(
|
||||
studyRight.semesterEnrollments,
|
||||
@@ -222,6 +213,8 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Gender cols */
|
||||
if (studyRight.student.gender_code === GenderCode.MALE) {
|
||||
yearlyStats[year][programmeOrStudyTrack].male += 1
|
||||
} else if (studyRight.student.gender_code === GenderCode.FEMALE) {
|
||||
@@ -230,6 +223,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
yearlyStats[year][programmeOrStudyTrack].otherUnknown += 1
|
||||
}
|
||||
|
||||
/* Current status cols */
|
||||
const hasGraduated = studyRightElement.graduated
|
||||
const hasGraduatedFromCombinedProgramme = combinedProgramme
|
||||
? (studyRight.studyRightElements.find(element => element.code === combinedProgramme)?.graduated ?? false)
|
||||
@@ -238,6 +232,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
if (hasGraduated) {
|
||||
yearlyStats[year][programmeOrStudyTrack].graduated += 1
|
||||
}
|
||||
|
||||
if (hasGraduatedFromCombinedProgramme) {
|
||||
yearlyStats[year][programmeOrStudyTrack].graduatedCombinedProgramme += 1
|
||||
} else if (combinedProgramme || !hasGraduated) {
|
||||
@@ -253,6 +248,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
/* Citizenship cols */
|
||||
const studentCitizenships = studyRight.student.citizenships
|
||||
|
||||
for (const citizenship of studentCitizenships) {
|
||||
@@ -269,6 +265,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
/* Graduation statistics */
|
||||
if (!hasGraduated) return
|
||||
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
|
||||
const [firstStudyRightElementWithSamePhase] = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
|
||||
@@ -368,7 +365,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
studyRightElement
|
||||
)
|
||||
|
||||
const studyTrack = studyRightElement.studyTrack?.code ?? null
|
||||
const studyTrack = studyRightElement.studyTrack?.code
|
||||
if (studyTrack) {
|
||||
updateCounts(
|
||||
startYear,
|
||||
@@ -379,6 +376,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
studyRightElement
|
||||
)
|
||||
}
|
||||
|
||||
const countAsBachelorMaster = doCombo && studyRight.extentCode === ExtentCode.BACHELOR_AND_MASTER
|
||||
if (countAsBachelorMaster) {
|
||||
const startedInBachelor = getStudyRightElementsWithPhase(studyRight, 1)[0]?.startDate
|
||||
@@ -395,12 +393,8 @@ const getMainStatsByTrackAndYear = async (
|
||||
for (const year of Object.keys(yearlyStats)) {
|
||||
if (year === 'Total') continue
|
||||
for (const track of Object.keys(yearlyStats[year])) {
|
||||
if (!yearlyStats.Total) {
|
||||
yearlyStats.Total = {}
|
||||
}
|
||||
if (!yearlyStats.Total[track]) {
|
||||
yearlyStats.Total[track] = getEmptyYear()
|
||||
}
|
||||
yearlyStats.Total ??= {}
|
||||
yearlyStats.Total[track] ??= getEmptyYear()
|
||||
|
||||
for (const field of keysOf(yearlyStats[year][track])) {
|
||||
if (field !== 'otherCountriesCounts') {
|
||||
@@ -408,9 +402,7 @@ const getMainStatsByTrackAndYear = async (
|
||||
continue
|
||||
}
|
||||
for (const country of Object.keys(yearlyStats[year][track].otherCountriesCounts)) {
|
||||
if (!yearlyStats.Total[track].otherCountriesCounts[country]) {
|
||||
yearlyStats.Total[track].otherCountriesCounts[country] = 0
|
||||
}
|
||||
yearlyStats.Total[track].otherCountriesCounts[country] ??= 0
|
||||
yearlyStats.Total[track].otherCountriesCounts[country] +=
|
||||
yearlyStats[year][track].otherCountriesCounts[country]
|
||||
}
|
||||
@@ -462,7 +454,7 @@ export const getStudyTrackStatsForStudyProgramme = async ({
|
||||
studyProgramme: string
|
||||
combinedProgramme?: string
|
||||
settings: { graduated: boolean; specialGroups: boolean }
|
||||
studyRightsOfProgramme: Array<SISStudyRight>
|
||||
studyRightsOfProgramme: SISStudyRight[]
|
||||
}) => {
|
||||
const isAcademicYear = true
|
||||
const includeYearsCombined = true
|
||||
|
||||
@@ -64,10 +64,8 @@ const parseCreditInfo = (credit: Credit) => ({
|
||||
|
||||
type Stats = { credits: number; passed: number; failed: number; transferred: number }
|
||||
|
||||
const markCredit = (stats: Stats, credits: number, passed: boolean, failed: boolean, transferred: boolean) => {
|
||||
if (!stats) {
|
||||
stats = { credits: 0, passed: 0, failed: 0, transferred: 0 }
|
||||
}
|
||||
const markCredit = (paramStats: Stats, credits: number, passed: boolean, failed: boolean, transferred: boolean) => {
|
||||
const stats = paramStats ?? { credits: 0, passed: 0, failed: 0, transferred: 0 }
|
||||
if (passed) {
|
||||
stats.credits = transferred ? stats.credits : stats.credits + credits
|
||||
stats.passed += 1
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
export { CourseWithSubsId, ParsedCourse } from './course'
|
||||
export { IamAccess } from './iamAccess'
|
||||
export { Unification, UnifyStatus } from './unification'
|
||||
export { ExpandedUser, FormattedUser } from './user'
|
||||
|
||||
/**
|
||||
* A utility type that extracts the type of the elements of an array. This type
|
||||
* takes a generic type `T` which extends an array of any type, and returns the
|
||||
* type of the elements in that array. If `T` is not an array, it returns `never`.
|
||||
*
|
||||
* @example
|
||||
* type ElementType = Unarray<number[]>; // number
|
||||
*/
|
||||
export type Unarray<T extends any[]> = T extends Array<infer U> ? U : never
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dateDaysFromNow } from './datetime'
|
||||
import { dateDaysFromNow } from '@oodikone/shared/util/datetime'
|
||||
|
||||
const isSpring = (date: Date) => {
|
||||
return 0 <= date.getMonth() && date.getMonth() <= 6
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const Sentry = require('@sentry/node')
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const { Worker } = require('bullmq')
|
||||
const moment = require('moment')
|
||||
const { Worker: BullMQWorker } = require('bullmq')
|
||||
const dayjs = require('dayjs')
|
||||
|
||||
const { redis, concurrentWorkers } = require('../config')
|
||||
const logger = require('../util/logger')
|
||||
@@ -15,18 +14,19 @@ const connection = {
|
||||
// https://github.com/taskforcesh/bullmq/issues/2075#issuecomment-1646079335
|
||||
process.execArgv = process.execArgv.filter(arg => !arg.includes('--max_old_space_size='))
|
||||
|
||||
const worker = new Worker(queueName, `${__dirname}/processor.js`, {
|
||||
const worker = new BullMQWorker(queueName, `${__dirname}/processor.js`, {
|
||||
connection,
|
||||
useWorkerThreads: true,
|
||||
concurrency: concurrentWorkers,
|
||||
})
|
||||
|
||||
worker.on('completed', job => {
|
||||
const timeUsed = moment.duration(moment(job.finishedOn).diff(job.processedOn, undefined, true))
|
||||
const timeUsedInSeconds = dayjs(job.finishedOn).diff(job.processedOn, 'seconds', true)
|
||||
const timeUsedInMinutes = timeUsedInSeconds / 60
|
||||
|
||||
const formattedTime =
|
||||
timeUsed.asSeconds() > 60
|
||||
? `${timeUsed.asMinutes().toFixed(3)} minutes`
|
||||
: `${timeUsed.asSeconds().toFixed(3)} seconds`
|
||||
timeUsedInSeconds > 60 ? `${timeUsedInMinutes.toFixed(3)} minutes` : `${timeUsedInSeconds.toFixed(3)} seconds`
|
||||
|
||||
logger.info(`Completed job: ${job.id} (took ${formattedTime})`)
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"inlineSources": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["index.js", "src"],
|
||||
"include": ["index.ts", "src"],
|
||||
"exclude": ["../frontend"]
|
||||
}
|
||||
|
||||
@@ -10,25 +10,25 @@ ENV PUBLIC_URL=$BASE_PATH
|
||||
ARG BUILT_AT
|
||||
ENV REACT_APP_BUILT_AT=$BUILT_AT
|
||||
|
||||
ARG STAGING
|
||||
ARG STAGING='false'
|
||||
ENV REACT_APP_STAGING=$STAGING
|
||||
|
||||
ARG SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_ENVIRONMENT=''
|
||||
ENV REACT_APP_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
|
||||
ARG SENTRY_RELEASE
|
||||
ARG SENTRY_RELEASE=''
|
||||
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
ARG SENTRY_DSN
|
||||
ARG SENTRY_DSN=''
|
||||
ENV REACT_APP_SENTRY_DSN=$SENTRY_DSN
|
||||
|
||||
ARG SERVICE_PROVIDER
|
||||
ARG SERVICE_PROVIDER='toska'
|
||||
ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
|
||||
|
||||
ARG LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
|
||||
ARG SIS_URL
|
||||
ARG SIS_URL='https://sisu.helsinki.fi'
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
|
||||
Generated
+74
-30
@@ -14,12 +14,13 @@
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@sentry/browser": "^9.28.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.2.1",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datetime": "^3.3.1",
|
||||
@@ -44,7 +45,7 @@
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"../shared": {
|
||||
@@ -1775,12 +1776,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1812,9 +1813,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2147,6 +2149,12 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
@@ -2652,6 +2660,39 @@
|
||||
"react-dom": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
|
||||
@@ -3241,6 +3282,7 @@
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
@@ -3251,9 +3293,9 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3261,6 +3303,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -3360,9 +3403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3380,7 +3423,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3790,6 +3833,7 @@
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4040,24 +4084,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",
|
||||
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"fdir": "^6.4.6",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.40.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
@@ -4066,14 +4110,14 @@
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "*",
|
||||
"less": "^4.0.0",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@sentry/browser": "^9.28.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.2.1",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datetime": "^3.3.1",
|
||||
@@ -47,7 +48,7 @@
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 80 80"
|
||||
>
|
||||
<path
|
||||
d="M70 30L60 56H20L10 30L27 40L40 15L52 40L70 30Z"
|
||||
fill="#fff238"
|
||||
stroke="#696969"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M40 52l9 -11l-9 -13l-9 13l9 11"
|
||||
fill="#61DAFF"
|
||||
stroke="#E8740E"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,12 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 80 80"
|
||||
>
|
||||
<path
|
||||
d="M70 30L60 56H20L10 30L27 40L40 15L52 40L70 30Z"
|
||||
fill="#fff238"
|
||||
stroke="#696969"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -35,7 +35,7 @@ const actionSuffixes = {
|
||||
success: 'SUCCESS',
|
||||
}
|
||||
|
||||
export const actionTypes = prefix => ({
|
||||
const actionTypes = prefix => ({
|
||||
attempt: `${prefix}${actionSuffixes.attempt}`,
|
||||
failure: `${prefix}${actionSuffixes.failure}`,
|
||||
success: `${prefix}${actionSuffixes.success}`,
|
||||
@@ -62,20 +62,6 @@ export const callApi = async (url, method = 'get', data, params, timeout = 0, pr
|
||||
}
|
||||
}
|
||||
|
||||
export const callController = (route, prefix, data, method = 'get', query, params, onProgress = null) => {
|
||||
const requestSettings = {
|
||||
route,
|
||||
method,
|
||||
data,
|
||||
prefix,
|
||||
query,
|
||||
params,
|
||||
onProgress,
|
||||
}
|
||||
const { attempt } = actionTypes(prefix)
|
||||
return { type: attempt, requestSettings }
|
||||
}
|
||||
|
||||
const handleError = (error, actionHistory = []) => {
|
||||
const { response } = error
|
||||
if (response && response.status) {
|
||||
|
||||
@@ -19,5 +19,5 @@ export const filterToolTips = {
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
|
||||
`,
|
||||
transferred:
|
||||
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
|
||||
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta. Oletusarvoisesti pois päältä Class statistics -näkymässä.',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export const populationStatisticsToolTips = {
|
||||
advanced: `
|
||||
advanced: {
|
||||
semesters:
|
||||
'Halutessa tarkastella vain syyslukukaudella (1.8.–31.12.) tai kevätlukukaudella (1.1.–31.7.) aloittaneita, haluttu ajanjakso valitaan tästä.',
|
||||
include:
|
||||
'Oletusarvoisesti pois suodatettuja ryhmiä voi sisällyttää mukaan tästä. Muutokset astuvat voimaan painamalla ”Fetch class with new settings”-painiketta.',
|
||||
legacy: `
|
||||
Tässä näkymässä on **oletusarvoisesti** suodatettu pois
|
||||
- vaihto-opiskelijat
|
||||
- opiskelijat, joilla ei ole tutkintoon johtavaa opinto-oikeutta
|
||||
@@ -11,6 +16,7 @@ export const populationStatisticsToolTips = {
|
||||
|
||||
**Include**: Oletusarvoisesti pois suodatetut ryhmät voi sisällyttää mukaan ja klikkaamalla ”Fetch class with new settings”-painiketta.
|
||||
`,
|
||||
},
|
||||
creditAccumulation: `
|
||||
**Credit accumulation**
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ export const studentToolTips = {
|
||||
studyRightStatus: `
|
||||
Shows the status of the current semester enrollment of the student in the corresponding programme.
|
||||
|
||||
**Active:** Student has a semester enrollment to the corresponding programme as either present or absent.
|
||||
**Active:** Student has a semester enrollment to the corresponding programme as present.
|
||||
**Absent:** Student has a semester enrollment to the corresponding programme as absent.
|
||||
**Inactive:** Student has not enrolled to the corresponding programme this semester.
|
||||
**Cancelled:** Study right for the corresponding programme is cancelled.
|
||||
**Graduated:** Student has graduated from the corresponding programme.
|
||||
|
||||
@@ -1,46 +1,90 @@
|
||||
import { filter, maxBy, orderBy, range } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import dayjs from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import { maxBy, orderBy, range } from 'lodash'
|
||||
|
||||
import irtomikko from '@/assets/irtomikko.png'
|
||||
import toskaLogo from '@/assets/toska.svg'
|
||||
import { serviceProvider } from '@/conf'
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { Absence } from '@/types/students'
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import {
|
||||
FormattedStudent,
|
||||
Release,
|
||||
CreditTypeCode,
|
||||
Name,
|
||||
Unarray,
|
||||
SemesterEnrollment,
|
||||
EnrollmentType,
|
||||
} from '@oodikone/shared/types'
|
||||
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
|
||||
|
||||
export const textAndDescriptionSearch = (dropDownOptions, param) =>
|
||||
filter(dropDownOptions, option =>
|
||||
dayjs.extend(isBetween)
|
||||
|
||||
export const textAndDescriptionSearch = (dropDownOptions: any[], param: string) =>
|
||||
dropDownOptions.filter(option =>
|
||||
option.text
|
||||
? option.text.toLowerCase().concat(option.description.toLowerCase()).includes(param.toLowerCase())
|
||||
: null
|
||||
)
|
||||
|
||||
export const images = {
|
||||
toskaLogo,
|
||||
irtomikko,
|
||||
export const isFall = (semester: number) => semester % 2 === 1
|
||||
|
||||
/**
|
||||
* @param currentSemester semesterCode for wanted semester
|
||||
* @param semesterEnrollments fetched from within a studyRight
|
||||
* @returns boolean depicting if registration for given semester is "absent"
|
||||
*/
|
||||
const semesterEnrollmentAbsence = (
|
||||
currentSemester: number,
|
||||
semesterEnrollments: SemesterEnrollment[] | undefined | null
|
||||
) => {
|
||||
const relevantSemesterEnrollment = semesterEnrollments?.find(se => se.semester === currentSemester)
|
||||
return relevantSemesterEnrollment?.type === EnrollmentType.ABSENT
|
||||
}
|
||||
|
||||
export const isFall = semester => semester % 2 === 1
|
||||
// NB: statutory absence is named just "absent"
|
||||
export const getStudyRightStatusText = (
|
||||
programme: { active: boolean; graduated: boolean; cancelled: boolean } | undefined,
|
||||
studyRight: StudentStudyRight | undefined,
|
||||
semestercode: number | undefined
|
||||
) => {
|
||||
if (!programme) return null
|
||||
if (programme.graduated) return 'Graduated'
|
||||
if (programme.cancelled) return 'Cancelled'
|
||||
if (semesterEnrollmentAbsence(semestercode ?? NaN, studyRight?.semesterEnrollments)) return 'Absent'
|
||||
if (programme.active) return 'Active'
|
||||
return 'Inactive'
|
||||
}
|
||||
|
||||
export const getStudentTotalCredits = (student, includeTransferredCredits = true) => {
|
||||
export const getStudentTotalCredits = (
|
||||
student: Pick<FormattedStudent, 'courses'>,
|
||||
includeTransferredCredits = true
|
||||
) => {
|
||||
const passedCourses = includeTransferredCredits
|
||||
? student.courses.filter(course => [4, 9].includes(course.credittypecode) && !course.isStudyModuleCredit)
|
||||
: student.courses.filter(course => course.credittypecode === 4 && !course.isStudyModuleCredit)
|
||||
? student.courses.filter(
|
||||
course =>
|
||||
[CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode) &&
|
||||
!course.isStudyModuleCredit
|
||||
)
|
||||
: student.courses.filter(course => course.credittypecode === CreditTypeCode.PASSED && !course.isStudyModuleCredit)
|
||||
return passedCourses.reduce((a, b) => a + b.credits, 0)
|
||||
}
|
||||
|
||||
const getGradedCourses = (student, includeTransferredCredits = true) =>
|
||||
const getGradedCourses = (student: FormattedStudent, includeTransferredCredits = true) =>
|
||||
includeTransferredCredits
|
||||
? student.courses.filter(course => Number(course.grade) && !course.isStudyModuleCredit)
|
||||
: student.courses.filter(
|
||||
course => Number(course.grade) && !course.isStudyModuleCredit && course.credittypecode !== 9
|
||||
course =>
|
||||
Number(course.grade) && !course.isStudyModuleCredit && course.credittypecode !== CreditTypeCode.APPROVED
|
||||
)
|
||||
|
||||
export const getStudentGradeMean = (student, includeTransferredCredits = true) => {
|
||||
export const getStudentGradeMean = (student: FormattedStudent, includeTransferredCredits = true) => {
|
||||
const courses = getGradedCourses(student, includeTransferredCredits)
|
||||
const gradeTotal = courses.reduce((a, b) => a + Number(b.grade), 0)
|
||||
const mean = gradeTotal / courses.length || 0
|
||||
return mean
|
||||
}
|
||||
|
||||
export const getStudentGradeMeanWeightedByCredits = (student, includeTransferredCredits = true) => {
|
||||
export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent, includeTransferredCredits = true) => {
|
||||
const courses = getGradedCourses(student, includeTransferredCredits)
|
||||
const gradeTotal = courses.reduce((a, b) => a + Number(b.grade) * Number(b.credits), 0)
|
||||
const sumWeights = courses.reduce((a, b) => a + Number(b.credits), 0)
|
||||
@@ -48,7 +92,12 @@ export const getStudentGradeMeanWeightedByCredits = (student, includeTransferred
|
||||
return mean
|
||||
}
|
||||
|
||||
export const getTextInWithOpen = (course, getTextIn, isOpenCourse, isStudyModuleCredit) => {
|
||||
export const getTextInWithOpen = (
|
||||
course: { code: string; name: Name },
|
||||
getTextIn: any,
|
||||
isOpenCourse: boolean,
|
||||
isStudyModuleCredit: boolean
|
||||
) => {
|
||||
if (!course) return ''
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
|
||||
let courseName = getTextIn(course.name)
|
||||
@@ -65,7 +114,7 @@ export const getTextInWithOpen = (course, getTextIn, isOpenCourse, isStudyModule
|
||||
return courseName
|
||||
}
|
||||
|
||||
export const getUnifyTextIn = unifyCourses => {
|
||||
export const getUnifyTextIn = (unifyCourses: string) => {
|
||||
switch (unifyCourses) {
|
||||
case 'regularStats':
|
||||
return '(normal)'
|
||||
@@ -79,26 +128,37 @@ export const getUnifyTextIn = unifyCourses => {
|
||||
}
|
||||
|
||||
// Gives students course completion date
|
||||
export const getStudentToTargetCourseDateMap = (students, codes) => {
|
||||
export const getStudentToTargetCourseDateMap = (
|
||||
students: Pick<FormattedStudent, 'studentNumber' | 'courses'>[],
|
||||
codes: string[]
|
||||
) => {
|
||||
const codeSet = new Set(codes)
|
||||
return students.reduce((acc, student) => {
|
||||
const targetCourse = student.courses
|
||||
.filter(course => codeSet.has(course.course_code))
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))[0]
|
||||
.sort((a, b) => +new Date(b.date) - +new Date(a.date))[0]
|
||||
acc[student.studentNumber] = targetCourse ? targetCourse.date : null
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const programmeIsActive = (studyRight, hasGraduated, currentSemesterCode) =>
|
||||
!studyRight.cancelled &&
|
||||
!hasGraduated &&
|
||||
(currentSemesterCode == null ||
|
||||
studyRight.semesterEnrollments?.find(
|
||||
enrollment => enrollment.semester === currentSemesterCode && [1, 2].includes(enrollment.type)
|
||||
) != null)
|
||||
const programmeIsActive = (
|
||||
studyRight: Unarray<FormattedStudent['studyRights']>,
|
||||
hasGraduated: boolean,
|
||||
currentSemesterCode: number | null
|
||||
) => {
|
||||
return (
|
||||
!studyRight.cancelled &&
|
||||
!hasGraduated &&
|
||||
!!studyRight.semesterEnrollments?.some(
|
||||
enrollment =>
|
||||
enrollment.semester === currentSemesterCode &&
|
||||
[EnrollmentType.PRESENT, EnrollmentType.ABSENT].includes(enrollment.type)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
|
||||
export const getAllProgrammesOfStudent = (studyRights: FormattedStudent['studyRights'], currentSemester: number) =>
|
||||
orderBy(
|
||||
studyRights?.flatMap(studyRight =>
|
||||
studyRight.studyRightElements
|
||||
@@ -108,6 +168,7 @@ export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
|
||||
name: element.name,
|
||||
graduated: element.graduated,
|
||||
startDate: element.startDate,
|
||||
cancelled: studyRight.cancelled,
|
||||
active: programmeIsActive(studyRight, element.graduated, currentSemester),
|
||||
facultyCode: studyRight.facultyCode,
|
||||
}))
|
||||
@@ -116,50 +177,36 @@ export const getAllProgrammesOfStudent = (studyRights, currentSemester) =>
|
||||
['desc', 'desc']
|
||||
)
|
||||
|
||||
export const getNewestProgrammeOfStudentAt = (studyRights, currentSemester, date) => {
|
||||
export const getNewestProgrammeOfStudentAt = (
|
||||
studyRights: FormattedStudent['studyRights'],
|
||||
currentSemester: number,
|
||||
date: string
|
||||
) => {
|
||||
const programmes = getAllProgrammesOfStudent(studyRights, currentSemester)
|
||||
if (!programmes.length) return null
|
||||
if (!date) return programmes[0]
|
||||
return programmes.find(programme => moment(date).isSameOrAfter(programme.startDate)) ?? null
|
||||
|
||||
return programmes.find(programme => dayjs(date).isSameOrAfter(programme.startDate)) ?? null
|
||||
}
|
||||
|
||||
export const getHighestGradeOfCourseBetweenRange = (courses, lowerBound, upperBound) => {
|
||||
const grades = []
|
||||
courses.forEach(course => {
|
||||
if (
|
||||
new Date(lowerBound).getTime() <= new Date(course.date).getTime() &&
|
||||
new Date(course.date).getTime() <= new Date(upperBound).getTime()
|
||||
) {
|
||||
if (course.grade === 'Hyv.') {
|
||||
grades.push({ grade: course.grade, value: 1 })
|
||||
} else if (!Number(course.grade)) {
|
||||
grades.push({ grade: course.grade, value: 0 })
|
||||
} else {
|
||||
grades.push({ grade: course.grade, value: Number(course.grade) })
|
||||
}
|
||||
}
|
||||
})
|
||||
return maxBy(grades, grade => grade.value)
|
||||
export const getHighestGradeOfCourseBetweenRange = (
|
||||
courses: FormattedStudent['courses'],
|
||||
lowerBound: string,
|
||||
upperBound: string
|
||||
) => {
|
||||
const grades = courses
|
||||
.filter(course => new Date(lowerBound) <= new Date(course.date) && new Date(course.date) <= new Date(upperBound))
|
||||
.map(course => {
|
||||
if (course.grade === 'HT') return { grade: course.grade, sortValue: 2 }
|
||||
if (course.grade === 'Hyv.' || course.grade === 'TT') return { grade: course.grade, sortValue: 1 }
|
||||
if (!Number(course.grade)) return { grade: course.grade, sortValue: 0 }
|
||||
return { grade: course.grade, sortValue: Number(course.grade) }
|
||||
})
|
||||
|
||||
return maxBy(grades, grade => grade.sortValue)?.grade
|
||||
}
|
||||
|
||||
export const getHighestGradeOrEnrollmentOfCourseBetweenRange = (courses, enrollments, lowerBound, upperBound) => {
|
||||
const grade = getHighestGradeOfCourseBetweenRange(courses, lowerBound, upperBound)
|
||||
if (!grade) return enrollments.length ? { grade: 'No grade' } : undefined
|
||||
return grade
|
||||
}
|
||||
|
||||
export const findStudyRightForClass = (studyRights, programmeCode, year) =>
|
||||
studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(
|
||||
element =>
|
||||
element.code === programmeCode &&
|
||||
(year == null ||
|
||||
year === 'All' ||
|
||||
moment(element.startDate).isBetween(`${year}-08-01`, `${Number(year) + 1}-07-31`, 'day', '[]'))
|
||||
)
|
||||
)
|
||||
|
||||
export const getTargetCreditsForProgramme = code => {
|
||||
export const getTargetCreditsForProgramme = (code: string) => {
|
||||
if (code === 'MH30_001' || code === 'KH90_001-MH90_001') return 360
|
||||
if (code === 'MH30_003') return 330
|
||||
if (code === 'MH30_004') return 150
|
||||
@@ -170,11 +217,12 @@ export const getTargetCreditsForProgramme = code => {
|
||||
// Those codes begin with 'LIS' is it 40 credits or something else?
|
||||
}
|
||||
|
||||
export const isMastersProgramme = programmeCode => programmeCode.startsWith('MH') || programmeCode.endsWith('-ma')
|
||||
export const isMastersProgramme = (programmeCode: string) =>
|
||||
programmeCode.startsWith('MH') || programmeCode.endsWith('-ma')
|
||||
|
||||
export const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
|
||||
export const getMonthsForDegree = (code: string) => getTargetCreditsForProgramme(code) / (60 / 12)
|
||||
|
||||
export const calculatePercentage = (numerator, denominator, numberOfDecimals = 2) =>
|
||||
export const calculatePercentage = (numerator: number, denominator: number, numberOfDecimals = 2) =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: numberOfDecimals,
|
||||
@@ -186,65 +234,69 @@ export const calculatePercentage = (numerator, denominator, numberOfDecimals = 2
|
||||
* and for masters a 2 year target. Any absences within the study right element extends the end date.
|
||||
* If any absence is in the start of study right element the start date is postponed.
|
||||
*/
|
||||
export const getStudyRightElementTargetDates = (studyRightElement, absences = []) => {
|
||||
export const getStudyRightElementTargetDates = (
|
||||
studyRightElement: Pick<SISStudyRightElement, 'code' | 'startDate'> | undefined,
|
||||
absences: Absence[]
|
||||
) => {
|
||||
if (!studyRightElement) return []
|
||||
const { code, startDate } = studyRightElement
|
||||
const { code, startDate: sreStartDate } = studyRightElement
|
||||
const months = getMonthsForDegree(code)
|
||||
const end =
|
||||
const sreEndDateTarget =
|
||||
code.includes('KH') || code.includes('ba') || ['MH30_001', 'MH30_003'].includes(code)
|
||||
? moment(startDate).add(months, 'months').set('month', 6).endOf('month')
|
||||
: moment(startDate).add(months, 'months')
|
||||
? dayjs(sreStartDate).add(months, 'months').set('month', 6).endOf('month').toDate()
|
||||
: dayjs(sreStartDate).add(months, 'months').toDate()
|
||||
|
||||
if (!absences) return [new Date(sreStartDate), sreEndDateTarget]
|
||||
|
||||
if (!absences) return [new Date(startDate), new Date(end)]
|
||||
const absencesWithinStudyRightElement = absences.filter(
|
||||
({ startdate, enddate }) => startdate >= new Date(startDate).getTime() && enddate <= new Date(end).getTime()
|
||||
({ startDate, endDate }) => startDate >= sreStartDate && endDate <= sreEndDateTarget
|
||||
)
|
||||
|
||||
if (!absencesWithinStudyRightElement.length) return [new Date(startDate), new Date(end)]
|
||||
if (!absencesWithinStudyRightElement.length) return [sreStartDate, sreEndDateTarget]
|
||||
|
||||
const absenceInStartOfStudyRight = absencesWithinStudyRightElement.find(({ startDate }) => sreStartDate === startDate)
|
||||
|
||||
const absenceInStartOfStudyRight = absencesWithinStudyRightElement.find(
|
||||
({ startdate }) => new Date(startDate).getTime() === startdate
|
||||
)
|
||||
const absentMonthsDuringStudy = Math.round(
|
||||
absencesWithinStudyRightElement
|
||||
.filter(({ startdate, enddate }) => {
|
||||
.filter(({ startDate, endDate }) => {
|
||||
if (!absenceInStartOfStudyRight) return true
|
||||
return startdate !== absenceInStartOfStudyRight.startdate && enddate !== absenceInStartOfStudyRight.enddate
|
||||
return startDate !== absenceInStartOfStudyRight.startDate && endDate !== absenceInStartOfStudyRight.endDate
|
||||
})
|
||||
.filter(({ startdate }) => startdate < new Date(end).getTime())
|
||||
.reduce((acc, absent) => {
|
||||
const { startdate, enddate } = absent
|
||||
const diff = moment(startdate).diff(moment(enddate), 'days') / 30
|
||||
.filter(({ startDate }) => startDate < sreEndDateTarget)
|
||||
.reduce((acc, absence) => {
|
||||
const { startDate, endDate } = absence
|
||||
const diff = dayjs(startDate).diff(endDate, 'days') / 30
|
||||
return acc + Math.abs(diff)
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const absentMonthsBeforeStudy = absenceInStartOfStudyRight
|
||||
? Math.round(
|
||||
Math.abs(
|
||||
moment(absenceInStartOfStudyRight.startdate).diff(moment(absenceInStartOfStudyRight.enddate), 'days') / 30
|
||||
)
|
||||
Math.abs(dayjs(absenceInStartOfStudyRight.startDate).diff(absenceInStartOfStudyRight.endDate, 'days') / 30)
|
||||
)
|
||||
: 0
|
||||
return [
|
||||
new Date(moment(startDate).add(absentMonthsBeforeStudy, 'months')),
|
||||
new Date(end.add(absentMonthsDuringStudy + absentMonthsBeforeStudy, 'months')),
|
||||
dayjs(sreStartDate).add(absentMonthsBeforeStudy, 'months').toDate(),
|
||||
dayjs(sreEndDateTarget)
|
||||
.add(absentMonthsDuringStudy + absentMonthsBeforeStudy, 'months')
|
||||
.toDate(),
|
||||
]
|
||||
}
|
||||
|
||||
export const TimeDivision = {
|
||||
ACADEMIC_YEAR: 'academic-year',
|
||||
CALENDAR_YEAR: 'calendar-year',
|
||||
SEMESTER: 'semester',
|
||||
export enum TimeDivision {
|
||||
ACADEMIC_YEAR = 'Academic year',
|
||||
CALENDAR_YEAR = 'Calendar year',
|
||||
SEMESTER = 'Semester',
|
||||
}
|
||||
|
||||
/* Returns an array of credit categories depending on parameters, shows the high limit
|
||||
of the category, for example [20, 40, 60, 80, 100, 120] where the first category is 0 - 20 */
|
||||
export const getCreditCategories = (
|
||||
cumulative,
|
||||
timeDivision,
|
||||
programmeCredits,
|
||||
timeSlots,
|
||||
creditCategoryAmount,
|
||||
cumulative: boolean,
|
||||
timeDivision: TimeDivision,
|
||||
programmeCredits: number,
|
||||
timeSlotsLength: number,
|
||||
creditCategoryAmount: number,
|
||||
minCredits = 0
|
||||
) => {
|
||||
// In calendar-year mode, minus 30 from target credits because programmes (usually) start in autumn,
|
||||
@@ -253,9 +305,9 @@ export const getCreditCategories = (
|
||||
const isPastAugust = new Date().getMonth() > 6
|
||||
const calendarModifier = 30 + (isPastAugust ? 0 : 30)
|
||||
const creditsByTimeslots =
|
||||
timeSlots.length * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
|
||||
timeSlotsLength * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
|
||||
const maxCredits = creditsByTimeslots > programmeCredits ? programmeCredits : creditsByTimeslots
|
||||
const creditCategoryArray = []
|
||||
const creditCategoryArray: number[] = []
|
||||
for (let i = 1; i <= creditCategoryAmount; i++) creditCategoryArray.push(i)
|
||||
|
||||
const limitBreaks = cumulative
|
||||
@@ -264,25 +316,25 @@ export const getCreditCategories = (
|
||||
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
|
||||
}
|
||||
|
||||
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
|
||||
export const validateInputLength = (input: string, minLength: number) => input?.trim().length >= minLength
|
||||
|
||||
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
export const splitByEmptySpace = (str: string) => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
export const getCurrentSemester = allSemesters => {
|
||||
export const getCurrentSemester = (allSemesters: SemestersData['semesters']) => {
|
||||
if (!allSemesters) return null
|
||||
return Object.values(allSemesters).find(
|
||||
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
|
||||
)
|
||||
}
|
||||
|
||||
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
|
||||
export const isNewStudyProgramme = (programmeCode: string) => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
|
||||
|
||||
/**
|
||||
* Extracts items from a string separated by commas, semicolons, spaces, or newlines.
|
||||
* @param {string} input - The input string containing items.
|
||||
* @returns An array of extracted items.
|
||||
*/
|
||||
export const extractItems = input => {
|
||||
export const extractItems = (input: string) => {
|
||||
const items = input.match(/[^\s,;]+/g) ?? []
|
||||
return [...items]
|
||||
}
|
||||
@@ -345,7 +397,7 @@ export const languageAbbreviations = {
|
||||
|
||||
export const showAsUserKey = 'showAsUser'
|
||||
|
||||
export const getEnrollmentTypeTextForExcel = (type, statutoryAbsence) => {
|
||||
export const getEnrollmentTypeTextForExcel = (type: number, statutoryAbsence?: boolean) => {
|
||||
if (type === 1) return 'Present'
|
||||
if (type === 2 && statutoryAbsence) return 'Absent (statutory)'
|
||||
if (type === 2) return 'Absent'
|
||||
@@ -360,11 +412,11 @@ export const isDefaultServiceProvider = () => {
|
||||
return serviceProvider === 'toska'
|
||||
}
|
||||
|
||||
export const formatContent = content => content.replace(/\n +/g, '\n')
|
||||
export const formatContent = (content: string) => content.replace(/\n +/g, '\n')
|
||||
|
||||
export const filterInternalReleases = release => !release.title.startsWith('Internal:')
|
||||
export const filterInternalReleases = (release: Release) => !release.title.startsWith('Internal:')
|
||||
|
||||
export const getDescription = description => {
|
||||
export const getDescription = (description: string) => {
|
||||
const lines = description.split('\n')
|
||||
const internalIndex = lines.findIndex(line => line.toLowerCase().includes('internal'))
|
||||
if (internalIndex === -1 || internalIndex === 0) {
|
||||
+26
-40
@@ -1,11 +1,12 @@
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment/AdapterMoment'
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs/AdapterDayjs'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/LocalizationProvider'
|
||||
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import HighCharts from 'highcharts'
|
||||
import moment from 'moment'
|
||||
import dayjs from 'dayjs'
|
||||
import updateLocale from 'dayjs/plugin/updateLocale'
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
@@ -36,59 +37,44 @@ HighCharts.setOptions({
|
||||
},
|
||||
})
|
||||
|
||||
moment.updateLocale('en', {
|
||||
dayjs.extend(updateLocale)
|
||||
dayjs.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
},
|
||||
})
|
||||
|
||||
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
|
||||
if (!isProduction || !id || !username) {
|
||||
return
|
||||
}
|
||||
Sentry.setUser({ id, username, mockedBy })
|
||||
}
|
||||
|
||||
const Layout = ({ children }) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StatusNotificationProvider>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<StatusNotification />
|
||||
<Footer />
|
||||
</StatusNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</LanguageProvider>
|
||||
</div>
|
||||
<LanguageProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StatusNotificationProvider>{children}</StatusNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
export const App = () => {
|
||||
const { isLoading, error, id, username, mockedBy } = useGetAuthorizedUserQuery()
|
||||
|
||||
useEffect(() => {
|
||||
if (isProduction) {
|
||||
initShibbolethPinger()
|
||||
}
|
||||
if (isProduction) initShibbolethPinger()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
addUserDetailsToLoggers({ id, username, mockedBy })
|
||||
if (isProduction && !isLoading && id && username) Sentry.setUser({ id, username, mockedBy })
|
||||
}, [id, username, mockedBy])
|
||||
|
||||
if (error) {
|
||||
return <AccessDenied notEnabled />
|
||||
}
|
||||
if (isLoading) return <SegmentDimmer isLoading />
|
||||
if (error) return <AccessDenied />
|
||||
|
||||
return <Layout>{isLoading ? <SegmentDimmer isLoading={isLoading} /> : <Routes />}</Layout>
|
||||
return (
|
||||
<Layout>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<Routes />
|
||||
<StatusNotification />
|
||||
<Footer />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const FacultiesTab = () => {
|
||||
const facultiesTotal = { ...emptyFields }
|
||||
selectedSemesters.forEach(semestercode => {
|
||||
data.faculties.forEach(faculty => {
|
||||
if (!course.bySemesters.cellStats[faculty]) course.bySemesters.cellStats[faculty] = { ...emptyFields }
|
||||
course.bySemesters.cellStats[faculty] ??= { ...emptyFields }
|
||||
const stats = course.bySemesters[semestercode]?.[faculty]
|
||||
if (!stats) return
|
||||
course.bySemesters.cellStats[faculty].completions += stats.completions
|
||||
|
||||
@@ -13,7 +13,7 @@ export const getCourseCodeColumn = getTextIn => ({
|
||||
getRowContent: row => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }} title={getTextIn(row.name)}>
|
||||
<b>{row.code}</b>
|
||||
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name && getTextIn(row.name)}</i>
|
||||
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name ? getTextIn(row.name) : null}</i>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { getCurrentSemester } from '@/common'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
@@ -12,13 +11,11 @@ import './index.css'
|
||||
export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, panes, infoBox, dividerText }) => {
|
||||
useTitle(title)
|
||||
const { data: semesterData } = useGetSemestersQuery()
|
||||
const currentSemester = semesterData && getCurrentSemester(semesterData.semesters)
|
||||
const semesters =
|
||||
semesterData &&
|
||||
Object.values(semesterData.semesters).filter(
|
||||
// 135 = Fall 2017
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
|
||||
)
|
||||
const { semesters: allSemesters, currentSemester } = semesterData ?? { semesters: {}, currentSemester: null }
|
||||
const semesters = Object.values(allSemesters).filter(
|
||||
// 135 = Fall 2017
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
|
||||
)
|
||||
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
|
||||
|
||||
@@ -90,11 +87,11 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
return (
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
<div className="colorized-courses-table">
|
||||
{dividerText && (
|
||||
{dividerText ? (
|
||||
<Divider horizontal style={{ marginTop: '3em' }}>
|
||||
{dividerText}
|
||||
</Divider>
|
||||
)}
|
||||
) : null}
|
||||
{infoBox}
|
||||
{displayedPanes.length === 1 ? displayedPanes[0].render() : <Tab panes={displayedPanes} />}
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ export const SemesterRangeSelector = () => {
|
||||
</div>
|
||||
<div>
|
||||
<b>Until</b>
|
||||
{semesters && (
|
||||
{semesters ? (
|
||||
<SemesterSelector
|
||||
allSemesters={semesters?.filter(semester => {
|
||||
return semesterFilter.start <= semester.semestercode
|
||||
@@ -122,7 +122,7 @@ export const SemesterRangeSelector = () => {
|
||||
setSemesterFilter({ ...semesterFilter, end: semester })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user