mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c367f5b93 | ||
|
|
ee2240f457 | ||
|
|
fb4ecc7785 | ||
|
|
594ffb460b | ||
|
|
d9d7f2ae80 | ||
|
|
8425643639 | ||
|
|
33d5a04dc8 | ||
|
|
abbecf412e | ||
|
|
6809c668e0 | ||
|
|
0db4db47ac | ||
|
|
ab420572eb | ||
|
|
657a96abc0 | ||
|
|
1760a165e4 | ||
|
|
3f311dee69 | ||
|
|
eb6b683296 | ||
|
|
b2153ec3dd | ||
|
|
8949e7baf0 | ||
|
|
1fa10f7812 | ||
|
|
fbe5db8a43 | ||
|
|
d4c7a4014b | ||
|
|
1177c2f848 | ||
|
|
0f8d72fd1d | ||
|
|
47e3e2e26e | ||
|
|
a312a14944 | ||
|
|
e3ec33f8dc | ||
|
|
9c55d3b2ab | ||
|
|
17ce83660c | ||
|
|
22d3877b8e | ||
|
|
c60bee34fd | ||
|
|
15240050b7 | ||
|
|
7da09c8b02 | ||
|
|
cfb2d4d5c0 | ||
|
|
39b594d795 | ||
|
|
ff5bce1713 | ||
|
|
8e288f3b26 | ||
|
|
4134c8b64b | ||
|
|
ddc1a22492 | ||
|
|
8ff1cb54dc | ||
|
|
777de520f9 | ||
|
|
a9e325fe72 | ||
|
|
befc3db3a8 | ||
|
|
e1b8ebd17f | ||
|
|
883129bee0 | ||
|
|
18eb04beeb | ||
|
|
eb12d7e5ec | ||
|
|
26ca0a3bc1 | ||
|
|
b79c97780b | ||
|
|
0cd3bf28ff | ||
|
|
20b0ba5a30 | ||
|
|
67001a9db1 | ||
|
|
8c268395b0 | ||
|
|
235d2b03b2 | ||
|
|
cbe20f1ad3 | ||
|
|
47211dcf89 | ||
|
|
26c6c653e5 | ||
|
|
13bdf698cf | ||
|
|
fd6a65925a | ||
|
|
435d2920fb | ||
|
|
4ae93e0b7e | ||
|
|
dc5200da2f | ||
|
|
373c8f9de8 | ||
|
|
34cc022830 | ||
|
|
ab311ddc66 | ||
|
|
833fdad3bb | ||
|
|
4b634e032d | ||
|
|
a16fd13413 | ||
|
|
58913b4684 | ||
|
|
05c8993ba3 | ||
|
|
d3090ae725 | ||
|
|
c870061fec | ||
|
|
ec43699c3c | ||
|
|
1b1e380a13 | ||
|
|
5c13b5c0ab | ||
|
|
f35fd41bfb | ||
|
|
6548cdd42b | ||
|
|
49b0da760d | ||
|
|
34e9cf5667 | ||
|
|
288d32c591 | ||
|
|
5001d55843 | ||
|
|
eaedfe99db | ||
|
|
6cf375936a | ||
|
|
8bc5c3ea65 | ||
|
|
3e4e749625 | ||
|
|
114af7522d | ||
|
|
c0b29d69c7 | ||
|
|
fb541ffa3a | ||
|
|
0ed4ce7bb4 | ||
|
|
87e1e332a8 | ||
|
|
914440cef9 | ||
|
|
901a4de21c | ||
|
|
2bc28c7dd3 |
@@ -47,4 +47,4 @@ runs:
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml logs
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
|
||||
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
if: always() && !cancelled()
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -115,7 +115,6 @@ jobs:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -145,7 +144,6 @@ jobs:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
|
||||
}
|
||||
|
||||
const checkTableContents = contents => {
|
||||
cy.get('table tbody').scrollIntoView()
|
||||
cy.get('table tbody').within(() => {
|
||||
contents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
@@ -396,7 +397,6 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
|
||||
cy.contains('Select all search results').should('not.exist')
|
||||
cy.cs('select-multiple-courses-toggle').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
|
||||
@@ -12,7 +12,7 @@ const save = () => {
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.cs('history-search').click().type(`${name}{enter}`)
|
||||
cy.cs('history-search').type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
|
||||
@@ -93,14 +93,14 @@ describe('Degree programme overview', () => {
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94, 0],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189, 0],
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -173,14 +173,14 @@ describe('Degree programme overview', () => {
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 101, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 0, 0, 26, 0],
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -214,9 +214,9 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 36 months')
|
||||
cy.contains('27 graduated on time')
|
||||
cy.contains('9 graduated max year overtime')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('26 graduated on time')
|
||||
cy.contains('10 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
})
|
||||
|
||||
@@ -245,7 +245,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('median study time: 4.5 semesters')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
@@ -257,7 +257,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 67 months')
|
||||
cy.contains('median study time: 12 semesters')
|
||||
cy.contains('4 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
@@ -415,12 +415,12 @@ describe('Degree programme overview', () => {
|
||||
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 16')
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('[aria-label="2019 - 2020, 23. On time."]').click()
|
||||
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
|
||||
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
cy.contains('On time: 13')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
@@ -429,7 +429,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('16 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
@@ -636,10 +636,11 @@ describe('Degree programme overview', () => {
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Degree students',
|
||||
'Open university',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
@@ -660,7 +661,7 @@ describe('Degree programme overview', () => {
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
@@ -672,12 +673,13 @@ describe('Degree programme overview', () => {
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 9)
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Degree students',
|
||||
'Open uni students',
|
||||
'Open university students (with hetu)',
|
||||
'Open university students (without hetu)',
|
||||
'Exchange students',
|
||||
'Other university students',
|
||||
'Separate studies',
|
||||
@@ -708,7 +710,7 @@ describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
it.skip('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
|
||||
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ services:
|
||||
- npm_config_cache=/tmp
|
||||
- PORT=3003
|
||||
- NODE_ENV=dev
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
+8
-1
@@ -75,7 +75,14 @@ export default tseslint.config(
|
||||
'prefer-const': 'error',
|
||||
'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/[^/]+$' }] }],
|
||||
'no-restricted-imports': ['error',
|
||||
{
|
||||
'patterns':
|
||||
[
|
||||
{ 'regex': '^@mui/[^/]+$', 'message': 'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.' },
|
||||
{ 'regex': '^src/.*', 'message': 'Root imports will not resolve in production build (instead silently crash).' }
|
||||
]
|
||||
}],
|
||||
'quotes': ['error', 'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
|
||||
+3
-2
@@ -28,9 +28,10 @@
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"i:both": "npm run install:both",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
|
||||
|
||||
## Following the naming convention in docker-compose, these are names for database
|
||||
## services. The real data databases inside service will have names suffixed by "-real".
|
||||
@@ -139,29 +138,6 @@ reset_databases() {
|
||||
successmsg "Database setup finished"
|
||||
}
|
||||
|
||||
reset_jami_data() {
|
||||
local database="jami-db"
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
local s3_path="jami"
|
||||
|
||||
infomsg "Removing old data"
|
||||
|
||||
|
||||
|
||||
infomsg "Removing database and related volume"
|
||||
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
|
||||
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
|
||||
|
||||
infomsg "Starting postgres in the background"
|
||||
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
|
||||
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
|
||||
|
||||
infomsg "Populating Jami"
|
||||
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
|
||||
docker-compose stop "$database"
|
||||
msg ""
|
||||
}
|
||||
|
||||
reset_all_real_data() {
|
||||
infomsg "Downloading real data dumps"
|
||||
for i in ${!DATABASES[*]}; do
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
*/.dockerignore
|
||||
*/.eslintrc.json
|
||||
*/.gitignore
|
||||
*/*Dockerfile
|
||||
*/build
|
||||
*/dist
|
||||
*/node_modules
|
||||
@@ -1,3 +0,0 @@
|
||||
.dockerignore
|
||||
*Dockerfile
|
||||
node_modules/
|
||||
Generated
+6
-38
@@ -11,7 +11,6 @@
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
@@ -2388,19 +2387,9 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2658,6 +2647,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -2818,6 +2808,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -2958,6 +2949,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3160,30 +3152,11 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -3448,6 +3421,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -4440,12 +4414,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc -p .",
|
||||
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -11,7 +11,6 @@
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
|
||||
@Column(DataType.JSONB)
|
||||
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
declare transferInfo: SISStudyRight['transferInfo']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
declare credits: SISStudyRight['credits']
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { isDev, isTest } from '../config'
|
||||
|
||||
const router = Router()
|
||||
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
|
||||
return res.status(200).json(fakeRelease)
|
||||
}
|
||||
|
||||
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
|
||||
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
const { data } = await Fetchios.get<Record<string, any>[]>(
|
||||
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
|
||||
{}
|
||||
)
|
||||
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
if (data) {
|
||||
const releasesFromAPI: Release[] = data.map(release => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
}
|
||||
|
||||
res.status(500).send()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -46,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const { data: teacherRightsToStudents, error } = importerClient
|
||||
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
|
||||
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
|
||||
: { data: null, error: null }
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { uniq } from '@oodikone/shared/util'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
@@ -20,49 +19,30 @@ const router = Router()
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, combineSubstitutions, includeSpecial } = req.query
|
||||
const { name, code, includeSpecial } = 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, includeSpecial === 'true')
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = uniq(
|
||||
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
|
||||
)
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
const mergedCourses = new Map<string, CourseWithSubsId>()
|
||||
|
||||
for (const course of courses) {
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
|
||||
if (!groupId) continue
|
||||
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
|
||||
const mergedCourse = mergedCourses.get(course.code)!
|
||||
|
||||
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 (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) })
|
||||
res.json({ courses: Array.from(mergedCourses.values()) })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
|
||||
programmes: refreshProgrammesByList,
|
||||
}
|
||||
if (typeof typeToJob[type] !== 'function') {
|
||||
return res.status(400).json('Bad job type')
|
||||
return res.status(400).json('Updater does not recognize this job type: ' + type)
|
||||
}
|
||||
const response = await typeToJob[type](list)
|
||||
if (response) {
|
||||
|
||||
@@ -47,18 +47,19 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
|
||||
...nameLikeTerm(name),
|
||||
...codeLikeTerm(code),
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const rawCourses = await getRawCourses(name, code, includeSpecial)
|
||||
return rawCourses
|
||||
.map(course => ({
|
||||
...course.toJSON(),
|
||||
...course,
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution)) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
|
||||
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cloneDeep, omit } from 'lodash-es'
|
||||
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
|
||||
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
@@ -253,21 +254,22 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
|
||||
} = await getStatsByStartYear(programmesOfFaculty)
|
||||
|
||||
const goals = {
|
||||
bachelor: 36,
|
||||
bcMsCombo: faculty === 'H90' ? 72 : 60,
|
||||
master: 24,
|
||||
doctor: 48,
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
exceptions: {
|
||||
MH30_004: 6, // months more
|
||||
'420420-ma': 6,
|
||||
'420074-ma': 6,
|
||||
'420119-ma': 6,
|
||||
MH30_001: 48,
|
||||
'320011-ma': 48,
|
||||
'320001-ma': 48,
|
||||
MH30_003: 42,
|
||||
'320002-ma': 42,
|
||||
'320009-ma': 42,
|
||||
// Exceptions are additions to the base amount
|
||||
MH30_004: GraduationTarget.HALF_YEAR,
|
||||
'420420-ma': GraduationTarget.HALF_YEAR,
|
||||
'420074-ma': GraduationTarget.HALF_YEAR,
|
||||
'420119-ma': GraduationTarget.HALF_YEAR,
|
||||
MH30_001: GraduationTarget.FOUR_YEARS,
|
||||
'320011-ma': GraduationTarget.FOUR_YEARS,
|
||||
'320001-ma': GraduationTarget.FOUR_YEARS,
|
||||
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
} as Record<string, number>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
export const countTimeCategories = (times: number[], goal: number) => {
|
||||
/** Map years to semesters */
|
||||
export const GraduationTarget = {
|
||||
HALF_YEAR: 1,
|
||||
ONE_YEAR: 2,
|
||||
TWO_YEARS: 4,
|
||||
THREE_YEARS: 6,
|
||||
THREE_POINT_FIVE_YEARS: 7,
|
||||
FOUR_YEARS: 8,
|
||||
FIVE_YEARS: 10,
|
||||
SIX_YEARS: 12,
|
||||
} as const
|
||||
|
||||
/** Uses semesters, not months */
|
||||
export const countTimeCategories = (times: number[], target: number) => {
|
||||
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
|
||||
times.forEach(time => {
|
||||
if (time <= goal) {
|
||||
statistics.onTime += 1
|
||||
} else if (time <= goal + 12) {
|
||||
statistics.yearOver += 1
|
||||
|
||||
for (const time of times) {
|
||||
if (time <= target) {
|
||||
statistics.onTime++
|
||||
} else if (time <= target + GraduationTarget.ONE_YEAR) {
|
||||
statistics.yearOver++
|
||||
} else {
|
||||
statistics.wayOver += 1
|
||||
statistics.wayOver++
|
||||
}
|
||||
})
|
||||
}
|
||||
return statistics
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { isProduction, pateToken } from '../config'
|
||||
import { FormattedUser } from '../types'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const pateClient = axios.create({
|
||||
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
|
||||
const pateClient = Fetchios.create({
|
||||
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
|
||||
params: {
|
||||
token: pateToken,
|
||||
},
|
||||
@@ -22,9 +21,9 @@ const baseSettings = {
|
||||
}
|
||||
|
||||
const sendEmail = async (options = {}) => {
|
||||
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
|
||||
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
|
||||
return await pateClient.post('/', options)
|
||||
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
|
||||
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
|
||||
return await pateClient.post('', options, {})
|
||||
}
|
||||
|
||||
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
const axios = require('axios')
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const getSisuAccessToken = async eppn => {
|
||||
export const getSisuAccessToken = async eppn => {
|
||||
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
|
||||
let sisuResponse
|
||||
try {
|
||||
sisuResponse = await axios.post(
|
||||
oriAppAuthPath,
|
||||
{
|
||||
username: sisGrapqlAppAccount,
|
||||
key: sisGrapqlAppKey,
|
||||
executingEppn: eppn,
|
||||
},
|
||||
{ 'content-type': 'application/json' }
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
const { data: sisuResponse, error } = await Fetchios.post(
|
||||
oriAppAuthPath,
|
||||
{
|
||||
username: sisGrapqlAppAccount,
|
||||
key: sisGrapqlAppKey,
|
||||
executingEppn: eppn,
|
||||
},
|
||||
{ 'content-type': 'application/json' }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new ApplicationError(
|
||||
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
|
||||
404
|
||||
)
|
||||
}
|
||||
return sisuResponse.data.authToken
|
||||
|
||||
return sisuResponse.authToken
|
||||
}
|
||||
|
||||
const decodeJwtTokenPayloadToObject = token => {
|
||||
export const decodeJwtTokenPayloadToObject = token => {
|
||||
const payload = token.split('.')[1]
|
||||
const paddedPayload = `${payload}==`
|
||||
const bufferObj = Buffer.from(paddedPayload, 'base64')
|
||||
return JSON.parse(bufferObj.toString('utf-8'))
|
||||
}
|
||||
|
||||
const getSisuAuthData = async eppn => {
|
||||
export const getSisuAuthData = async eppn => {
|
||||
const accessToken = await getSisuAccessToken(eppn)
|
||||
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
|
||||
return { accessToken, tokenData }
|
||||
}
|
||||
|
||||
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
export const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
private_person(id: $subjectUserId) {
|
||||
id
|
||||
first_name:firstNames
|
||||
@@ -52,32 +53,17 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
}
|
||||
}`
|
||||
|
||||
const getGraphqlData = async (accessToken, queryObject) => {
|
||||
const graphQlUrl = `${sisUrl}/api`
|
||||
const authHeader = `Application ${accessToken}`
|
||||
const config = {
|
||||
url: graphQlUrl,
|
||||
method: 'post',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Accept-Charset': 'UTF-8',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: queryObject,
|
||||
}
|
||||
let graphQlResponse
|
||||
export const getGraphqlData = async (accessToken, queryObject) => {
|
||||
try {
|
||||
graphQlResponse = await axios(config)
|
||||
const graphQlResponse = await Fetchios.post(`${sisUrl}/api`, queryObject, {
|
||||
headers: {
|
||||
Authorization: `Application ${accessToken}`,
|
||||
'Accept-Charset': 'UTF-8',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
return graphQlResponse.data.data.private_person
|
||||
} catch (error) {
|
||||
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
|
||||
}
|
||||
return graphQlResponse.data.data.private_person
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSisuAccessToken,
|
||||
decodeJwtTokenPayloadToObject,
|
||||
getSisuAuthData,
|
||||
personSearchQuery,
|
||||
getGraphqlData,
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export const formatStudentForAPI = (
|
||||
sis_person_id: student.sis_person_id,
|
||||
citizenships: student.citizenships,
|
||||
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
|
||||
hasPersonalIdentityCode: student.hasPersonalIdentityCode,
|
||||
|
||||
tags,
|
||||
transferredStudyright,
|
||||
|
||||
@@ -79,6 +79,7 @@ type StudentPersonalData = Pick<
|
||||
| 'gender_code'
|
||||
| 'birthdate'
|
||||
| 'sis_person_id'
|
||||
| 'hasPersonalIdentityCode'
|
||||
>
|
||||
|
||||
type StudentStudyRightElement = Pick<
|
||||
@@ -125,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
|
||||
'gender_code',
|
||||
'birthdate',
|
||||
'sis_person_id',
|
||||
'hasPersonalIdentityCode',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
|
||||
|
||||
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions', 'is_study_module'],
|
||||
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { getCreditStats, setCreditStats } from './analyticsService'
|
||||
import { getCourseCodesOfProvider } from './providers'
|
||||
import { getCreditsForProvider, getTransferredCredits } from './studyProgramme/creditGetters'
|
||||
import { getStudentHetuStateMap } from './studyProgramme/studentGetters'
|
||||
import { defineYear } from './studyProgramme/studyProgrammeHelpers'
|
||||
import { getSISStudyRightsOfStudents } from './studyProgramme/studyRightFinders'
|
||||
|
||||
@@ -47,13 +48,17 @@ const basicDegreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCo
|
||||
This is probably very rare to cause differences, only one such was found in MH60_001
|
||||
*/
|
||||
|
||||
const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
|
||||
const getCategory = (hasHetu: boolean, extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
|
||||
if (degreeStudyRightExtentCode && basicDegreeExtentCodes.includes(degreeStudyRightExtentCode)) {
|
||||
return 'basic'
|
||||
}
|
||||
switch (extentCode) {
|
||||
case ExtentCode.OPEN_UNIVERSITY_STUDIES:
|
||||
return 'open-uni'
|
||||
if (hasHetu) {
|
||||
return 'open-uni-hetu'
|
||||
} else {
|
||||
return 'open-uni-no-hetu'
|
||||
}
|
||||
case ExtentCode.MASTER:
|
||||
case ExtentCode.BACHELOR_AND_MASTER:
|
||||
case ExtentCode.BACHELOR:
|
||||
@@ -121,38 +126,43 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
|
||||
|
||||
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
|
||||
|
||||
const studyRights = await getSISStudyRightsOfStudents(students)
|
||||
const [studyRights, studentToHetuStateMap] = await Promise.all([
|
||||
getSISStudyRightsOfStudents(students),
|
||||
getStudentHetuStateMap(students),
|
||||
])
|
||||
|
||||
const studyRightIdToStudyRightMap = studyRights.reduce<Record<string, (typeof studyRights)[number]>>((obj, cur) => {
|
||||
obj[cur.id] = cur
|
||||
return obj
|
||||
}, {})
|
||||
const studyRightIdToStudyRightMap = new Map(studyRights.map(sr => [sr.id, sr]))
|
||||
|
||||
const studentNumberToStudyRightsMap = studyRights.reduce<Record<string, typeof studyRights>>((obj, cur) => {
|
||||
obj[cur.studentNumber] ??= []
|
||||
obj[cur.studentNumber].push(cur)
|
||||
return obj
|
||||
}, {})
|
||||
const studentNumberToStudyRightsMap = studyRights.reduce((map, cur) => {
|
||||
// Node does not support getOrInsert as of writing :-(
|
||||
if (map.has(cur.studentNumber)) {
|
||||
map.get(cur.studentNumber)!.push(cur)
|
||||
} else {
|
||||
map.set(cur.studentNumber, [cur])
|
||||
}
|
||||
return map
|
||||
}, new Map<string, [(typeof studyRights)[number]]>())
|
||||
|
||||
const stats: Record<string, Record<string, number>> = {}
|
||||
|
||||
for (const { attainmentDate, studentNumber, credits: numOfCredits, studyrightId, semestercode } of credits) {
|
||||
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap[studyrightId]
|
||||
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap.get(studyrightId)
|
||||
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
|
||||
const degreeStudyRight = getBasicDegreeStudyRight(
|
||||
studentNumberToStudyRightsMap[studentNumber],
|
||||
studentNumberToStudyRightsMap.get(studentNumber),
|
||||
attainmentDate,
|
||||
semestercode
|
||||
)
|
||||
const hasHetu = !!studentToHetuStateMap.get(studentNumber)
|
||||
|
||||
if (!studyRightLinkedToAttainment && !degreeStudyRight) {
|
||||
continue
|
||||
}
|
||||
|
||||
const category = getCategory(studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
|
||||
const category = getCategory(hasHetu, studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
|
||||
stats[attainmentYear] ??= {}
|
||||
stats[attainmentYear][category] ??= 0
|
||||
stats[attainmentYear][category] += numOfCredits || 0
|
||||
stats[attainmentYear][category] += numOfCredits
|
||||
}
|
||||
|
||||
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
|
||||
@@ -164,7 +174,7 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
|
||||
const attainmentYear = defineYear(createdate, isAcademicYear)
|
||||
stats[attainmentYear] ??= {}
|
||||
stats[attainmentYear].transferred ??= 0
|
||||
stats[attainmentYear].transferred += credits || 0
|
||||
stats[attainmentYear].transferred += credits
|
||||
})
|
||||
|
||||
return { stats, id: providerCode }
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
import axios from 'axios'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
|
||||
import { StudyplanModel } from '../models'
|
||||
|
||||
const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
const params = { params: { token: SECRET_TOKEN } }
|
||||
const client = Fetchios.create({
|
||||
baseUrl: SIS_UPDATER_URL,
|
||||
params: { token: SECRET_TOKEN ?? '' },
|
||||
})
|
||||
|
||||
export const abortUpdate = async () => {
|
||||
const response = await client.get('/v1/abort', params)
|
||||
const response = await client.get<{ message: string }>('/v1/abort', {})
|
||||
return response.data?.message
|
||||
}
|
||||
|
||||
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
|
||||
const data = { coursecodes }
|
||||
const response = await client.post('/v1/courses', data, params)
|
||||
const response = await client.post('/v1/courses', data, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISMetadata = async () => {
|
||||
const response = await client.get('/v1/meta', params)
|
||||
const response = await client.get('/v1/meta', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISProgrammes = async () => {
|
||||
const response = await client.get('/v1/programmes', params)
|
||||
const response = await client.get('/v1/programmes', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISRedisCache = async () => {
|
||||
const response = await client.get('/v1/rediscache', params)
|
||||
const response = await client.get('/v1/rediscache', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudents = async () => {
|
||||
const response = await client.get('/v1/students', params)
|
||||
const response = await client.get('/v1/students', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
|
||||
const data = { studentnumbers }
|
||||
const response = await client.post('/v1/students', data, params)
|
||||
const response = await client.post('/v1/students', data, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -54,6 +56,6 @@ export const updateSISStudyPlans = async (days: number) => {
|
||||
limit: 1000,
|
||||
})
|
||||
const studentnumbers = result.map(r => r.studentnumber)
|
||||
const response = await client.post('v1/studyplans', { studentnumbers }, params)
|
||||
const response = await client.post('v1/studyplans', { studentnumbers }, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { AxiosError } from 'axios'
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
|
||||
@@ -12,22 +11,23 @@ const importerClient = getImporterClient()
|
||||
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
|
||||
if (!importerClient || !sisPersonId) return []
|
||||
|
||||
try {
|
||||
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
|
||||
`/person-groups/person/${sisPersonId}`
|
||||
)
|
||||
const { data, error } = await importerClient.get<Record<string, StudyGuidanceGroup>>(
|
||||
`/person-groups/person/${sisPersonId}`,
|
||||
{}
|
||||
)
|
||||
|
||||
return response?.data ? Object.values(response.data) : []
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
logger.error('Could not fetch study guidance groups!')
|
||||
if (error instanceof AxiosError) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.stack)
|
||||
logger.error(JSON.stringify(error.response?.data))
|
||||
logger.error(JSON.stringify(error.message))
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
return []
|
||||
return data ? Object.values(data) : []
|
||||
}
|
||||
|
||||
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { QueryTypes } from 'sequelize'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { CreditTypeCode, ExtentCode, Name } from '@oodikone/shared/types'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { StudentModel } from '../../models'
|
||||
import logger from '../../util/logger'
|
||||
|
||||
// Categories here are as per Linjaukset.md in internal docs.
|
||||
@@ -235,3 +236,26 @@ export const getTransferCourseAggregates = async (params: {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getStudentHetuStateMap = async (studentNumbers: string[]): Promise<Map<string, boolean>> => {
|
||||
if (!studentNumbers.length) return new Map()
|
||||
|
||||
try {
|
||||
return new Map(
|
||||
(
|
||||
await StudentModel.findAll({
|
||||
attributes: ['studentnumber', 'hasPersonalIdentityCode'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(student => [student.studentnumber, student.hasPersonalIdentityCode])
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('getOpenUniHetutonSplit failed', { error })
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
|
||||
|
||||
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
|
||||
import { getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
|
||||
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
|
||||
|
||||
const START_YEAR = 2017
|
||||
const JULY = 6
|
||||
@@ -52,7 +52,8 @@ type YearAccumulator = {
|
||||
separateStudents: Set<string>
|
||||
otherStudents: Set<string>
|
||||
|
||||
openStudents: Set<string>
|
||||
openStudentsHasHetu: Set<string>
|
||||
openStudentsNoHetu: Set<string>
|
||||
transferStudents: Set<string>
|
||||
}
|
||||
|
||||
@@ -72,7 +73,8 @@ const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
|
||||
separateStudents: new Set(),
|
||||
otherStudents: new Set(),
|
||||
|
||||
openStudents: new Set(),
|
||||
openStudentsHasHetu: new Set(),
|
||||
openStudentsNoHetu: new Set(),
|
||||
transferStudents: new Set(),
|
||||
})
|
||||
|
||||
@@ -117,7 +119,8 @@ const finalizeYearStats = (yearAccumulator: YearAccumulator): YearStats => {
|
||||
stats.separateStudents = yearAccumulator.separateStudents.size
|
||||
stats.otherStudents = yearAccumulator.otherStudents.size
|
||||
|
||||
stats.openStudents = yearAccumulator.openStudents.size
|
||||
stats.openStudentsHasHetu = yearAccumulator.openStudentsHasHetu.size
|
||||
stats.openStudentsNoHetu = yearAccumulator.openStudentsNoHetu.size
|
||||
stats.transferStudents = yearAccumulator.transferStudents.size
|
||||
|
||||
stats.allStudents = stats.allPassed + stats.allNotPassed
|
||||
@@ -131,6 +134,9 @@ const getYearRange = async (unixMillis: number, isAcademicYear: boolean) => {
|
||||
return range(START_YEAR, lastYear + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @endpoint "programme courses"
|
||||
*/
|
||||
export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
unixMillis: number,
|
||||
studyProgramme: string,
|
||||
@@ -169,6 +175,10 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
}),
|
||||
])
|
||||
|
||||
const openUniStudentHetuMap = await getStudentHetuStateMap(
|
||||
programmeCredits.filter(c => c.variant === 'openUni').map(c => c.studentNumber)
|
||||
)
|
||||
|
||||
const courseMap = new Map<string, CourseAccumulator>()
|
||||
let maxYear = 0
|
||||
|
||||
@@ -208,8 +218,13 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
stats.otherStudentsCredits += row.credits
|
||||
break
|
||||
case 'openUni':
|
||||
yearAccumulator.openStudents.add(row.studentNumber)
|
||||
stats.openStudentsCredits += row.credits
|
||||
if (openUniStudentHetuMap.get(row.studentNumber)) {
|
||||
stats.openStudentsHasHetuCredits += row.credits
|
||||
yearAccumulator.openStudentsHasHetu.add(row.studentNumber)
|
||||
} else {
|
||||
stats.openStudentsNoHetuCredits += row.credits
|
||||
yearAccumulator.openStudentsNoHetu.add(row.studentNumber)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import { indexOf, orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
@@ -10,12 +11,14 @@ import {
|
||||
ExtentCode,
|
||||
Phase,
|
||||
EnrollmentType,
|
||||
TransferInfo,
|
||||
} from '@oodikone/shared/types'
|
||||
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import logger from '../../util/logger'
|
||||
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
|
||||
import { getGraduatedStats } from './studyProgrammeBasics'
|
||||
@@ -34,6 +37,8 @@ import {
|
||||
} from './studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from './studyRightFinders'
|
||||
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
/**
|
||||
* For bachelor + master study right combo. This rules out master's programmes that don't
|
||||
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
|
||||
@@ -47,19 +52,6 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
|
||||
return minimumCredits !== null && minimumCredits <= 180
|
||||
}
|
||||
|
||||
const calculateAbsenceInMonths = (
|
||||
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) => {
|
||||
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(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Absence rules:
|
||||
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
|
||||
@@ -67,29 +59,58 @@ const calculateAbsenceInMonths = (
|
||||
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
|
||||
*
|
||||
* Additional _non-statutory_ absences will consume semesters
|
||||
*
|
||||
* @returns duration of studies in SEMESTERS (not months!)
|
||||
*/
|
||||
export const calculateDurationOfStudies = (
|
||||
startDate: Date,
|
||||
graduationDate: Date,
|
||||
semesterEnrollments: SemesterEnrollment[],
|
||||
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
|
||||
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
|
||||
transferInfo?: TransferInfo | null
|
||||
) => {
|
||||
const allowedNonStatutoryAbsences = dayjs(startDate).isBefore(dayjs('2015-08-01'), 'day') ? 4 : 2
|
||||
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
|
||||
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
|
||||
dayjs('2015-08-01'),
|
||||
'day'
|
||||
)
|
||||
? 4
|
||||
: 2
|
||||
|
||||
const semestersWithStatutoryAbsence = semesterEnrollments
|
||||
.filter(enrollment => enrollment.statutoryAbsence)
|
||||
.map(enrollment => enrollment.semester)
|
||||
/** A semester ending and the next one starting overlap, this takes care of that */
|
||||
const getCorrectSemester = (date: Dayjs) =>
|
||||
Object.values(semesters).find(
|
||||
({ startdate: semesterStart, enddate: semesterEnd }) =>
|
||||
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
|
||||
)?.semestercode
|
||||
|
||||
const semestersWithNonStatutoryAbsence = semesterEnrollments
|
||||
.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
|
||||
.map(enrollment => enrollment.semester)
|
||||
.slice(0, allowedNonStatutoryAbsences)
|
||||
const startSemester = getCorrectSemester(dayjs(startDate))
|
||||
const endSemester = getCorrectSemester(dayjs(graduationDate))
|
||||
|
||||
const monthsToSubtract = [...semestersWithStatutoryAbsence, ...semestersWithNonStatutoryAbsence].reduce(
|
||||
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
|
||||
0
|
||||
if (!startSemester || !endSemester) {
|
||||
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
|
||||
return -1
|
||||
}
|
||||
|
||||
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
|
||||
|
||||
const semestersWithStatutoryAbsence =
|
||||
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
|
||||
|
||||
const semestersWithNonStatutoryAbsence = Math.min(
|
||||
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
|
||||
.length + (transferInfo?.usedAbsenceTerms ?? 0),
|
||||
allowedNonStatutoryAbsences
|
||||
)
|
||||
|
||||
return (
|
||||
(transferInfo?.usedTerms ?? 0) +
|
||||
endSemester -
|
||||
startSemester -
|
||||
semestersWithStatutoryAbsence -
|
||||
semestersWithNonStatutoryAbsence +
|
||||
1
|
||||
)
|
||||
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
|
||||
}
|
||||
|
||||
export type GraduationTimes = {
|
||||
@@ -188,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
const graduationDate = correctStudyRightElement.endDate
|
||||
|
||||
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
|
||||
const duration = calculateDurationOfStudies(
|
||||
startDate,
|
||||
graduationDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
const graduationYear = defineYear(graduationDate, isAcademicYear)
|
||||
|
||||
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
|
||||
@@ -204,7 +231,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
const goal = await getGoal(studyProgramme)
|
||||
const times: ChartGraduationTimes = { medians: [], goal }
|
||||
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + 36 }
|
||||
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
|
||||
|
||||
for (const year of years.toReversed()) {
|
||||
const median = getMedian(graduationTimes[year])
|
||||
@@ -219,7 +246,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
if (doCombo) {
|
||||
const median = getMedian(graduationTimesCombo[year])
|
||||
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
|
||||
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
|
||||
comboTimes.medians.push({
|
||||
y: median,
|
||||
amount: graduationTimesCombo[year].length,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { ProgrammeModuleModel } from '../../models'
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
|
||||
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
|
||||
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
|
||||
@@ -44,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
|
||||
}
|
||||
|
||||
export const getMedian = (values: number[]) => {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
if (!values.length) return 0
|
||||
|
||||
// compareFn mandatory
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
|
||||
const half = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2) {
|
||||
return sorted[half]
|
||||
}
|
||||
values.sort((a, b) => a - b)
|
||||
const half = Math.floor(values.length / 2)
|
||||
if (values.length % 2) {
|
||||
return values[half]
|
||||
}
|
||||
return (values[half - 1] + values[half]) / 2.0
|
||||
return (sorted[half - 1] + sorted[half]) / 2.0
|
||||
}
|
||||
|
||||
export function defineYear(date: Date, isAcademicYear: true): string
|
||||
@@ -183,9 +185,9 @@ export const getGoal = async (programme?: string) => {
|
||||
return 0
|
||||
}
|
||||
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
|
||||
return 48
|
||||
return GraduationTarget.FOUR_YEARS
|
||||
}
|
||||
return (minimumCredits / 60) * 12
|
||||
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
|
||||
}
|
||||
|
||||
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
|
||||
|
||||
@@ -14,15 +14,18 @@ export const getStudyRightsInProgramme = async (
|
||||
where.graduated = true
|
||||
}
|
||||
|
||||
const studyRights = await SISStudyRightModel.findAll({
|
||||
attributes: ['id'],
|
||||
include: {
|
||||
model: SISStudyRightElementModel,
|
||||
as: 'studyRightElements',
|
||||
attributes: [],
|
||||
where,
|
||||
},
|
||||
})
|
||||
const studyRightIds = (
|
||||
await SISStudyRightModel.findAll({
|
||||
attributes: ['id'],
|
||||
include: {
|
||||
model: SISStudyRightElementModel,
|
||||
as: 'studyRightElements',
|
||||
attributes: [],
|
||||
where,
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(sr => sr.id)
|
||||
|
||||
const include: Includeable[] = [
|
||||
{
|
||||
@@ -54,11 +57,11 @@ export const getStudyRightsInProgramme = async (
|
||||
|
||||
return (
|
||||
await SISStudyRightModel.findAll({
|
||||
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
|
||||
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
|
||||
include,
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
|
||||
[Op.in]: studyRightIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SemesterEnrollment,
|
||||
} from '@oodikone/shared/types'
|
||||
import { createLocaleComparator, keysOf } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
|
||||
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
|
||||
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
|
||||
combinedProgramme?: string
|
||||
) => {
|
||||
const goal = await getGoal(combinedProgramme ?? studyProgramme)
|
||||
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
|
||||
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
|
||||
|
||||
const calculateGraduationTimes = (
|
||||
graduationTimes: Record<string, Record<string, number[]>>,
|
||||
@@ -276,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
|
||||
|
||||
const graduationDate = studyRightElement.endDate
|
||||
|
||||
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
|
||||
const duration = calculateDurationOfStudies(
|
||||
startDate,
|
||||
graduationDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
|
||||
if (countAsBachelorMaster) {
|
||||
if (!graduationTimesCombo[programmeOrStudyTrack]) {
|
||||
@@ -313,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
|
||||
bachelorStartDate,
|
||||
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
|
||||
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { importerToken, importerUrl } from '../config'
|
||||
import logger from './logger'
|
||||
|
||||
const importerClient = axios.create({
|
||||
const importerClient = Fetchios.create({
|
||||
headers: { token: importerToken },
|
||||
baseURL: importerUrl,
|
||||
baseUrl: importerUrl,
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import axios from 'axios'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
import { importerToken, isStaging, jamiUrl } from '../config'
|
||||
import { IamAccess } from '../types'
|
||||
import logger from './logger'
|
||||
|
||||
const jamiClient = axios.create({
|
||||
baseURL: jamiUrl,
|
||||
const jamiClient = Fetchios.create({
|
||||
baseUrl: jamiUrl,
|
||||
params: {
|
||||
token: importerToken,
|
||||
},
|
||||
@@ -24,29 +24,34 @@ export const getUserIamAccess = async (
|
||||
): Promise<UserIamAccess> => {
|
||||
if (iamGroups.length === 0) return {}
|
||||
|
||||
try {
|
||||
const { data } = await jamiClient.post('/', {
|
||||
const { data, error } = await jamiClient.post<Pick<UserIamAccess, 'specialGroup'>>(
|
||||
'',
|
||||
{
|
||||
userId: sisPersonId,
|
||||
getSisuAccess: !isStaging && !!sisPersonId,
|
||||
iamGroups,
|
||||
})
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
if (error === null) {
|
||||
const { specialGroup, ...resetOfIamAccess } = data
|
||||
return { iamAccess: resetOfIamAccess, specialGroup }
|
||||
} catch (error) {
|
||||
if (attempt > 3) {
|
||||
logger.error('[Jami] error: ', error)
|
||||
Sentry.captureException(error)
|
||||
return {}
|
||||
}
|
||||
|
||||
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
|
||||
}
|
||||
|
||||
if (attempt > 3) {
|
||||
logger.error('[Jami] error: ', error)
|
||||
Sentry.captureException(error)
|
||||
return {}
|
||||
}
|
||||
|
||||
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
|
||||
}
|
||||
|
||||
export const getUserIams = async (userId: string) => {
|
||||
try {
|
||||
const { data } = await jamiClient.get(`/${userId}`)
|
||||
const { data } = await jamiClient.get<any>(userId, {})
|
||||
|
||||
return data.iamGroups as string[]
|
||||
} catch (error: any) {
|
||||
if (error.response.status !== 404) {
|
||||
@@ -65,12 +70,12 @@ type Access = {
|
||||
}
|
||||
|
||||
export const getAllUserAccess = async (userIds: string[]) => {
|
||||
const { data } = await jamiClient.post('access-and-special-groups', { userIds })
|
||||
const { data } = await jamiClient.post('access-and-special-groups', { userIds }, {})
|
||||
return data as Access[]
|
||||
}
|
||||
|
||||
jamiClient
|
||||
.get('/ping', { timeout: 4000 })
|
||||
.get('ping', { timeout: 4000 })
|
||||
.then(() => {
|
||||
logger.info('JAMI connected')
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"sourceMap": true,
|
||||
"sourceRoot": "/",
|
||||
"inlineSources": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["index.ts", "src", "tests"],
|
||||
"exclude": ["../frontend"]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.dockerignore
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
*Dockerfile
|
||||
build/
|
||||
node_modules/
|
||||
@@ -28,6 +28,8 @@ ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ARG SIS_URL='https://sisu.helsinki.fi'
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
RUN npm install -g serve@^14
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
@@ -36,7 +38,7 @@ COPY ./frontend/package*.json .
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
RUN npm run build && npm install -g serve@^14
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["serve", "--single", "--listen", "5000", "build"]
|
||||
|
||||
Generated
-259
@@ -17,7 +17,6 @@
|
||||
"@sentry/browser": "^10.43.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -2257,23 +2256,6 @@
|
||||
"vite": "^4 || ^5 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
@@ -2299,19 +2281,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2380,18 +2349,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -2484,15 +2441,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2525,20 +2473,6 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2548,51 +2482,6 @@
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -2687,42 +2576,6 @@
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2747,82 +2600,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -3144,15 +2921,6 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -3748,27 +3516,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3948,12 +3695,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@sentry/browser": "^10.43.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"lodash-es": "^4.17.23",
|
||||
|
||||
+33
-50
@@ -1,14 +1,12 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import axios from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
import { actionHistory } from '@/redux/actionHistory'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const baseHeaders = isDev
|
||||
const baseHeaders: Record<string, string> = isDev
|
||||
? {
|
||||
uid: 'mluukkai',
|
||||
displayName: 'Matti Luukkainen',
|
||||
@@ -31,68 +29,53 @@ const getHeaders = () => {
|
||||
return headers
|
||||
}
|
||||
|
||||
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
|
||||
const api = Fetchios.create({ baseUrl: apiBasePath })
|
||||
|
||||
const actionTypes = prefix => ({
|
||||
attempt: `${prefix}ATTEMPT`,
|
||||
failure: `${prefix}FAILURE`,
|
||||
success: `${prefix}SUCCESS`,
|
||||
})
|
||||
type ApiMethods = 'get' | 'post' | 'put' | 'delete'
|
||||
|
||||
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
|
||||
const options = { headers: getHeaders(), timeout }
|
||||
/**
|
||||
* Calls API with minimal preprocessing.
|
||||
* NOTE! `data` -attribute has to be JSON.stringified in-order for the predefined
|
||||
* 'Content-Type' to work and the receiving Express instance to parse the body correctly.
|
||||
*/
|
||||
export const callApi = async (
|
||||
url: string,
|
||||
method: ApiMethods = 'get',
|
||||
data?: any,
|
||||
params: Record<string, any> = {},
|
||||
timeout = 30_000
|
||||
) => {
|
||||
const apiRequestController = new AbortController()
|
||||
const signal = timeout !== 0 ? apiRequestController.signal : undefined
|
||||
|
||||
const onDownloadProgress = ({ loaded, total }) => {
|
||||
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
|
||||
const options = {
|
||||
params,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getHeaders(),
|
||||
},
|
||||
}
|
||||
|
||||
// const buildUrl = () => e{
|
||||
// const urlParams = new URLSearchParams(params)
|
||||
// return apiBasePath + url + urlParams.toString()
|
||||
// }
|
||||
|
||||
switch (method) {
|
||||
case 'get':
|
||||
return api.get(url, { ...options, params, onDownloadProgress })
|
||||
return api.get(url, options)
|
||||
case 'post':
|
||||
return api.post(url, data, options)
|
||||
case 'put':
|
||||
return api.put(url, data, options)
|
||||
case 'delete':
|
||||
return api.delete(url, { headers: options.headers, data })
|
||||
return api.delete(url, options)
|
||||
default:
|
||||
return Promise.reject(new Error('Invalid http method'))
|
||||
throw new Error('Invalid method used for API call.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error, actionHistory = []) => {
|
||||
const { config, request, response } = error
|
||||
if (response && response.status) {
|
||||
Sentry.withScope(scope => {
|
||||
scope.setExtra('config', config)
|
||||
scope.setExtra('request', request)
|
||||
scope.setExtra('response', response)
|
||||
scope.setExtra('actionHistory', JSON.stringify(actionHistory))
|
||||
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const handleRequest = store => next => async action => {
|
||||
if (action.requestSettings) {
|
||||
const { success, failure } = actionTypes(prefix)
|
||||
const { route, method, data, prefix, query, params, onProgress } = action.requestSettings
|
||||
|
||||
// Based no the previous config. We don't actually care when this finishes.
|
||||
void callApi(route, method, data, params, 0, onProgress)
|
||||
.then(res => store.dispatch({ type: success, response: res.data, query }))
|
||||
.catch(error => {
|
||||
// Handle error first to avoid redux minified error #3 in production.
|
||||
// We don't need to add the error to the actionHistory as it is already passed to Sentry.
|
||||
handleError(error, actionHistory)
|
||||
store.dispatch({ type: failure, response: error, query })
|
||||
})
|
||||
}
|
||||
|
||||
next(action)
|
||||
}
|
||||
|
||||
// Redux-toolkit query based API
|
||||
// Api can be extended with enhanceEndpoints method
|
||||
// All tags used for invalidating cache must be defined here
|
||||
@@ -47,12 +47,11 @@ export const facultyToolTips: Record<string, string> = {
|
||||
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
|
||||
|
||||
- **Breakdown**/**Median study times**:
|
||||
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), 12 kuukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
|
||||
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
|
||||
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
- **Graduation year**/**Starting year**:
|
||||
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
|
||||
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
|
||||
`,
|
||||
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
|
||||
studentProgress: `
|
||||
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
|
||||
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
|
||||
|
||||
@@ -8,6 +8,7 @@ export const filterToolTips = {
|
||||
|
||||
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
|
||||
`,
|
||||
hetu: 'Rajaa opiskelijoita sen mukaan onko heillä suomalaista henkilötunnusta. Filters students based on if they have a Finnish personal identity code.',
|
||||
startingYear:
|
||||
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
|
||||
studentNumber:
|
||||
|
||||
@@ -29,7 +29,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
|
||||
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
|
||||
|
||||
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
|
||||
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija. Avoimen opiskelijat ovat eroteltu suomalaisen henkilötunnuksen omaaviin / hetuttomiin.
|
||||
|
||||
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
|
||||
|
||||
@@ -72,22 +72,22 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
averageGraduationTimes: `
|
||||
Yksittäinen palkki kertoo, kuinka moni opiskelija on valmistunut kyseisenä vuonna/lukuvuonna.
|
||||
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
|
||||
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
|
||||
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
|
||||
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
|
||||
`,
|
||||
averageGraduationTimesStudyTracks: `
|
||||
Yksittäinen palkki kertoo, kuinka moni kyseisenä **lukuvuotena ALOITTANEISTA** opiskelijoista on valmistunut.
|
||||
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
|
||||
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
|
||||
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
|
||||
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
courseFilter,
|
||||
creditsEarnedFilter,
|
||||
genderFilter,
|
||||
hetuFilter,
|
||||
gradeFilter,
|
||||
programmeFilter,
|
||||
startYearAtUniFilter,
|
||||
@@ -208,6 +209,7 @@ export const CoursePopulation = () => {
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
courseFilter,
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
hetuFilter,
|
||||
enrollmentStatusFilter,
|
||||
genderFilter,
|
||||
hopsFilter,
|
||||
@@ -51,6 +52,7 @@ export const CustomPopulationWrapper = ({
|
||||
transferredToProgrammeFilter(),
|
||||
startYearAtUniFilter(),
|
||||
tagsFilter(),
|
||||
hetuFilter(),
|
||||
programmeFilter(),
|
||||
creditDateFilter(),
|
||||
enrollmentStatusFilter({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { FilterTrayProps } from '../FilterTray'
|
||||
import { FilterRadio } from './common/FilterRadio'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const DEFAULT_STATE = 0
|
||||
|
||||
const HetuFilterCard = ({ onOptionsChange }: FilterTrayProps) => {
|
||||
const modeOptions = [
|
||||
{ key: 'All', text: 'All', value: 0 },
|
||||
{ key: 'Has hetu', text: 'Has hetu', value: 1 },
|
||||
{ key: 'Does not have hetu', text: 'Does not have hetu', value: 2 },
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterRadio
|
||||
defaultValue={DEFAULT_STATE}
|
||||
filterKey={hetuFilter.key}
|
||||
onChange={({ target }) => onOptionsChange({ mode: target.value })}
|
||||
options={modeOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @options
|
||||
* 0 - All
|
||||
* 1 - Has hetu
|
||||
* 2 - No hetu
|
||||
*/
|
||||
export const hetuFilter = createFilter({
|
||||
key: 'hetuFilter',
|
||||
title: 'Personal identity code',
|
||||
|
||||
info: filterToolTips.hetu,
|
||||
|
||||
defaultOptions: {
|
||||
mode: 0,
|
||||
},
|
||||
|
||||
isActive: ({ mode }) => mode !== 0,
|
||||
|
||||
filter(student, { options: { mode } }) {
|
||||
const hetu = student.hasPersonalIdentityCode
|
||||
|
||||
switch (Number(mode)) {
|
||||
case 0:
|
||||
return true
|
||||
case 1:
|
||||
return hetu
|
||||
case 2:
|
||||
return !hetu
|
||||
default:
|
||||
// never reached
|
||||
return true
|
||||
}
|
||||
},
|
||||
|
||||
render: HetuFilterCard,
|
||||
})
|
||||
@@ -18,3 +18,4 @@ export { studyRightTypeFilter } from './studyRightType'
|
||||
export { studyTrackFilter } from './studyTrack'
|
||||
export { tagsFilter } from './tags'
|
||||
export { transferredToProgrammeFilter } from './transferredToProgramme'
|
||||
export { hetuFilter } from './hetu'
|
||||
|
||||
@@ -71,7 +71,8 @@ export const studentNumberFilter = createFilter({
|
||||
filter(student, { options }) {
|
||||
const { allowlist, blocklist } = options
|
||||
|
||||
return allowlist.includes(student.studentNumber) && !blocklist.includes(student.studentNumber)
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
|
||||
},
|
||||
|
||||
render: StudentNumberFilterCard,
|
||||
|
||||
@@ -120,11 +120,11 @@ export const MedianBarChart = ({
|
||||
yearLabel === 'Start year'
|
||||
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} months</b><br />`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
if (!facultyGraph) {
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} months **</b></p>` : ''
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
|
||||
}
|
||||
return `${timeText}${statisticsText}`
|
||||
@@ -201,7 +201,7 @@ export const MedianBarChart = ({
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
},
|
||||
{
|
||||
value: goal + 12.1,
|
||||
value: goal + 2.1,
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
},
|
||||
{
|
||||
@@ -230,7 +230,7 @@ export const MedianBarChart = ({
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (months)' },
|
||||
title: { text: 'Graduation time (semesters)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
@@ -246,7 +246,7 @@ export const MedianBarChart = ({
|
||||
{
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
width: 2,
|
||||
value: goal + 12,
|
||||
value: goal + 2,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -66,6 +66,9 @@ export const OodiTableContainer = <OTData,>({
|
||||
backgroundColor: 'grey.100',
|
||||
}
|
||||
: {},
|
||||
'& tr:hover > td': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{table.getAggregationRowModel().rows.map(row => OodiTableDataRow(row, true))}
|
||||
|
||||
@@ -10,13 +10,14 @@ import { flexRender } from './util'
|
||||
export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const { maxSize } = cell.column.columnDef
|
||||
const { size, maxSize } = cell.column.columnDef
|
||||
return (
|
||||
<TableCell
|
||||
className="ot-data-cell"
|
||||
key={cell.id}
|
||||
sx={{
|
||||
...getCommonPinningStyles(cell.column),
|
||||
width: size ?? undefined,
|
||||
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import TablePagination from '@mui/material/TablePagination'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
|
||||
const tableRows = table.getRowCount()
|
||||
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, tableRows),
|
||||
})
|
||||
|
||||
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
|
||||
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
|
||||
}),
|
||||
[table.getCoreRowModel().rows.length]
|
||||
)
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={table.getFilteredRowModel().rows.length}
|
||||
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
|
||||
onRowsPerPageChange={event => {
|
||||
table.setPageSize(Number(event.target.value))
|
||||
table.setPageIndex(0)
|
||||
}}
|
||||
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
|
||||
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
|
||||
page={table.getState().pagination.pageIndex}
|
||||
rowsPerPage={table.getState().pagination.pageSize}
|
||||
rowsPerPageOptions={pageRowOptions}
|
||||
|
||||
@@ -93,6 +93,7 @@ export const OodiTableExcelExport = <TData extends object>({
|
||||
}),
|
||||
columnHelper.accessor('sample', {
|
||||
header: 'Sample values',
|
||||
enableSorting: false,
|
||||
cell: cell => {
|
||||
const value = cell.getValue<React.ReactNode>()
|
||||
return (
|
||||
@@ -112,15 +113,14 @@ export const OodiTableExcelExport = <TData extends object>({
|
||||
[data]
|
||||
)
|
||||
|
||||
const prepData = exportData.slice(0, 10)
|
||||
|
||||
const pivotedData = useMemo(
|
||||
() =>
|
||||
exportColumnKeys.map(accessorKey => ({
|
||||
header: accessorKey,
|
||||
sample: prepData
|
||||
sample: data
|
||||
.map(row => row[accessorKey])
|
||||
.filter(value => value !== undefined)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.map(churnSampleData),
|
||||
})),
|
||||
[exportColumnKeys, exportData]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
|
||||
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
|
||||
import { OodiTableContainer } from './OodiTable'
|
||||
@@ -28,21 +28,14 @@ export const OodiTable = <TData,>({
|
||||
cy,
|
||||
}: {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
columns: ColumnDef<TData, any>[]
|
||||
isExportView?: true
|
||||
options?: Partial<TableOptions<TData>>
|
||||
toolbarContent?: ReactNode
|
||||
cy?: string
|
||||
}) => {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, data.length),
|
||||
})
|
||||
|
||||
// should maybe use a deep merging tool or write own
|
||||
const config: Partial<TableOptions<TData>> = {
|
||||
...options,
|
||||
}
|
||||
const { ...config }: Partial<TableOptions<TData>> = options
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
|
||||
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
...config,
|
||||
state: { pagination, ...config.state },
|
||||
})
|
||||
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
|
||||
}
|
||||
|
||||
+46
-28
@@ -26,6 +26,11 @@ const getSortingValue = (course: Courses['number'] | undefined) => {
|
||||
return 1
|
||||
}
|
||||
|
||||
const isCourseCodes = (courses: unknown): courses is string[] => {
|
||||
if (Array.isArray(courses) && courses.length && typeof courses.at(0) === 'string') return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>): ColumnDef<CourseTabStudent, any>[] => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const theme = useTheme()
|
||||
@@ -153,35 +158,14 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
if (!correctCourse) return null
|
||||
|
||||
const sub = correctCourse.substitutedBy
|
||||
const substitutePrefix = sub ? `Substituting course: ${sub}\n` : ''
|
||||
|
||||
if (correctCourse.grade) {
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title =
|
||||
substitutePrefix +
|
||||
(isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`)
|
||||
if (correctCourse.enrollmentDate) {
|
||||
const subPrefix =
|
||||
sub && !isCourseCodes(sub)
|
||||
? `Substituted by: ${sub?.map(course => course.course_code)?.join(', ')}\n`
|
||||
: ''
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.enrollmentDate) {
|
||||
return (
|
||||
<div
|
||||
title={
|
||||
substitutePrefix + `Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`
|
||||
}
|
||||
>
|
||||
<div title={`${subPrefix}Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`}>
|
||||
<RemoveIcon
|
||||
sx={{
|
||||
color: isWithinSixMonths(correctCourse.enrollmentDate)
|
||||
@@ -192,11 +176,45 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.inHops) {
|
||||
const subString = sub && isCourseCodes(sub) ? `Substituted by: ${sub?.join(', ')}\n\n` : ''
|
||||
return (
|
||||
<div title={substitutePrefix + 'In primary studyplan'}>
|
||||
<div title={`${subString}In primary studyplan`}>
|
||||
<CropSquareIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.grade) {
|
||||
// NOTE: Contains only normally-passed-courses because substitutions don't have a grade field
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title = isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
) : (
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (sub) {
|
||||
const subStringPrefix = `Substituted by:\n\n`
|
||||
const subString = isCourseCodes(sub)
|
||||
? subStringPrefix + sub.join(', ')
|
||||
: subStringPrefix +
|
||||
sub
|
||||
.map(
|
||||
course =>
|
||||
`${course.course_code}\nGrade: ${course.grade}\nCompleted on: ${formatDate(course.date, dateFormat)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
return (
|
||||
<div title={subString}>
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
+152
-80
@@ -7,7 +7,7 @@ import { useToggle } from '@/hooks/toggle'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { FormattedStudent } from '@oodikone/shared/types/studentData'
|
||||
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { IncludeSubstitutionsToggle } from '../../IncludeSubstitutionsToggle'
|
||||
import { useGetColumnDefinitions } from './columnDefinitions'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type Courses = Record<
|
||||
{
|
||||
grade?: string
|
||||
completionDate?: string
|
||||
substitutedBy?: string
|
||||
substitutedBy?: StudentCourse[] | string[]
|
||||
inHops?: boolean
|
||||
enrollmentDate?: Date
|
||||
passed?: boolean
|
||||
@@ -48,11 +48,12 @@ const studentMapper = (
|
||||
student: FormattedStudent,
|
||||
includeSubstitutions: boolean,
|
||||
curriculumCourseCodes: string[],
|
||||
substituteToPrimaryCodes: Record<string, string[]>
|
||||
substitutionsForCourseCode: Record<string, string[][]>
|
||||
) => {
|
||||
const courseMap = {}
|
||||
|
||||
// NB: there can be many attainments/enrollments for each course code
|
||||
// All passed courses that are included in curriculum
|
||||
const passedCourses = student.courses.filter(
|
||||
course => curriculumCourseCodes.includes(course.course_code) && course.passed
|
||||
)
|
||||
@@ -61,74 +62,152 @@ const studentMapper = (
|
||||
studyPlan.included_courses.filter(code => curriculumCourseCodes.includes(code))
|
||||
)
|
||||
|
||||
const passedSubstituteCourses = student.courses.filter(course => !!substituteToPrimaryCodes[course.course_code])
|
||||
const substituteEnrollments = student.enrollments.filter(
|
||||
enrollment => !!substituteToPrimaryCodes[enrollment.course_code]
|
||||
)
|
||||
const substituteHopsItems = student.studyplans.flatMap(studyPlan =>
|
||||
studyPlan.included_courses.filter(code => !!substituteToPrimaryCodes[code])
|
||||
// All passed courses including random AY-codes etc
|
||||
const allPassedCourseCodes = student.courses.filter(course => course.passed).map(course => course.course_code)
|
||||
const substitutionsToCurriculumCourses = curriculumCourseCodes.reduce<Record<string, string[][]>>((acc, code) => {
|
||||
const substitutionsToCurriculumCourse = substitutionsForCourseCode[code]
|
||||
if (substitutionsToCurriculumCourse) {
|
||||
acc[code] = substitutionsToCurriculumCourse
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// curriculumCourseCode -> passedSubstGroup
|
||||
const passedSubstitutionsToCurriculumCourses = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, StudentCourse[]>
|
||||
>((acc, code) => {
|
||||
const passedSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => allPassedCourseCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
|
||||
// Also this .find (and at(0)!) should never be undefined because the codes are student's completed courses => they exist under student.courses
|
||||
const passedSubstitutionGroupCourses = passedSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.courses.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (passedSubstitutionGroups.length) {
|
||||
acc[code] = passedSubstitutionGroupCourses
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const enrollmentCodes = enrollments.map(e => e.course_code)
|
||||
const enrollmentsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, FormattedStudent['enrollments']>
|
||||
>((acc, code) => {
|
||||
const enrolledSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => enrollmentCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Same as above
|
||||
const enrolledSubstitutionGroupCourses = enrolledSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.enrollments.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (enrolledSubstitutionGroupCourses?.length) {
|
||||
acc[code] = enrolledSubstitutionGroupCourses
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const hopsItemsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<Record<string, string[]>>(
|
||||
(acc, code) => {
|
||||
// TODO: Same as above
|
||||
const hopsSubstitutionGroups = substitutionsToCurriculumCourses[code]
|
||||
.filter(substGroup => substGroup.every(sgCode => hopsItems.includes(sgCode)))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)!
|
||||
if (hopsSubstitutionGroups?.length) {
|
||||
acc[code] = hopsSubstitutionGroups
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses, substitutions: boolean) => {
|
||||
const mapSubstitutionCourses = (coursesToAdd: typeof passedSubstitutionsToCurriculumCourses) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(coursesToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
exportValue: `Substitutes ${code}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses) => {
|
||||
for (const course of coursesToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[course.course_code] : [course.course_code]
|
||||
codes.forEach(code => {
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: substitutions ? course.course_code : undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
const code = course.course_code
|
||||
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments, substitutions: boolean) => {
|
||||
const mapSubstitutionEnrollments = (enrollmentsToAdd: typeof enrollmentsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(enrollmentsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
enrollmentDate: substitutionGroup.at(0)!.enrollment_date_time,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments) => {
|
||||
for (const enrollment of enrollmentsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[enrollment.course_code] : [enrollment.course_code]
|
||||
codes.forEach(code => {
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: substitutions ? enrollment.course_code : undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems, substitutions: boolean) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[selection] : [selection]
|
||||
codes.forEach(code => {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutions ? selection : undefined,
|
||||
const code = enrollment.course_code
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses, false)
|
||||
if (includeSubstitutions) mapCourses(passedSubstituteCourses, true)
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const code = selection
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapSubstitutionHopsSelections = (selectionsToAdd: typeof hopsItemsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(selectionsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutionGroup,
|
||||
exporValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses)
|
||||
if (includeSubstitutions) mapSubstitutionCourses(passedSubstitutionsToCurriculumCourses)
|
||||
|
||||
mapEnrollments(enrollments)
|
||||
if (includeSubstitutions) mapSubstitutionEnrollments(enrollmentsWithSubstitutions)
|
||||
|
||||
mapHopsSelections(hopsItems)
|
||||
if (includeSubstitutions) mapSubstitutionHopsSelections(hopsItemsWithSubstitutions)
|
||||
|
||||
const totalPassed = Object.keys(courseMap).length
|
||||
|
||||
mapEnrollments(enrollments, false)
|
||||
if (includeSubstitutions) mapEnrollments(substituteEnrollments, true)
|
||||
|
||||
mapHopsSelections(hopsItems, false)
|
||||
if (includeSubstitutions) mapHopsSelections(substituteHopsItems, true)
|
||||
|
||||
const totalPlanned = Object.keys(courseMap).length - totalPassed
|
||||
|
||||
return {
|
||||
@@ -163,7 +242,7 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
|
||||
/**
|
||||
* @returns true if current course (the latter) has the same or better grade than old course
|
||||
*/
|
||||
const compareCourseGrades = (previous, current) =>
|
||||
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
|
||||
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
|
||||
|
||||
export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTabContainerProps) => {
|
||||
@@ -182,34 +261,27 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
|
||||
const curriculumCourseCodes = useMemo(() => curriculumCourses.map(course => course.code), [curriculumCourses])
|
||||
|
||||
const substitutionCourseCodes = useMemo(
|
||||
// All substitution_groups entries as codes
|
||||
const substitutionGroupsCourseCodes = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => curriculumCourseCodes.includes(course.course.code))
|
||||
.flatMap(course => course.course.substitutions),
|
||||
.flatMap(course => course.course.substitution_groups?.map(sbGr => sbGr) ?? []),
|
||||
[courses, curriculumCourseCodes]
|
||||
)
|
||||
|
||||
/**
|
||||
* Justification for using array:
|
||||
* there are instances where a course substitutes multiple different courses currently present in curriculum
|
||||
* g.g. course code TKT20015 -> BSCS2002/TKT10001 in curriculum 2020-2023
|
||||
* but most of the time there is only one main code
|
||||
*/
|
||||
const substituteToPrimaryCodes: Record<string, string[]> = useMemo(
|
||||
// All substitutionGroups for a given course code => course.substitution_groups
|
||||
const substitutionsForCourseCode: Record<string, string[][]> = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => substitutionCourseCodes.includes(course.course.code))
|
||||
.reduce((acc, course) => {
|
||||
const mainCodes = course.course.substitutions.filter(sub => curriculumCourseCodes.includes(sub))
|
||||
// If no mainCode in curriculum, leave blank as nothing would be shown in course tab anyway
|
||||
if (mainCodes.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...mainCodes)
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[curriculumCourseCodes, substitutionCourseCodes, courses]
|
||||
courses.reduce((acc, course) => {
|
||||
const substitutionGroups = course.course.substitution_groups ?? []
|
||||
if (substitutionGroups.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...substitutionGroups.filter(group => group.length))
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[substitutionGroupsCourseCodes, courses]
|
||||
)
|
||||
|
||||
const coursesByParentModule = useMemo(() => {
|
||||
@@ -232,9 +304,9 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
const formattedStudents = useMemo(
|
||||
() =>
|
||||
students.map(student =>
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substituteToPrimaryCodes)
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substitutionsForCourseCode)
|
||||
),
|
||||
[students, studentMapper]
|
||||
[students, studentMapper, includeSubstitutions]
|
||||
)
|
||||
|
||||
const columns = useGetColumnDefinitions(coursesByParentModule)
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ export const useFormat = ({
|
||||
/* BASE COLUMNS */
|
||||
studentNumber: student.obfuscated ? 'Hidden' : student.studentNumber,
|
||||
creditsTotal: student.credits,
|
||||
creditsHops: student.hopsCredits,
|
||||
creditsHops: programmeDetails?.primaryStudyplan?.completed_credits ?? 0,
|
||||
|
||||
creditsCombinedProg: !!combinedProgramme || showBachelorAndMaster ? getCombinedCredits(studentBlob) : null,
|
||||
creditsSince: getCreditsBetween(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
import { SwapHorizIcon } from '@/theme'
|
||||
|
||||
export const GroupChip = ({ group }: { group: string[] }) => (
|
||||
<Chip
|
||||
icon={<SwapHorizIcon color="primary" fontSize="small" />}
|
||||
label={group.join(', ')}
|
||||
sx={{ my: 0.25, width: 'fit-content' }}
|
||||
/>
|
||||
)
|
||||
@@ -28,7 +28,7 @@ export const ExpandableRow = ({
|
||||
return (
|
||||
<TableRow>
|
||||
{yearArray?.map((value, index) => (
|
||||
<TableCell align="right" key={getKey(value, index)}>
|
||||
<TableCell align="right" key={getKey(value, index)} sx={{ px: 0.5, py: 0.75 }}>
|
||||
{index === 0 ? (
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton data-cy={`${cypress}${yearIndex}`} onClick={toggleVisibility} size="small">
|
||||
|
||||
@@ -61,7 +61,7 @@ export const MedianTimeBarChart = ({
|
||||
? `<b>From class of ${year}, ${amount}/${classSize} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${year}</b>`
|
||||
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} months</b><br />`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} semesters</b><br />`
|
||||
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
@@ -119,7 +119,7 @@ export const MedianTimeBarChart = ({
|
||||
color: '#90A959',
|
||||
},
|
||||
{
|
||||
value: goal + 12.1,
|
||||
value: goal + 2.1,
|
||||
color: '#FEE191',
|
||||
},
|
||||
{
|
||||
@@ -141,7 +141,7 @@ export const MedianTimeBarChart = ({
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (months)' },
|
||||
title: { text: 'Graduation time (semesters)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const MedianTimeBarChart = ({
|
||||
{
|
||||
color: '#FEE191',
|
||||
width: 2,
|
||||
value: goal + 12,
|
||||
value: goal + 2,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,13 +6,34 @@ import Table from '@mui/material/Table'
|
||||
* To be paired with StyledCell
|
||||
*/
|
||||
export const StyledTable = styled(Table, {
|
||||
shouldForwardProp: prop => prop !== 'showCellBorders' && prop !== 'zebraStriped',
|
||||
})<{ showCellBorders?: true; zebraStriped?: true }>(({ theme, showCellBorders, zebraStriped }) => {
|
||||
shouldForwardProp: prop =>
|
||||
prop !== 'showCellBorders' && prop !== 'zebraStriped' && prop !== 'slimHeader' && prop !== 'slimBody',
|
||||
})<{ showCellBorders?: true; zebraStriped?: true; slimHeader?: true; slimBody?: true }>(({
|
||||
theme,
|
||||
showCellBorders,
|
||||
zebraStriped,
|
||||
slimHeader,
|
||||
slimBody,
|
||||
}) => {
|
||||
return {
|
||||
'&': {
|
||||
border: `1px solid ${theme.palette.grey[300]}`,
|
||||
},
|
||||
|
||||
'& .MuiTableHead-root .MuiTableCell-root': slimHeader
|
||||
? {
|
||||
paddingTop: '0.5em',
|
||||
paddingBottom: '0.5em',
|
||||
}
|
||||
: {},
|
||||
|
||||
'& .MuiTableBody-root .MuiTableCell-root': slimBody
|
||||
? {
|
||||
paddingTop: '0.5em',
|
||||
paddingBottom: '0.5em',
|
||||
}
|
||||
: {},
|
||||
|
||||
'& .MuiTableHead-root': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
border: `1px solid ${theme.palette.grey[300]}`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
|
||||
import { StarIcon, SwapHorizIcon } from '@/theme'
|
||||
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
|
||||
import { StarIcon } from '@/theme'
|
||||
|
||||
export const PrimaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
|
||||
<Chip color={'primary'} icon={<StarIcon fontSize="small" />} label={`${code} • ${name}`} />
|
||||
)
|
||||
|
||||
export const SecondaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
|
||||
<Chip icon={<SwapHorizIcon fontSize="small" />} label={`${code} • ${name}`} />
|
||||
<GroupChip group={[`${code} • ${name}`]} />
|
||||
)
|
||||
|
||||
+86
-94
@@ -1,18 +1,30 @@
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { FormattedStats } from '@/types/courseStat'
|
||||
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
|
||||
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { getGradeSpread, getThesisGradeSpread, isThesisGrades } from '../util'
|
||||
import { ObfuscatedCell } from './ObfuscatedCell'
|
||||
import { TimeCell } from './TimeCell'
|
||||
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
|
||||
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
|
||||
type TableData = {
|
||||
name: string
|
||||
code: number
|
||||
totalAttempts: number
|
||||
passed: number
|
||||
failed: number
|
||||
passRate: string
|
||||
enrollments?: number
|
||||
rowObfuscated?: boolean
|
||||
grades: Record<string, number>
|
||||
}
|
||||
|
||||
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean): TableData[] => {
|
||||
return stats.map(stat => {
|
||||
const {
|
||||
name,
|
||||
@@ -42,7 +54,7 @@ const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
|
||||
}
|
||||
|
||||
export const AttemptsTable = ({
|
||||
data: { name, stats },
|
||||
data: { stats },
|
||||
separate,
|
||||
showGrades,
|
||||
userHasAccessToAllStats,
|
||||
@@ -50,7 +62,7 @@ export const AttemptsTable = ({
|
||||
openOrRegular,
|
||||
alternatives,
|
||||
}: {
|
||||
data: { name: string; stats: FormattedStats[] }
|
||||
data: ProgrammeStats
|
||||
separate: boolean
|
||||
showGrades: boolean
|
||||
userHasAccessToAllStats: boolean
|
||||
@@ -58,14 +70,10 @@ export const AttemptsTable = ({
|
||||
openOrRegular: CourseSearchState
|
||||
alternatives: string[]
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||
|
||||
const showPopulation = useCallback(
|
||||
(yearCode: string) => {
|
||||
(yearCode: number) => {
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
@@ -81,51 +89,12 @@ export const AttemptsTable = ({
|
||||
|
||||
const useThesisGrades = isThesisGrades(stats[0].attempts.grades)
|
||||
const data = useMemo(() => getTableData(stats, useThesisGrades), [stats, useThesisGrades])
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Time',
|
||||
Cell: ({ cell, row }) => (
|
||||
<TimeCell
|
||||
href={showPopulation(row.original.code)}
|
||||
isEmptyRow={row.original.totalAttempts === 0}
|
||||
name={cell.getValue<string>()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
},
|
||||
{
|
||||
accessorKey: 'totalAttempts',
|
||||
header: 'Total attempts',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passed',
|
||||
header: 'Passed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failed',
|
||||
header: 'Failed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passRate',
|
||||
header: 'Pass rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<string>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'enrollments',
|
||||
header: 'Enrollments',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
const excelData = useMemo(() => {
|
||||
return data.map(({ grades, ...rest }) => ({
|
||||
...rest,
|
||||
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(prev => {
|
||||
@@ -142,51 +111,74 @@ export const AttemptsTable = ({
|
||||
updatedVisibility.passRate = !showGrades
|
||||
updatedVisibility.enrollments = !showGrades
|
||||
|
||||
return { ...updatedVisibility }
|
||||
return {
|
||||
...updatedVisibility,
|
||||
name: true,
|
||||
totalAttempts: true,
|
||||
}
|
||||
})
|
||||
}, [showGrades, stats])
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const columnHelper = createColumnHelper<TableData>()
|
||||
const ooditable_columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Time',
|
||||
cell: ctx => (
|
||||
<TimeCell
|
||||
href={showPopulation(ctx.row.original.code)}
|
||||
isEmptyRow={ctx.row.original.totalAttempts === 0}
|
||||
name={ctx.getValue()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
}),
|
||||
columnHelper.accessor('totalAttempts', {
|
||||
header: 'Total attempts',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('passed', {
|
||||
header: 'Passed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('failed', {
|
||||
header: 'Failed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('passRate', {
|
||||
header: 'Pass rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('enrollments', {
|
||||
header: 'Enrollments',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
...getGradeColumns<TableData>(resolveGrades(stats)),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
...commonOptions,
|
||||
columns,
|
||||
data,
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
...defaultOptions.initialState,
|
||||
...commonOptions.initialState,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder: [
|
||||
'name',
|
||||
'totalAttempts',
|
||||
'passed',
|
||||
'failed',
|
||||
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
|
||||
'passRate',
|
||||
'enrollments',
|
||||
],
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
state: { columnVisibility },
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
muiTableBodyCellProps: {
|
||||
...defaultOptions?.muiTableHeadCellProps,
|
||||
...commonOptions?.muiTableBodyCellProps,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ExportToExcelDialog
|
||||
exportColumns={columns}
|
||||
exportData={exportData}
|
||||
featureName={`attempt_statistics_${name}`}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
open={exportModalOpen}
|
||||
<>
|
||||
<OodiTable
|
||||
columns={ooditable_columns}
|
||||
data={data}
|
||||
options={ooditable_table}
|
||||
toolbarContent={
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
|
||||
}
|
||||
/>
|
||||
<MaterialReactTable table={table} />
|
||||
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+109
-117
@@ -1,18 +1,30 @@
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
|
||||
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { FormattedStats } from '@/types/courseStat'
|
||||
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
|
||||
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { ObfuscatedCell } from './ObfuscatedCell'
|
||||
import { TimeCell } from './TimeCell'
|
||||
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
|
||||
const getTableData = (stats: FormattedStats[]) => {
|
||||
type TableData = {
|
||||
name: string
|
||||
code: number
|
||||
totalStudents: number
|
||||
passed: number
|
||||
failed: number
|
||||
enrolledNoGrade?: number
|
||||
passRate: string
|
||||
failRate: string
|
||||
grades: Record<string, number>
|
||||
rowObfuscated?: boolean
|
||||
}
|
||||
|
||||
const getTableData = (stats: FormattedStats[]): TableData[] => {
|
||||
return stats.map(stat => ({
|
||||
name: stat.name,
|
||||
code: stat.code,
|
||||
@@ -23,11 +35,12 @@ const getTableData = (stats: FormattedStats[]) => {
|
||||
passRate: formatPercentage(stat.students.passRate * 100),
|
||||
failRate: formatPercentage(stat.students.failRate * 100),
|
||||
grades: stat.students.grades,
|
||||
rowObfuscated: stat.rowObfuscated,
|
||||
}))
|
||||
}
|
||||
|
||||
export const StudentsTable = ({
|
||||
data: { name, stats },
|
||||
data: { stats },
|
||||
separate,
|
||||
showGrades,
|
||||
userHasAccessToAllStats,
|
||||
@@ -35,7 +48,7 @@ export const StudentsTable = ({
|
||||
openOrRegular,
|
||||
alternatives,
|
||||
}: {
|
||||
data: { name: string; stats: FormattedStats[] }
|
||||
data: ProgrammeStats
|
||||
separate: boolean
|
||||
showGrades: boolean
|
||||
userHasAccessToAllStats: boolean
|
||||
@@ -43,14 +56,10 @@ export const StudentsTable = ({
|
||||
openOrRegular: CourseSearchState
|
||||
alternatives: string[]
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||
|
||||
const showPopulation = useCallback(
|
||||
(yearCode: string) => {
|
||||
(yearCode: number) => {
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
@@ -65,128 +74,111 @@ export const StudentsTable = ({
|
||||
)
|
||||
|
||||
const data = useMemo(() => getTableData(stats), [stats])
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Time',
|
||||
Cell: ({ cell, row }) => (
|
||||
<TimeCell
|
||||
href={showPopulation(row.original.code)}
|
||||
isEmptyRow={row.original.totalStudents === 0}
|
||||
name={cell.getValue<string>()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
},
|
||||
{
|
||||
accessorKey: 'totalStudents',
|
||||
header: 'Total students',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Total students"
|
||||
tooltipText="Total count of students, including enrolled students with no grade"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passed',
|
||||
header: 'Passed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failed',
|
||||
header: 'Failed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
{
|
||||
accessorKey: 'enrolledNoGrade',
|
||||
header: 'Enrolled, no grade',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Enrolled, no grade"
|
||||
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passRate',
|
||||
header: 'Pass rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failRate',
|
||||
header: 'Fail rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
const excelData = useMemo(() => {
|
||||
return data.map(({ grades, ...rest }) => ({
|
||||
...rest,
|
||||
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(prev => {
|
||||
const updatedVisibility: Record<string, boolean> = { ...prev }
|
||||
|
||||
const gradeColumns = resolveGrades(stats).map(({ key }) => `grades.${key}`)
|
||||
|
||||
gradeColumns.forEach(key => {
|
||||
updatedVisibility[key] = showGrades
|
||||
})
|
||||
resolveGrades(stats)
|
||||
.map(({ key }) => `grades.${key}`)
|
||||
.forEach(key => {
|
||||
updatedVisibility[key] = showGrades
|
||||
})
|
||||
|
||||
updatedVisibility.passed = !showGrades
|
||||
updatedVisibility.failed = !showGrades
|
||||
|
||||
return { ...updatedVisibility }
|
||||
return {
|
||||
...updatedVisibility,
|
||||
name: true,
|
||||
totalStudents: true,
|
||||
enrolledNoGrade: true,
|
||||
passRate: true,
|
||||
failRate: true,
|
||||
}
|
||||
})
|
||||
}, [showGrades, stats])
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const columnHelper = createColumnHelper<TableData>()
|
||||
const ooditable_columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Time',
|
||||
cell: ctx => (
|
||||
<TimeCell
|
||||
href={showPopulation(ctx.row.original.code)}
|
||||
isEmptyRow={ctx.row.original.totalStudents === 0}
|
||||
name={ctx.getValue()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
}),
|
||||
columnHelper.accessor('totalStudents', {
|
||||
header: _ => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Total students"
|
||||
tooltipText="Total count of students, including enrolled students with no grade"
|
||||
/>
|
||||
),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('passed', {
|
||||
header: 'Passed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('failed', {
|
||||
header: 'Failed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
...getGradeColumns<TableData>(resolveGrades(stats)),
|
||||
columnHelper.accessor('enrolledNoGrade', {
|
||||
header: _ => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Enrolled, no grade"
|
||||
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
|
||||
/>
|
||||
),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('passRate', {
|
||||
header: 'Pass rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('failRate', {
|
||||
header: 'Fail rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
...commonOptions,
|
||||
columns,
|
||||
data,
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
...defaultOptions.initialState,
|
||||
...commonOptions.initialState,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder: [
|
||||
'name',
|
||||
'totalStudents',
|
||||
'passed',
|
||||
'failed',
|
||||
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
|
||||
'enrolledNoGrade',
|
||||
'passRate',
|
||||
'failRate',
|
||||
],
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
state: { columnVisibility },
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
muiTableBodyCellProps: {
|
||||
...defaultOptions?.muiTableHeadCellProps,
|
||||
...commonOptions?.muiTableBodyCellProps,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ExportToExcelDialog
|
||||
exportColumns={columns}
|
||||
exportData={exportData}
|
||||
featureName={`student_statistics_${name}`}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
open={exportModalOpen}
|
||||
<>
|
||||
<OodiTable
|
||||
columns={ooditable_columns}
|
||||
data={data}
|
||||
options={ooditable_table}
|
||||
toolbarContent={
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
|
||||
}
|
||||
/>
|
||||
<MaterialReactTable table={table} />
|
||||
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-20
@@ -1,3 +1,4 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { flatten } from 'lodash-es'
|
||||
|
||||
import { FAILED_GRADES, NUMERIC_GRADES, OTHER_PASSED_GRADES } from '@/constants/grades'
|
||||
@@ -8,14 +9,15 @@ export const formatPercentage = (rate: number) => {
|
||||
return Number.isNaN(rate) ? '–' : `${rate.toFixed(2)} %`
|
||||
}
|
||||
|
||||
export const getGradeColumns = (grades: { key: string; title: string }[]) => {
|
||||
return grades.map(({ key, title }) => ({
|
||||
export const getGradeColumns = <T extends { grades; rowObfuscated?: boolean }>(
|
||||
grades: { key: string; title: string }[]
|
||||
): ColumnDef<T>[] =>
|
||||
grades.map(({ key, title }) => ({
|
||||
id: `grades.${key}`,
|
||||
accessorFn: row => row.grades[key],
|
||||
header: title,
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : (cell.getValue() ?? 0)),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : (ctx.getValue() ?? 0)),
|
||||
}))
|
||||
}
|
||||
|
||||
const gradesOrder = {
|
||||
0: 0,
|
||||
@@ -73,19 +75,3 @@ export const resolveGrades = (stats: FormattedStats[]) => {
|
||||
const grades = [...new Set(allGrades)]
|
||||
return getSortedGrades(grades)
|
||||
}
|
||||
|
||||
export const commonOptions = {
|
||||
defaultColumn: { size: 0 },
|
||||
enableColumnFilters: false,
|
||||
enableColumnOrdering: false,
|
||||
enableDensityToggle: false,
|
||||
enableGlobalFilter: false,
|
||||
enableHiding: false,
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
showColumnFilters: false,
|
||||
},
|
||||
muiTableBodyCellProps: {
|
||||
align: 'right' as const,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Table from '@mui/material/Table'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { memo } from 'react'
|
||||
|
||||
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DeleteIcon } from '@/theme'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { getActiveYears } from '../util'
|
||||
|
||||
const CourseTable = ({
|
||||
export const CourseTable = ({
|
||||
combineSubstitutions,
|
||||
courses,
|
||||
hidden,
|
||||
onSelectCourse,
|
||||
title,
|
||||
}: {
|
||||
combineSubstitutions: boolean
|
||||
courses: any[]
|
||||
hidden: boolean
|
||||
onSelectCourse: (course: any) => void
|
||||
@@ -29,62 +31,67 @@ const CourseTable = ({
|
||||
|
||||
const getEmptyListRow = () => (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={2}>
|
||||
<TableCell align="center" colSpan={3}>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
const toCourseRow = course => (
|
||||
<TableRow
|
||||
data-cy={`course-${course.code}`}
|
||||
hover
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{getActiveYears(course)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{[course.code, ...course.substitutions].join(', ')}</TableCell>
|
||||
{title === 'Selected courses' && (
|
||||
<TableCell align="right">
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
const toCourseRow = (course: SearchResultCourse) => {
|
||||
return (
|
||||
<TableRow
|
||||
data-cy={`course-${course.code}`}
|
||||
hover
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{getActiveYears(course)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
<TableCell>
|
||||
<Typography fontSize="0.9rem">{course.code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{combineSubstitutions ? (
|
||||
<Stack>
|
||||
{course.substitution_groups?.map(group => <GroupChip group={group} key={group.join(':')} />) ?? (
|
||||
<Typography fontSize="0.9rem">Equivalent groups not available!</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
course.code
|
||||
)}
|
||||
</TableCell>
|
||||
{title === 'Selected courses' && (
|
||||
<TableCell align="right">
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{title}</TableCell>
|
||||
<TableCell>Code(s)</TableCell>
|
||||
{title === 'Selected courses' && <TableCell />}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<StyledTable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{title}</TableCell>
|
||||
<TableCell>Main course code</TableCell>
|
||||
<TableCell>Equivalent groups</TableCell>
|
||||
{title === 'Selected courses' && <TableCell />}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
|
||||
</StyledTable>
|
||||
)
|
||||
}
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
if (prevProps.courses.length !== nextProps.courses.length) {
|
||||
return false
|
||||
}
|
||||
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
|
||||
}
|
||||
|
||||
export const MemoizedCourseTable = memo(CourseTable, areEqual)
|
||||
|
||||
@@ -21,9 +21,10 @@ import { useSearchHistory } from '@/hooks/searchHistory'
|
||||
import { useToggle } from '@/hooks/toggle'
|
||||
import { useGetCourseSearchResultQuery } from '@/redux/courseSearch'
|
||||
import { AddIcon, SearchIcon } from '@/theme'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { SearchHistoryItem } from '@/types/searchHistory'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { MemoizedCourseTable as CourseTable } from './CourseTable'
|
||||
import { CourseTable } from './CourseTable'
|
||||
import { FetchStatisticsButton } from './FetchStatisticsButton'
|
||||
import { MultipleCoursesAlert } from './MultipleCoursesAlert'
|
||||
|
||||
@@ -37,7 +38,7 @@ export const SearchForm = () => {
|
||||
const [courseCode, setCourseCode] = useState('')
|
||||
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
|
||||
const [debouncedCourseCode, setDebouncedCourseCode] = useDebouncedState('')
|
||||
const [selectedCourses, setSelectedCourses] = useState({})
|
||||
const [selectedCourses, setSelectedCourses] = useState<Record<string, SearchResultCourse>>({})
|
||||
|
||||
const isInputValid = validateInputLength(courseName, 5) || validateInputLength(courseCode, 2)
|
||||
const isDebouncedInputValid =
|
||||
@@ -49,7 +50,6 @@ export const SearchForm = () => {
|
||||
{
|
||||
name: debouncedCourseName,
|
||||
code: debouncedCourseCode,
|
||||
combineSubstitutions,
|
||||
includeSpecial: includeSpecialCourses,
|
||||
},
|
||||
{ skip: !isDebouncedInputValid }
|
||||
@@ -66,7 +66,7 @@ export const SearchForm = () => {
|
||||
setDebouncedCourseCode(event.target.value)
|
||||
}
|
||||
|
||||
const onSelectCourse = course => {
|
||||
const onSelectCourse = (course: SearchResultCourse) => {
|
||||
const isSelected = !!selectedCourses[course.code]
|
||||
|
||||
if (isSelected) {
|
||||
@@ -149,7 +149,7 @@ export const SearchForm = () => {
|
||||
const noSelectedCourses = !selected.length
|
||||
|
||||
const addAllCourses = () => {
|
||||
const newSelectedCourses = courses.reduce((newSelected, course) => {
|
||||
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
|
||||
newSelected[course.code] = { ...course }
|
||||
return newSelected
|
||||
}, {})
|
||||
@@ -245,6 +245,7 @@ export const SearchForm = () => {
|
||||
{selected.length > 0 && selectMultipleCourses ? (
|
||||
<Stack gap={2}>
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
courses={selected}
|
||||
hidden={noSelectedCourses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
@@ -259,6 +260,7 @@ export const SearchForm = () => {
|
||||
</Stack>
|
||||
) : null}
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
courses={courses}
|
||||
hidden={false}
|
||||
onSelectCourse={onSelectCourse}
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const InteractiveDataTable = ({
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{titles.map((title, index) => (
|
||||
<TableCell key={title}>
|
||||
<TableCell key={title} sx={{ px: 0, py: 0.5 }}>
|
||||
<Box alignItems="center" display="flex" justifyContent={index === 0 ? 'left' : 'right'}>
|
||||
{index === 0 && (
|
||||
<Box sx={{ marginRight: 1 }}>
|
||||
|
||||
+29
-9
@@ -18,14 +18,24 @@ import {
|
||||
import { CurriculumPicker } from '@/components/common/CurriculumPicker'
|
||||
import { StyledAccordion } from '@/components/common/StyledAccordion'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GetTextIn, useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { ArrowDropDownIcon, CloseIcon, DoneIcon } from '@/theme'
|
||||
import { Absence } from '@/types/students'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { useCurriculumState } from '../../../hooks/useCurriculums'
|
||||
|
||||
const ModuleTable = ({ data, cypress, getTextIn }) => (
|
||||
const ModuleTable = ({
|
||||
data,
|
||||
cypress,
|
||||
getTextIn,
|
||||
}: {
|
||||
data: StudentPageStudent['courses']
|
||||
cypress: string
|
||||
getTextIn: GetTextIn
|
||||
}) => (
|
||||
<StyledTable data-cy={cypress}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -48,7 +58,15 @@ const ModuleTable = ({ data, cypress, getTextIn }) => (
|
||||
</StyledTable>
|
||||
)
|
||||
|
||||
export const BachelorHonours = ({ absentYears, programmeCode, student }) => {
|
||||
export const BachelorHonours = ({
|
||||
absentYears,
|
||||
programmeCode,
|
||||
student,
|
||||
}: {
|
||||
absentYears: Absence[]
|
||||
programmeCode: string
|
||||
student: StudentPageStudent
|
||||
}) => {
|
||||
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(
|
||||
programmeCode,
|
||||
new Date().getFullYear().toString()
|
||||
@@ -60,8 +78,8 @@ export const BachelorHonours = ({ absentYears, programmeCode, student }) => {
|
||||
|
||||
if (!student?.courses || !student?.studyRights) return null
|
||||
|
||||
let studyStartDate
|
||||
let reason
|
||||
let studyStartDate: dayjs.Dayjs
|
||||
let reason = ''
|
||||
let graduated = false
|
||||
let inspection = false
|
||||
let inTime = false
|
||||
@@ -72,8 +90,9 @@ export const BachelorHonours = ({ absentYears, programmeCode, student }) => {
|
||||
|
||||
if (studyRightWithCorrectProgramme) {
|
||||
studyStartDate = dayjs(studyRightWithCorrectProgramme.startDate)
|
||||
graduated = !!studyRightWithCorrectProgramme.studyRightElements.find(element => element.code === programmeCode)
|
||||
.graduated
|
||||
graduated =
|
||||
studyRightWithCorrectProgramme.studyRightElements.find(element => element.code === programmeCode)?.graduated ??
|
||||
false
|
||||
}
|
||||
|
||||
const mandatoryModuleCodes = mandatoryModules ? mandatoryModules.map(mod => mod.code).filter(Boolean) : []
|
||||
@@ -84,7 +103,7 @@ export const BachelorHonours = ({ absentYears, programmeCode, student }) => {
|
||||
intermediateHonoursModules[programmeCode].includes(mod.course.code)
|
||||
)
|
||||
|
||||
const mainModules = []
|
||||
const mainModules: StudentPageStudent['courses'] = []
|
||||
if (degreeModule) mainModules.push(degreeModule)
|
||||
if (basicModules.length) mainModules.push(...basicModules)
|
||||
if (intermediateModules.length) mainModules.push(...intermediateModules)
|
||||
@@ -96,7 +115,8 @@ export const BachelorHonours = ({ absentYears, programmeCode, student }) => {
|
||||
|
||||
if (degreeModule) {
|
||||
const graduationDate = dayjs(degreeModule.date)
|
||||
const yearsForGraduation = graduationDate.diff(studyStartDate, 'years', true)
|
||||
// HACK: StudyStartDate should never be null even though db allows it
|
||||
const yearsForGraduation = graduationDate.diff(studyStartDate!, 'years', true)
|
||||
|
||||
// calculate time student has been absent during bachelors degree
|
||||
const timeAbsent = absentYears.reduce((acc, curr) => {
|
||||
+30
-11
@@ -64,8 +64,21 @@ export const OverallStatsTable = ({
|
||||
columnHelper.accessor('degreeStudentsCredits', {
|
||||
header: 'Degree students',
|
||||
}),
|
||||
columnHelper.accessor('openStudentsCredits', {
|
||||
header: 'Open university',
|
||||
columnHelper.accessor('openStudentsHasHetuCredits', {
|
||||
header: () => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Open university (hetu)"
|
||||
tooltipText="Credits produced by open university students with a Finnish personal identity code (hetu)"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('openStudentsNoHetuCredits', {
|
||||
header: () => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Open university (no hetu)"
|
||||
tooltipText="Credits produced by open university students who do not have a Finnish personal identity code (hetu)"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('exchangeStudentsCredits', {
|
||||
header: 'Exchange students',
|
||||
@@ -114,8 +127,21 @@ export const OverallStatsTable = ({
|
||||
columnHelper.accessor('degreeStudents', {
|
||||
header: 'Degree students',
|
||||
}),
|
||||
columnHelper.accessor('openStudents', {
|
||||
header: 'Open uni students',
|
||||
columnHelper.accessor('openStudentsHasHetu', {
|
||||
header: () => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Open university students (with hetu)"
|
||||
tooltipText="hetu = Finnish personal identity code"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('openStudentsNoHetu', {
|
||||
header: () => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Open university students (without hetu)"
|
||||
tooltipText="hetu = Finnish personal identity code"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('exchangeStudents', {
|
||||
header: 'Exchange students',
|
||||
@@ -147,12 +173,6 @@ export const OverallStatsTable = ({
|
||||
[showStudents]
|
||||
) as ColumnDef<FilteredColumnData>[]
|
||||
|
||||
const options = {
|
||||
state: {
|
||||
useZebrastripes: false,
|
||||
},
|
||||
}
|
||||
|
||||
const columnKeys = useMemo(() => {
|
||||
const squashGroups = column => {
|
||||
if (column.columns) return column.columns.flatMap(squashGroups)
|
||||
@@ -166,7 +186,6 @@ export const OverallStatsTable = ({
|
||||
columns={columns}
|
||||
cy="overall-stats-table"
|
||||
data={filteredData}
|
||||
options={options}
|
||||
toolbarContent={<OodiTableExcelExport data={filteredData} exportColumnKeys={columnKeys} />}
|
||||
/>
|
||||
)
|
||||
|
||||
+5
-2
@@ -36,8 +36,11 @@ export const filterDataByYear = (
|
||||
acc.separateStudents += curr[1].separateStudents
|
||||
acc.separateStudentsCredits += curr[1].separateStudentsCredits
|
||||
|
||||
acc.openStudents += curr[1].openStudents
|
||||
acc.openStudentsCredits += curr[1].openStudentsCredits
|
||||
acc.openStudentsHasHetu += curr[1].openStudentsHasHetu
|
||||
acc.openStudentsHasHetuCredits += curr[1].openStudentsHasHetuCredits
|
||||
|
||||
acc.openStudentsNoHetu += curr[1].openStudentsNoHetu
|
||||
acc.openStudentsNoHetuCredits += curr[1].openStudentsNoHetuCredits
|
||||
}
|
||||
|
||||
return acc
|
||||
|
||||
+54
-79
@@ -1,11 +1,13 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Link } from '@/components/common/Link'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useAddStudyProgrammePinMutation, useRemoveStudyProgrammePinMutation } from '@/redux/studyProgrammePins'
|
||||
@@ -29,81 +31,6 @@ export const StudyProgrammeTable = ({
|
||||
const [addStudyProgrammePins] = useAddStudyProgrammePinMutation()
|
||||
const [removeStudyProgrammePins] = useRemoveStudyProgrammePinMutation()
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Code',
|
||||
size: 160,
|
||||
},
|
||||
{
|
||||
accessorKey: 'progId',
|
||||
header: 'Id',
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
Cell: ({ row }) => <Link to={`/study-programme/${row.original.code}`}>{getTextIn(row.original.name)}</Link>,
|
||||
minSize: 490,
|
||||
},
|
||||
{
|
||||
id: 'pin',
|
||||
header: 'Pin',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Pin"
|
||||
tooltipText={`
|
||||
Click the pin icon to pin/unpin a programme.
|
||||
Pinned programmes are shown first here and in the degree programme search in Class statistics.
|
||||
`}
|
||||
/>
|
||||
),
|
||||
Cell: ({ row }) => {
|
||||
const isPinned = pinnedProgrammes.includes(row.original.code)
|
||||
return (
|
||||
<IconButton
|
||||
data-cy={`pin-programme-${row.original.code}-button`}
|
||||
onClick={
|
||||
isPinned
|
||||
? () => void removeStudyProgrammePins({ programmeCode: row.original.code })
|
||||
: () => void addStudyProgrammePins({ programmeCode: row.original.code })
|
||||
}
|
||||
sx={{
|
||||
color: theme =>
|
||||
isPinned ? theme.palette.studyProgrammePin.pinned : theme.palette.studyProgrammePin.unpinned,
|
||||
}}
|
||||
>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
},
|
||||
size: 100,
|
||||
muiTableHeadCellProps: {
|
||||
align: 'center',
|
||||
},
|
||||
muiTableBodyCellProps: {
|
||||
align: 'center',
|
||||
},
|
||||
},
|
||||
],
|
||||
[addStudyProgrammePins, getTextIn, pinnedProgrammes, removeStudyProgrammePins]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: programmes,
|
||||
enableBottomToolbar: false,
|
||||
enableColumnActions: false,
|
||||
enablePagination: false,
|
||||
enableSorting: false,
|
||||
enableTopToolbar: false,
|
||||
layoutMode: 'grid',
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
})
|
||||
|
||||
if (!visible || programmes == null || programmes.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -113,7 +40,55 @@ export const StudyProgrammeTable = ({
|
||||
<Typography component="h3" variant="h6">
|
||||
{header}
|
||||
</Typography>
|
||||
<MaterialReactTable table={table} />
|
||||
<StyledTable slimBody slimHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 100 }}>Code</TableCell>
|
||||
<TableCell sx={{ width: 100 }}>Id</TableCell>
|
||||
<TableCell sx={{ minWidth: 160 }}>Name</TableCell>
|
||||
<TableCell sx={{ width: 80 }}>
|
||||
<TableHeaderWithTooltip
|
||||
header="Pin"
|
||||
tooltipText={`
|
||||
Click the pin icon to pin/unpin a programme.
|
||||
Pinned programmes are shown first here and in the degree programme search in Class statistics.
|
||||
`}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{programmes.map(programme => (
|
||||
<TableRow key={programme?.code}>
|
||||
<TableCell>{programme?.code}</TableCell>
|
||||
<TableCell>{programme?.progId}</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/study-programme/${programme?.code}`}>{getTextIn(programme?.name)}</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
<IconButton
|
||||
data-cy={`pin-programme-${programme?.code}-button`}
|
||||
onClick={
|
||||
pinnedProgrammes.includes(programme?.code)
|
||||
? () => void removeStudyProgrammePins({ programmeCode: programme?.code })
|
||||
: () => void addStudyProgrammePins({ programmeCode: programme?.code })
|
||||
}
|
||||
sx={{
|
||||
color: theme =>
|
||||
pinnedProgrammes.includes(programme?.code)
|
||||
? theme.palette.studyProgrammePin.pinned
|
||||
: theme.palette.studyProgrammePin.unpinned,
|
||||
}}
|
||||
>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { GetCourseSearchResultRequest, GetCourseSearchResultResponse } from '@/t
|
||||
const courseSearchApi = RTKApi.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getCourseSearchResult: builder.query<GetCourseSearchResultResponse, GetCourseSearchResultRequest>({
|
||||
query: ({ name, code, combineSubstitutions, includeSpecial }) => ({
|
||||
query: ({ name, code, includeSpecial }) => ({
|
||||
url: '/v2/coursesmulti',
|
||||
params: { name, code, combineSubstitutions, includeSpecial },
|
||||
params: { name, code, includeSpecial },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setupListeners } from '@reduxjs/toolkit/query/react'
|
||||
import { configureStore } from '@reduxjs/toolkit/react'
|
||||
|
||||
import { handleRequest, RTKApi } from '@/apiConnection'
|
||||
import { RTKApi } from '@/apiConnection'
|
||||
import { isDev } from '@/conf'
|
||||
import { actionHistoryMiddleware } from './actionHistory'
|
||||
import { reducer as filters } from './filters'
|
||||
@@ -22,7 +22,6 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }).concat(
|
||||
RTKApi.middleware,
|
||||
handleRequest,
|
||||
actionHistoryMiddleware
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { Unarray } from '@oodikone/shared/types'
|
||||
import { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
|
||||
export type SearchResultCourse = Unarray<GetCourseSearchResultResponse['courses']>
|
||||
|
||||
export type GetCourseSearchResultResponse = {
|
||||
courses: Array<{
|
||||
code: string
|
||||
course_unit_type: string
|
||||
coursetypecode: string
|
||||
createdAt: string
|
||||
id: string
|
||||
is_study_module: boolean
|
||||
mainCourseCode: string | null
|
||||
max_attainment_date: string
|
||||
min_attainment_date: string
|
||||
name: Name
|
||||
subsId: number
|
||||
substitutions: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
courses: Array<Omit<CourseWithSubsId, 'courseType' | 'credits' | 'enrollments' | 'organizations'>>
|
||||
}
|
||||
|
||||
export type GetCourseSearchResultRequest = {
|
||||
|
||||
@@ -24,7 +24,8 @@ export const makeGraphData = (data: Record<string, Record<string, number>> | und
|
||||
agreement: 'Other university',
|
||||
separate: 'Separate studies',
|
||||
basic: 'Degree students',
|
||||
'open-uni': 'Open university',
|
||||
'open-uni-hetu': 'Open university (hetu)',
|
||||
'open-uni-no-hetu': 'Open university (no hetu)',
|
||||
transferred: 'Transferred',
|
||||
'incoming-exchange': 'Exchange students',
|
||||
other: 'Other',
|
||||
@@ -58,20 +59,22 @@ export const makeTableStats = (data: Record<string, Record<string, number>> | un
|
||||
for (let year = currentYear - (isAcademicYear && new Date().getMonth() < 7 ? 1 : 0); year >= 2017; year--) {
|
||||
const yearData = data[getFormattedYear(year, isAcademicYear)]
|
||||
const basic = yearData?.basic || 0
|
||||
const openUni = yearData?.['open-uni'] || 0
|
||||
const openUniHetu = yearData?.['open-uni-hetu'] || 0
|
||||
const openUniNoHetu = yearData?.['open-uni-no-hetu'] || 0
|
||||
const exchange = yearData?.['incoming-exchange'] || 0
|
||||
const separate = yearData?.separate || 0
|
||||
const agreement = yearData?.agreement || 0
|
||||
const transferred = yearData?.transferred || 0
|
||||
const other = yearData?.other || 0
|
||||
const total = basic + openUni + exchange + separate + agreement
|
||||
const total = basic + openUniHetu + openUniNoHetu + exchange + separate + agreement
|
||||
|
||||
const yearStats = [
|
||||
getFormattedYear(year, isAcademicYear),
|
||||
Math.round(total),
|
||||
Math.round(basic),
|
||||
Math.round(exchange),
|
||||
Math.round(openUni),
|
||||
Math.round(openUniHetu),
|
||||
Math.round(openUniNoHetu),
|
||||
Math.round(agreement),
|
||||
Math.round(separate),
|
||||
Math.round(transferred),
|
||||
@@ -83,10 +86,11 @@ export const makeTableStats = (data: Record<string, Record<string, number>> | un
|
||||
const titles = [
|
||||
'Code',
|
||||
'Total',
|
||||
'Degree students',
|
||||
'Exchange students',
|
||||
'Open university',
|
||||
'Other university',
|
||||
'Degree',
|
||||
'Exchange',
|
||||
'Open (hetu)',
|
||||
'Open (no hetu)',
|
||||
'Other univ',
|
||||
'Separate',
|
||||
'Transferred',
|
||||
'Other',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtentCode, SemesterEnrollment } from '../types'
|
||||
import type { ExtentCode, SemesterEnrollment, TransferInfo } from '../types'
|
||||
|
||||
import type { Credit } from './credit'
|
||||
import type { Enrollment } from './enrollment'
|
||||
@@ -22,6 +22,7 @@ export type SISStudyRight = {
|
||||
extentCode: ExtentCode
|
||||
admissionType: string
|
||||
semesterEnrollments: SemesterEnrollment[] | null
|
||||
transferInfo: TransferInfo | null
|
||||
credits: Credit[]
|
||||
enrollments: Enrollment[]
|
||||
tvex: boolean
|
||||
|
||||
Generated
+18
@@ -6,9 +6,20 @@
|
||||
"": {
|
||||
"name": "@oodikone/shared",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -22,6 +33,13 @@
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../types'
|
||||
import { FormattedStudent } from '../types/studentData'
|
||||
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'substitutions' | 'is_study_module'>
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'is_study_module' | 'substitution_groups'>
|
||||
|
||||
export type AttainmentDates = {
|
||||
latestTotal?: Date
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"sourceMap": true,
|
||||
"sourceRoot": "/",
|
||||
"inlineSources": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export type { SemesterEnrollment } from './semesterEnrollment'
|
||||
export type { StudyTrack } from './studyTrack'
|
||||
export type { UnifyStatus } from './unification'
|
||||
export type { FormattedStudent } from './studentData'
|
||||
export type { TransferInfo } from './transferInfo'
|
||||
|
||||
// ENUMS
|
||||
export { CreditTypeCode } from './creditTypeCode'
|
||||
|
||||
@@ -19,6 +19,7 @@ type StudentPersonalData = Pick<
|
||||
| 'gender_code'
|
||||
| 'birthdate'
|
||||
| 'sis_person_id'
|
||||
| 'hasPersonalIdentityCode'
|
||||
>
|
||||
|
||||
export type StudentStudyRightElement = Pick<
|
||||
@@ -135,6 +136,7 @@ export type FormattedStudent = {
|
||||
citizenships: Name[]
|
||||
criteriaProgress: Record<string, CriteriaYear>
|
||||
curriculumVersion: string | null
|
||||
hasPersonalIdentityCode: boolean
|
||||
|
||||
transferredStudyright: boolean
|
||||
transferSource: string | undefined
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type TransferInfo = {
|
||||
originalStartDate: Date
|
||||
usedTerms?: number
|
||||
usedAbsenceTerms?: number
|
||||
usedStatutoryAbsenceTerms?: number
|
||||
originalUniversityUrn?: string
|
||||
transferComments?: string
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Result } from '../util'
|
||||
|
||||
type Params = Record<string, string> | string
|
||||
|
||||
// eslint-disable-next-line import-x/no-unused-modules
|
||||
export type BaseConfig = { baseUrl?: string; params?: Params; timeout?: number } & RequestInit
|
||||
|
||||
// eslint-disable-next-line import-x/no-unused-modules
|
||||
export type RequestConfig = { params?: Params; timeout?: number } & RequestInit
|
||||
|
||||
const buildBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
return new URL(baseUrl)
|
||||
} catch (_) {
|
||||
// Invalid URLs will be caught here if the baseUrl cannot be extended on top of page origin.
|
||||
return new URL(baseUrl, globalThis?.location.origin)
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = (
|
||||
baseUrl: string | undefined = '',
|
||||
url: string | undefined = '',
|
||||
baseParams?: Params,
|
||||
params?: Params
|
||||
) => {
|
||||
const base = baseUrl ? buildBaseUrl(url ? baseUrl.replace(/\/?$/, '/') : baseUrl) : ''
|
||||
const path = new URL(url.replace(/^\//, ''), base)
|
||||
|
||||
const baseUrlParams = new URLSearchParams(baseParams)
|
||||
const urlParams = new URLSearchParams(params)
|
||||
|
||||
Array.from(baseUrlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
|
||||
Array.from(urlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
|
||||
|
||||
return path.href
|
||||
}
|
||||
|
||||
const buildSignal = (timeout: number | undefined): AbortSignal | undefined =>
|
||||
timeout !== undefined ? AbortSignal.timeout(timeout) : undefined
|
||||
|
||||
const prepData = <T>(data: T) => JSON.stringify(data)
|
||||
|
||||
// We only really use JSON in Oodikone, so I didn't fell like implementing this function.
|
||||
const getMimeType = <T>(_: T) => {
|
||||
return 'application/json'
|
||||
}
|
||||
|
||||
const convertDataBasedOnCT = <R>(contentType: string, res: Response): Promise<R> => {
|
||||
// Should be the most common type, as express and JAMI return JSON
|
||||
if (contentType.startsWith('application/json')) return res.json() as Promise<R>
|
||||
else if (contentType.startsWith('application/octet-stream')) return res.arrayBuffer() as Promise<R>
|
||||
else if (contentType.startsWith('multipart/form-data')) return res.formData() as Promise<R>
|
||||
else if (contentType.startsWith('text/')) return res.text() as Promise<R>
|
||||
else return res.blob() as Promise<R>
|
||||
}
|
||||
|
||||
const handleRequestData = <R>(url: string, options: RequestInit): Promise<Result<R>> =>
|
||||
globalThis
|
||||
.fetch(url, options)
|
||||
.then(async res => {
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
|
||||
return {
|
||||
...res,
|
||||
data: await convertDataBasedOnCT<R>(ct, res),
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
.catch(error => ({
|
||||
data: null,
|
||||
error,
|
||||
}))
|
||||
|
||||
const fetcher = <T>({
|
||||
baseUrl,
|
||||
params: baseParams,
|
||||
timeout: baseTimeout,
|
||||
headers: baseHeaders,
|
||||
...baseRest
|
||||
}: BaseConfig) => ({
|
||||
get: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'GET',
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
post: <R>(
|
||||
url: string | undefined,
|
||||
data: T,
|
||||
{ params, headers, timeout, ...rest }: RequestConfig
|
||||
): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
'Content-Type': getMimeType(data),
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'POST',
|
||||
body: prepData(data),
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
put: <R>(
|
||||
url: string | undefined,
|
||||
data: T,
|
||||
{ params, headers, timeout, ...rest }: RequestConfig
|
||||
): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
'Content-Type': getMimeType(data),
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'PUT',
|
||||
body: prepData(data),
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
delete: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'DELETE',
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Fetchios = {
|
||||
create: <R>(config: BaseConfig) => fetcher<R>(config),
|
||||
get: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<R>({ baseUrl: url }).get(undefined, config),
|
||||
post: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<T>({ baseUrl: url }).post<R>(undefined, data, config),
|
||||
put: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<T>({ baseUrl: url }).put<R>(undefined, data, config),
|
||||
delete: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<R>({ baseUrl: url }).delete(undefined, config),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type Err<E> = {
|
||||
error: E
|
||||
}
|
||||
|
||||
type Result<T, E = Error> = Ok<T> | Err<E>
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>
|
||||
|
||||
/**
|
||||
* @returns data, error object with distinct values.
|
||||
@@ -72,5 +72,3 @@ export const mapValues = <T extends object, K extends keyof T, R>(input: T, f: (
|
||||
}
|
||||
|
||||
export const splitByEmptySpace = (str: string) => str.split(/\s+/g)
|
||||
|
||||
export const uniq = <T>(data: T[]): T[] => [...new Set(data)]
|
||||
|
||||
@@ -15,8 +15,10 @@ export const createEmptyStats = (isStudyModule = false): YearStats => ({
|
||||
otherUniversityCredits: 0,
|
||||
otherStudents: 0,
|
||||
otherStudentsCredits: 0,
|
||||
openStudents: 0,
|
||||
openStudentsCredits: 0,
|
||||
openStudentsHasHetu: 0,
|
||||
openStudentsHasHetuCredits: 0,
|
||||
openStudentsNoHetu: 0,
|
||||
openStudentsNoHetuCredits: 0,
|
||||
separateStudents: 0,
|
||||
separateStudentsCredits: 0,
|
||||
transferStudents: 0,
|
||||
@@ -37,8 +39,10 @@ export const yearStatNumberKeys = [
|
||||
'otherUniversityCredits',
|
||||
'otherStudents',
|
||||
'otherStudentsCredits',
|
||||
'openStudents',
|
||||
'openStudentsCredits',
|
||||
'openStudentsHasHetu',
|
||||
'openStudentsHasHetuCredits',
|
||||
'openStudentsNoHetu',
|
||||
'openStudentsNoHetuCredits',
|
||||
'separateStudents',
|
||||
'separateStudentsCredits',
|
||||
'transferStudents',
|
||||
|
||||
@@ -82,7 +82,7 @@ const scheduleMeta = async (clean = true) => {
|
||||
table: IMPORTER_TABLES.courseUnits,
|
||||
distinct: 'group_id',
|
||||
pluck: 'group_id',
|
||||
limit: isDev ? DEV_SCHEDULE_COUNT : null,
|
||||
limit: null,
|
||||
clean,
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ const scheduleMeta = async (clean = true) => {
|
||||
whereIn: ['type', ['StudyModule', 'DegreeProgramme']],
|
||||
distinct: 'group_id',
|
||||
pluck: 'group_id',
|
||||
limit: isDev ? DEV_SCHEDULE_COUNT : null,
|
||||
limit: null,
|
||||
clean,
|
||||
})
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ class DbConnections extends EventEmitter {
|
||||
logger,
|
||||
})
|
||||
|
||||
const migrations = (await migrator.up()).map(m => m.name)
|
||||
logger.info({ message: 'Migrations up to date', meta: migrations })
|
||||
const newMigrations = (await migrator.up()).map(m => m.name)
|
||||
const executedMigrations = await migrator.executed()
|
||||
logger.info({ message: 'Migrations up to date', meta: newMigrations, latest: executedMigrations.at(-1)?.name })
|
||||
} catch (error) {
|
||||
logger.error({ message: 'Migration error', meta: JSON.stringify(error) })
|
||||
throw error
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
const { JSONB } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.addColumn('sis_study_rights', 'transfer_info', JSONB)
|
||||
},
|
||||
down: async queryInterface => {
|
||||
await queryInterface.removeColumn('sis_study_rights', 'transfer_info')
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,7 @@ SISStudyRight.init(
|
||||
extentCode: INTEGER,
|
||||
admissionType: STRING,
|
||||
semesterEnrollments: JSONB,
|
||||
transferInfo: JSONB,
|
||||
facultyCode: STRING,
|
||||
expirationRuleUrns: ARRAY(STRING),
|
||||
tvex: BOOLEAN,
|
||||
|
||||
@@ -238,40 +238,42 @@ export const courseProviderMapper =
|
||||
|
||||
const timify = date => new Date(date).getTime()
|
||||
|
||||
export const courseMapper = courseIdToAttainments => (groupedCourse, substitutions) => {
|
||||
const [groupId, courses] = groupedCourse
|
||||
const { code, name, study_level: coursetypecode, course_unit_type } = courses[0]
|
||||
export const courseMapper =
|
||||
courseIdToAttainments =>
|
||||
([groupId, courses], substitutions, substitution_groups) => {
|
||||
const { code, name, study_level: coursetypecode, course_unit_type } = courses[0]
|
||||
|
||||
const { min_attainment_date, max_attainment_date } = courses.reduce(
|
||||
(res, curr) => {
|
||||
const courseAttainments = courseIdToAttainments[curr.id]
|
||||
if (!courseAttainments || courseAttainments.length === 0) return res
|
||||
let { min_attainment_date, max_attainment_date } = res
|
||||
if (!min_attainment_date || timify(min_attainment_date) > timify(courseAttainments[0].attainment_date))
|
||||
min_attainment_date = courseAttainments[0].attainment_date
|
||||
if (
|
||||
!max_attainment_date ||
|
||||
timify(max_attainment_date) < timify(courseAttainments[courseAttainments.length - 1].attainment_date)
|
||||
)
|
||||
max_attainment_date = courseAttainments[courseAttainments.length - 1].attainment_date
|
||||
const { min_attainment_date, max_attainment_date } = courses.reduce(
|
||||
(res, curr) => {
|
||||
const courseAttainments = courseIdToAttainments[curr.id]
|
||||
if (!courseAttainments || courseAttainments.length === 0) return res
|
||||
let { min_attainment_date, max_attainment_date } = res
|
||||
if (!min_attainment_date || timify(min_attainment_date) > timify(courseAttainments[0].attainment_date))
|
||||
min_attainment_date = courseAttainments[0].attainment_date
|
||||
if (
|
||||
!max_attainment_date ||
|
||||
timify(max_attainment_date) < timify(courseAttainments[courseAttainments.length - 1].attainment_date)
|
||||
)
|
||||
max_attainment_date = courseAttainments[courseAttainments.length - 1].attainment_date
|
||||
|
||||
return { min_attainment_date, max_attainment_date }
|
||||
},
|
||||
{ min_attainment_date: null, max_attainment_date: null }
|
||||
)
|
||||
return { min_attainment_date, max_attainment_date }
|
||||
},
|
||||
{ min_attainment_date: null, max_attainment_date: null }
|
||||
)
|
||||
|
||||
return {
|
||||
id: groupId,
|
||||
name,
|
||||
code,
|
||||
coursetypecode,
|
||||
min_attainment_date,
|
||||
max_attainment_date,
|
||||
is_study_module: course_unit_type == null, // Only course units have a course_unit_type so if it's null, it must be a study module
|
||||
substitutions,
|
||||
course_unit_type,
|
||||
return {
|
||||
id: groupId,
|
||||
name,
|
||||
code,
|
||||
coursetypecode,
|
||||
min_attainment_date,
|
||||
max_attainment_date,
|
||||
is_study_module: course_unit_type == null, // Only course units have a course_unit_type so if it's null, it must be a study module
|
||||
substitutions,
|
||||
substitution_groups,
|
||||
course_unit_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mapCourseType = studyLevel => ({
|
||||
coursetypecode: studyLevel.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { uniqBy, flatten, groupBy, memoize } from 'lodash-es'
|
||||
import { Op } from 'sequelize'
|
||||
import { rootOrgId } from '../config.js'
|
||||
import { bulkCreate, selectFromByIdsOrderBy } from '../db/index.js'
|
||||
import {
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Organization,
|
||||
StudyrightExtent,
|
||||
} from '../db/models/index.js'
|
||||
import logger from '../utils/logger.js'
|
||||
import {
|
||||
courseMapper,
|
||||
courseProviderMapper,
|
||||
@@ -50,11 +50,15 @@ const getSubstitutionPriority = memoize(code => {
|
||||
|
||||
const updateCourses = async (courseIdToAttainments, groupIdToCourse) => {
|
||||
const courseProviders = []
|
||||
const mapCourse = courseMapper(courseIdToAttainments)
|
||||
|
||||
const courses = Object.entries(groupIdToCourse).map(groupedCourse => {
|
||||
const [groupId, courses] = groupedCourse
|
||||
const courses = Object.entries(groupIdToCourse).map(([groupId, courses]) => {
|
||||
// Take substitutions from all course units
|
||||
// NOTE: Modules DO NOT have substitutions fields.
|
||||
const substitutionArrays = courses.flatMap(
|
||||
course => course.substitutions?.map(subGroup => subGroup.map(sub => sub.courseUnitGroupId)) ?? []
|
||||
)
|
||||
const uniqueSubstitutionArrays = uniqBy(substitutionArrays, a => [...a].sort().join('|'))
|
||||
|
||||
// TODO: Replace this old implementation of substitutions
|
||||
// Take substitutions from all course units
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
@@ -66,47 +70,34 @@ const updateCourses = async (courseIdToAttainments, groupIdToCourse) => {
|
||||
|
||||
const organisationsById = {}
|
||||
|
||||
for (const { organisations: courseUnitOrganisations, validity_period: courseUnitValidityPeriod } of courses) {
|
||||
if (!courseUnitOrganisations) {
|
||||
continue
|
||||
}
|
||||
for (const { organisations, validity_period: courseValidityPeriod } of courses) {
|
||||
if (!organisations) continue
|
||||
for (const { share, organisationId, roleUrn, validityPeriod } of organisations) {
|
||||
const { startDate, endDate } = validityPeriod ?? courseValidityPeriod ?? {}
|
||||
|
||||
for (const { share, organisationId, roleUrn, validityPeriod = {} } of courseUnitOrganisations) {
|
||||
try {
|
||||
const effectiveValidityPeriod = Object.keys(validityPeriod).length ? validityPeriod : courseUnitValidityPeriod
|
||||
const shareObj = {
|
||||
share,
|
||||
...(effectiveValidityPeriod?.startDate && { startDate: effectiveValidityPeriod.startDate }),
|
||||
...(effectiveValidityPeriod?.endDate && { endDate: effectiveValidityPeriod.endDate }),
|
||||
}
|
||||
|
||||
if (!organisationsById[organisationId]) {
|
||||
organisationsById[organisationId] = { organisationId, roleUrn, shares: [shareObj] }
|
||||
} else {
|
||||
organisationsById[organisationId].shares.push(shareObj)
|
||||
}
|
||||
} catch (error) {
|
||||
// This try-catch-clause added because of a lot of failed updates due to these lines of code.
|
||||
// If the null check for effectiveValidityPeriod fixes this, this try-clause might be removed
|
||||
logger.error(`Error in course unit organisation handling for orgId ${organisationId} with error`, error)
|
||||
}
|
||||
organisationsById[organisationId] ??= { organisationId, roleUrn, shares: [] }
|
||||
organisationsById[organisationId].shares.push({
|
||||
share,
|
||||
...(startDate && { startDate }),
|
||||
...(endDate && { endDate }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const organisations = Object.values(organisationsById)
|
||||
|
||||
const mapCourseProvider = courseProviderMapper(groupId)
|
||||
courseProviders.push(
|
||||
...organisations
|
||||
.filter(({ roleUrn }) => roleUrn === 'urn:code:organisation-role:responsible-organisation')
|
||||
.map(mapCourseProvider)
|
||||
)
|
||||
return mapCourse(groupedCourse, substitutions)
|
||||
const organisations = Object.values(organisationsById)
|
||||
.filter(({ roleUrn }) => roleUrn === 'urn:code:organisation-role:responsible-organisation')
|
||||
.map(mapCourseProvider)
|
||||
|
||||
courseProviders.push(...organisations)
|
||||
|
||||
// TODO: Remove OLD substitutions when done
|
||||
return courseMapper(courseIdToAttainments)([groupId, courses], substitutions, uniqueSubstitutionArrays)
|
||||
})
|
||||
|
||||
// change substitutions ids to course codes and update
|
||||
|
||||
for (const course of courses) {
|
||||
// TODO: OLD substitutions
|
||||
const newSubstitutions = []
|
||||
for (const sub of course.substitutions) {
|
||||
const subs = await Course.findOne({
|
||||
@@ -119,11 +110,29 @@ const updateCourses = async (courseIdToAttainments, groupIdToCourse) => {
|
||||
|
||||
course.substitutions = newSubstitutions
|
||||
|
||||
const sortedSubstitutions = [course.code, ...course.substitutions]
|
||||
.map(code => [code, getSubstitutionPriority(code)])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
const subtitutionSet = new Set()
|
||||
for (const subGroup of course.substitution_groups) {
|
||||
for (const sub of subGroup) subtitutionSet.add(sub)
|
||||
}
|
||||
|
||||
course.mainCourseCode = sortedSubstitutions[0][0]
|
||||
const idToCodePairs = (
|
||||
await Course.findAll({
|
||||
attributes: ['id', 'code'],
|
||||
where: {
|
||||
id: { [Op.in]: Array.from(subtitutionSet) },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(({ id, code }) => [id, code])
|
||||
const idToCodeMap = new Map(idToCodePairs)
|
||||
|
||||
course.substitution_groups = course.substitution_groups
|
||||
.map(subGroup => subGroup.map(id => idToCodeMap.get(id) ?? null))
|
||||
.map(subGroup => subGroup.filter(substitution_groups => !!substitution_groups)) // Filter out undefined/null entries
|
||||
|
||||
course.mainCourseCode = [course.code, ...course.substitution_groups]
|
||||
.sort((a, b) => getSubstitutionPriority(b) - getSubstitutionPriority(a))
|
||||
.at(0)
|
||||
}
|
||||
|
||||
await bulkCreate(Course, courses)
|
||||
|
||||
@@ -63,6 +63,7 @@ const studyRightMapper = (personIdToStudentNumber, admissionNamesById, semesterE
|
||||
extentCode,
|
||||
admissionType: admissionNamesById[studyRight.admission_type_urn],
|
||||
semesterEnrollments: getStudyRightSemesterEnrollments(semesterEnrollmentsForStudyRight),
|
||||
transferInfo: studyRight.study_right_transfer,
|
||||
facultyCode: getOrganisationCode(studyRight.organisation_id),
|
||||
expirationRuleUrns: studyRight.study_right_expiration_rules_urn,
|
||||
tvex: !!studyRight.code_urns?.includes(TVEX_URN_CODE),
|
||||
|
||||
Reference in New Issue
Block a user