mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2abda1de50 | ||
|
|
333944d377 | ||
|
|
50c301fe82 | ||
|
|
35ea22e125 | ||
|
|
c3c7b5b479 | ||
|
|
b6494f88d0 | ||
|
|
87bc8b24a9 | ||
|
|
e08d99c801 | ||
|
|
b5f7a8bd86 | ||
|
|
7d7d71b6be | ||
|
|
c1bacd11cc | ||
|
|
110994ea46 | ||
|
|
11bde27811 | ||
|
|
0f4ef4a3cd | ||
|
|
a3bb84e169 | ||
|
|
915d5c097c | ||
|
|
523f6aee3e | ||
|
|
e874454e40 | ||
|
|
45289702d5 | ||
|
|
1ab6366fd1 | ||
|
|
a01b06b8bc | ||
|
|
11dfa4c6f1 | ||
|
|
b94e4fc25d | ||
|
|
c1bb44bd55 | ||
|
|
1d8f92c663 | ||
|
|
b5ee1f2190 | ||
|
|
c5649e9d39 | ||
|
|
2a979af974 | ||
|
|
06964526dc | ||
|
|
b2b471dab5 | ||
|
|
15b9551f7a | ||
|
|
c6f4e6066b | ||
|
|
7ebb60e564 | ||
|
|
b641cf9ca6 | ||
|
|
686697f202 | ||
|
|
296299e635 | ||
|
|
c8b8b033ed | ||
|
|
f64f34b971 | ||
|
|
9b4f689dd3 | ||
|
|
cd32860300 | ||
|
|
7ed58eb38a | ||
|
|
291082e6c3 | ||
|
|
54f363be6e | ||
|
|
539ed1e3b8 | ||
|
|
9d61b50db5 | ||
|
|
8ede9de315 | ||
|
|
1cb43cee6b | ||
|
|
b2617c8933 | ||
|
|
beb1130050 | ||
|
|
1c75204854 | ||
|
|
80bba6e0fc | ||
|
|
47b0dabe75 | ||
|
|
da45c8c150 | ||
|
|
8fd7383217 | ||
|
|
baa6d87c2c | ||
|
|
5ae4c8c7a0 | ||
|
|
cbb7e82d23 | ||
|
|
2768e95920 | ||
|
|
5b7d641ad1 | ||
|
|
37a71d7580 | ||
|
|
aaa7b5de27 | ||
|
|
40bcbc59dc | ||
|
|
5e46ce0cb0 | ||
|
|
517c08091e | ||
|
|
e25dfb2a36 | ||
|
|
aa4e0abb44 | ||
|
|
3e5dc9a9f0 | ||
|
|
afd9541151 | ||
|
|
a5ab3b972c | ||
|
|
4f3350e4f9 | ||
|
|
2b8e1ed98f |
@@ -8,7 +8,6 @@ on:
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
- 'services/**'
|
||||
- '!services/backend/src/rapodiff/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'package*.json'
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
- 'services/**'
|
||||
- '!services/backend/src/rapodiff/**'
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'package*.json'
|
||||
|
||||
+4
-5
@@ -5,11 +5,10 @@ const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodi
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
export default {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': () => 'eslint --fix --report-unused-disable-directives',
|
||||
'services/backend/**/*.{js.jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{js,jsx,ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': () => 'prettier . --write',
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Oodikone
|
||||
|
||||
[](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
|
||||
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
|
||||
@@ -129,7 +130,6 @@ Some useful commands are defined in `package.json` and can be run with `npm run
|
||||
- `npm run docker:down`: stops the whole environment
|
||||
- `npm run testupdater`: runs tests for updater
|
||||
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
|
||||
- `npm run rapodiff`: runs [Rapodiff](./services/backend/src/rapodiff/)
|
||||
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
|
||||
- `npm run install:docker`: installs `node_modules` for each package inside containers
|
||||
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
|
||||
@@ -187,8 +187,6 @@ Continuous integration (CI) works with Github actions and is defined in workflow
|
||||
- Updater is deployed to production when pushed if tests pass
|
||||
- Test databases can be updated, instructions in [anonyymioodi README](anonyymioodi/README.md)
|
||||
|
||||
[Rapodiff](./services/backend/src/rapodiff/) can be used to check differences between Rapo and Oodikone data.
|
||||
|
||||
## ❓FAQ
|
||||
|
||||
### Modules are missing after updating package.json
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script will first make db dumps of the test environment and upload the dumps to staging
|
||||
|
||||
|
||||
. ../scripts/utils.sh
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
declare -r db_names="sis-db kone-db user-db"
|
||||
declare -A db_url_names
|
||||
db_url_names["sis-db"]="SIS_DB_URL"
|
||||
db_url_names["kone-db"]="DB_URL_KONE"
|
||||
db_url_names["user-db"]="DB_URL_USER"
|
||||
|
||||
declare -r dump_directory="dumps"
|
||||
|
||||
# Takes the first db-tools pod
|
||||
check_oc_logged_staging
|
||||
db_tools_name=$(oc get pods -l app=db-tools --output jsonpath='{.items[0].metadata.name}')
|
||||
readonly db_tools_name
|
||||
|
||||
|
||||
create_dumps
|
||||
upload_and_migrate_dumps
|
||||
}
|
||||
|
||||
|
||||
|
||||
function create_dumps() {
|
||||
echo "Starting test/anon containers..."
|
||||
(npm run docker:down && npm run both) || die "Failed to start containers"
|
||||
echo
|
||||
echo "Waiting for containers to start..."
|
||||
sleep 10 # wait for containers to start
|
||||
|
||||
# Create dumps for all dbs
|
||||
echo
|
||||
echo "Creating dumps for $db_names"
|
||||
echo
|
||||
|
||||
# Create dir if not exists
|
||||
if [ ! -d $dump_directory ]; then mkdir -p $dump_directory; fi
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
create_dump_from_db "$db"
|
||||
if [ ! -f "$dump_directory/$db.sqz" ]; then die "Db dump not created"; fi
|
||||
done
|
||||
|
||||
echo "Dumps can be found in $PWD/\*.sqz"
|
||||
}
|
||||
|
||||
|
||||
# $1 db-name
|
||||
function create_dump_from_db() {
|
||||
echo "Dumping db $1..."
|
||||
|
||||
docker exec "$1" pg_dump -Fc -U postgres "$1" > "$dump_directory/$1.sqz" || die "Failed to dump db $1"
|
||||
}
|
||||
|
||||
|
||||
function check_oc_logged_staging() {
|
||||
echo -n "Checking if oc is logged into toska staging..."
|
||||
[[ $(oc config current-context) == "toska/api-ocp-test-0-k8s-it-helsinki-fi"* ]] || die "\nNot logged in to toska staging environment: $(oc config current-context)"
|
||||
echo " OK"
|
||||
}
|
||||
|
||||
|
||||
function upload_and_migrate_dumps() {
|
||||
|
||||
local target_dump_directory="$db_tools_name:/tmp/oodikone-staging-dumps"
|
||||
echo -n "Dumping $dump_directory/ to $target_dump_directory..."
|
||||
oc rsync "$dump_directory/" "$target_dump_directory" 1> /dev/null # show errors
|
||||
echo " OK"
|
||||
echo
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
migrate_dumps "$db"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
function migrate_dumps() {
|
||||
local db_url_b64
|
||||
local db_url_raw
|
||||
local db_url
|
||||
|
||||
db_url_b64=$(oc get secrets -l app.kubernetes.io/instance=oodikone --output jsonpath="{.items[0].data.${db_url_names[$1]}}")
|
||||
db_url_raw=$(echo "$db_url_b64" | base64 --decode)
|
||||
db_url="${db_url_raw%\?*}"
|
||||
|
||||
[[ $db_url != *"oodikone_*_test"* ]] || die "Trying to use non-test database: $db_url"
|
||||
|
||||
echo "Migrating $1 to url: $db_url"
|
||||
|
||||
oc exec "$db_tools_name" -- pg_restore --clean --no-owner -d "$db_url" "/tmp/oodikone-staging-dumps/$1.sqz"
|
||||
}
|
||||
|
||||
|
||||
# Run script
|
||||
main
|
||||
@@ -92,17 +92,17 @@ const clickAway = () => {
|
||||
|
||||
const getCourseSearchHooksForSingleYear = code => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': code },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
}
|
||||
|
||||
@@ -263,17 +263,17 @@ describe('Basic user', () => {
|
||||
|
||||
it('Searching multiple courses having substitution mappings shows course statistics', () => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code: 'TKT' },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
@@ -309,17 +309,17 @@ describe('Basic user', () => {
|
||||
|
||||
it('On searches with multiple courses, has correct links on the Course tab', () => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
pathname: '/api/coursesmulti',
|
||||
query: { code: 'BSCS' },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
pathname: '/api/courseyearlystats',
|
||||
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/populationstatisticsbycourse',
|
||||
pathname: '/api/populationstatisticsbycourse',
|
||||
}).as('coursestats')
|
||||
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('Degree programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
@@ -355,14 +355,14 @@ describe('Degree programme overview', () => {
|
||||
describe('Population link button works for', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('Class size 8 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2023 - 2024, 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class size 227 students')
|
||||
cy.contains('Class of 2017 - 2026, 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
@@ -371,9 +371,9 @@ describe('Degree programme overview', () => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 26 students')
|
||||
cy.contains('Class of 2022 - 2023, 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
@@ -465,9 +465,9 @@ describe('Degree programme overview', () => {
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Showing 10 out of 30 students')
|
||||
})
|
||||
|
||||
@@ -825,7 +825,8 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
|
||||
@@ -27,8 +27,8 @@ describe('Population statistics tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
|
||||
})
|
||||
|
||||
describe('Population search', () => {
|
||||
@@ -74,8 +74,8 @@ describe('Population statistics tests', () => {
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('Class size 47 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2017 - 2018, 47 students')
|
||||
})
|
||||
|
||||
// TODO: Delete MUI
|
||||
@@ -95,7 +95,7 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
@@ -251,7 +251,7 @@ describe('Population statistics tests', () => {
|
||||
it('Is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.intercept('/api/courseyearlystats**').as('coursePage')
|
||||
cy.cs('toggle-group-module-MAT110').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
|
||||
cy.wait('@coursePage')
|
||||
@@ -401,8 +401,8 @@ describe('Population statistics tests', () => {
|
||||
describe('When using IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Population statistics is visible', () => {
|
||||
|
||||
@@ -162,8 +162,8 @@ describe('Students tests', () => {
|
||||
.parent()
|
||||
.within(() => cy.get('a').click())
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.visit('/populations')
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
|
||||
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
## Study guidance groups
|
||||
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Frontend: things are done in the same way as with class stats.
|
||||
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
|
||||
|
||||
+56
-63
@@ -25,7 +25,7 @@ export default tseslint.config(
|
||||
'nyc-config.cjs',
|
||||
'.lintstagedrc.mjs',
|
||||
'cypress.config.js',
|
||||
]
|
||||
],
|
||||
},
|
||||
|
||||
// Global options (excl. e2e)
|
||||
@@ -44,8 +44,8 @@ export default tseslint.config(
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.{js,cjs}']
|
||||
}
|
||||
allowDefaultProject: ['*.{js,cjs}'],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
@@ -53,13 +53,13 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'import-x': eslintPluginImportX
|
||||
'import-x': eslintPluginImportX,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
camelcase: 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'err'],
|
||||
@@ -75,31 +75,42 @@ 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',
|
||||
'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 }
|
||||
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 }],
|
||||
|
||||
'import-x/no-commonjs': 'error',
|
||||
'import-x/no-default-export': 'error',
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import-x/no-unused-modules': ['error',
|
||||
'import-x/no-unused-modules': [
|
||||
'error',
|
||||
{
|
||||
missingExports: true,
|
||||
unusedExports: true,
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
|
||||
}],
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
}],
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}'],
|
||||
},
|
||||
],
|
||||
'import-x/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
@@ -112,9 +123,7 @@ export default tseslint.config(
|
||||
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/dot-notation': 'error',
|
||||
'@typescript-eslint/prefer-destructuring': ['error',
|
||||
{ VariableDeclarator: { object: true } }
|
||||
],
|
||||
'@typescript-eslint/prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
|
||||
'@typescript-eslint/no-implied-eval': 'error',
|
||||
@@ -134,12 +143,12 @@ export default tseslint.config(
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json'
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
}),
|
||||
createNodeResolver({
|
||||
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
|
||||
})
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -147,22 +156,22 @@ export default tseslint.config(
|
||||
{
|
||||
files: ['services/backend/src/routes/*.{ts,js}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Disable type-aware linting for javascript files
|
||||
{
|
||||
files: ['{updater,services}/**/*.{js,cjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked]
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
|
||||
// Frontend camelCase and react
|
||||
{
|
||||
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': eslintPluginReact,
|
||||
react: eslintPluginReact,
|
||||
},
|
||||
extends: [
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
@@ -173,14 +182,15 @@ export default tseslint.config(
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'warn',
|
||||
camelcase: 'warn',
|
||||
'react/prop-types': 'off', // Typescript but worse
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/function-component-definition': ['error',
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/no-array-index-key': 'error',
|
||||
@@ -188,9 +198,7 @@ export default tseslint.config(
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error',
|
||||
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
|
||||
],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
|
||||
@@ -198,29 +206,25 @@ export default tseslint.config(
|
||||
'react/jsx-tag-spacing': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'react': {
|
||||
'version': 'detect'
|
||||
}
|
||||
}
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Cypress should only use rules provided by the plugin below
|
||||
{
|
||||
files: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslintPluginCypress.configs.recommended
|
||||
]
|
||||
extends: [eslintPluginCypress.configs.recommended],
|
||||
},
|
||||
|
||||
// Override for backend api tests
|
||||
{
|
||||
files: [
|
||||
"services/backend/tests**/*.ts"
|
||||
],
|
||||
files: ['services/backend/tests/**/*.ts'],
|
||||
rules: {
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'no-console': 'off',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
@@ -228,7 +232,7 @@ export default tseslint.config(
|
||||
files: [
|
||||
'services/backend/**/*.js',
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'updater/sis-updater-scheduler/**/*.js'
|
||||
'updater/sis-updater-scheduler/**/*.js',
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
@@ -239,20 +243,9 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
|
||||
// Rapodiff
|
||||
{
|
||||
files: ['services/backend/src/rapodiff/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Migration files
|
||||
{
|
||||
files: [
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'services/backend/src/database/**/*.js'
|
||||
],
|
||||
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
|
||||
rules: {
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
@@ -270,5 +263,5 @@ export default tseslint.config(
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
|
||||
)
|
||||
|
||||
Generated
+497
-263
File diff suppressed because it is too large
Load Diff
+8
-12
@@ -6,9 +6,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"cypress": "cypress",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:run": "cypress run --browser chrome",
|
||||
"instrument": "./scripts/instrument.sh",
|
||||
"cypress:run": "cypress run --browser firefox",
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"stylelint": "stylelint",
|
||||
@@ -26,13 +24,11 @@
|
||||
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
|
||||
"flushredis": "docker exec -it redis redis-cli flushall",
|
||||
"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 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: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",
|
||||
"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",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
@@ -46,21 +42,21 @@
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"cypress": "^15.9.0",
|
||||
"cypress": "^15.13.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.1",
|
||||
"eslint-plugin-cypress": "^6.2.3",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.2.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^39.0.0",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+403
-271
File diff suppressed because it is too large
Load Diff
@@ -9,23 +9,19 @@
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^10.43.0",
|
||||
"@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",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"express": "^5.2.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.20.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.8",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
@@ -37,9 +33,11 @@
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"supertest": "^7.2.2",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'express-async-errors'
|
||||
import express from 'express'
|
||||
|
||||
import { baseUrl, backendPort } from './config'
|
||||
|
||||
@@ -7,9 +7,7 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
export const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
export const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
export const pateToken = process.env.PATE_API_TOKEN ?? ''
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express'
|
||||
import { omit } from 'lodash-es'
|
||||
import morgan from 'morgan'
|
||||
|
||||
import { FormattedUser } from '../types'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { getFullStudyProgrammeRights } from '../util'
|
||||
import logger from '../util/logger'
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*.csv
|
||||
*.csv#
|
||||
@@ -1,22 +0,0 @@
|
||||
# Rapodiff
|
||||
|
||||
Use this to check differences between oodikone and rapo.
|
||||
|
||||
## How to load the CSV file from Rapo
|
||||
|
||||
1. Go to Rapo and open ”Koulutusohjelmien vuosiseuranta”
|
||||
2. Navigate to ”Opintopisteet ja opetus / Credits and teaching”
|
||||
3. From the faculty dropdown menu, select all the faculties
|
||||
4. From the bottom of the page, click ”Vie -> Tiedot -> CSV”
|
||||
5. Rename the file to `data.csv` and copy it to this directory
|
||||
|
||||
## Usage
|
||||
|
||||
1. Have real oodikone running. Then run rapodiff **in the root directory**: `npm run rapodiff -- args`
|
||||
2. place args with a mode. omit args to print possible modes.
|
||||
|
||||
## Known differences in APIs
|
||||
|
||||
- rapo does not show students that have graduated (it however takes some days for rapo to notice a graduation)
|
||||
- rapo does not handle the starting year of transfers properly (shows in old staring population)
|
||||
- rapo show some students incorrectly passive, see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3521
|
||||
@@ -1,188 +0,0 @@
|
||||
const { orderBy } = require('lodash-es')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
const { parseCsv, printProgressBar } = require('./helpers')
|
||||
|
||||
const diffs = []
|
||||
let noDiffCounter = 0
|
||||
|
||||
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
|
||||
const transformProgrammeCode = oldCode => `${oldCode[4]}H${oldCode.slice(0, 2)}_${oldCode.slice(5, 8)}`
|
||||
|
||||
const formatData = data =>
|
||||
data.reduce((obj, cur) => {
|
||||
const parseNum = str => (str === '' ? 0 : parseFloat(str.replace(',', '.')))
|
||||
const [
|
||||
year,
|
||||
facultyCode,
|
||||
programmeInfo,
|
||||
basic,
|
||||
exchange,
|
||||
otherUni,
|
||||
openUni,
|
||||
special,
|
||||
total,
|
||||
transferredFromAbroad,
|
||||
otherTransferred,
|
||||
] = cur
|
||||
const programmeCode = transformProgrammeCode(programmeInfo.split(' ')[0])
|
||||
if (!obj[programmeCode]) obj[programmeCode] = {}
|
||||
obj[programmeCode][year] = {
|
||||
year: parseNum(year),
|
||||
facultyCode,
|
||||
programmeInfo,
|
||||
basic: parseNum(basic),
|
||||
'incoming-exchange': parseNum(exchange),
|
||||
agreement: parseNum(otherUni),
|
||||
'open-uni': parseNum(openUni),
|
||||
separate: parseNum(special),
|
||||
total: parseNum(total),
|
||||
transferred: parseNum(transferredFromAbroad) + parseNum(otherTransferred),
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const diff = (rapoData, okData, code) => {
|
||||
for (const year of Object.keys(okData)) {
|
||||
if (year !== '2022' && year !== '2023') continue
|
||||
const rapo = rapoData[year]
|
||||
const ok = okData[year]
|
||||
if (!rapo) {
|
||||
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
|
||||
continue
|
||||
}
|
||||
for (const field of ['basic', 'incoming-exchange', 'open-uni', 'agreement', 'separate', 'transferred']) {
|
||||
const okValue = Math.round(ok[field] || 0)
|
||||
const rapoValue = Math.round(rapo[field] || 0)
|
||||
const diff = Math.abs(okValue - rapoValue)
|
||||
if (diff === 0) {
|
||||
noDiffCounter++
|
||||
continue
|
||||
}
|
||||
const percentage = rapoValue !== 0 ? Number(((diff / rapoValue) * 100).toFixed(2)) : 100
|
||||
diffs.push({ field, year, code, okValue, rapoValue, diff, 'diff %': percentage })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processProgrammes = async data => {
|
||||
let programmeCounter = 0
|
||||
const rapoProgrammeData = formatData(data.slice(1))
|
||||
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
|
||||
for (const programmeCode of allProgrammeCodes) {
|
||||
const okProgrammeData = await computeCreditsProduced(programmeCode, false, true)
|
||||
diff(rapoProgrammeData[programmeCode], okProgrammeData.stats, programmeCode)
|
||||
printProgressBar(allProgrammeCodes.length, ++programmeCounter)
|
||||
}
|
||||
const fieldToSortBy = 'diff' // 'diff %'
|
||||
const orderedDiffs = orderBy(diffs, fieldToSortBy, 'asc')
|
||||
console.table(orderedDiffs)
|
||||
console.log(`${diffs.length} diffs found with current settings. Numbers with no diff: ${noDiffCounter}`)
|
||||
const totalDiff = orderedDiffs.reduce((sum, cur) => cur.diff + sum, 0)
|
||||
console.log('Total difference: ', totalDiff)
|
||||
}
|
||||
|
||||
const programmeCreditsDiff = async fileName => {
|
||||
console.log('Running studyprogramme credits diff')
|
||||
await parseCsv(fileName, processProgrammes)
|
||||
console.log('Diff completed.')
|
||||
}
|
||||
|
||||
/*
|
||||
separate
|
||||
Suoritus on erillisellä opiskeluoikeudella opettajankoulutuksen opintoja suorittavan opiskelijan suorittama
|
||||
Suoritus on erillisellä opiskeluoikeudella opiskelevan suorittama
|
||||
|
||||
agreement
|
||||
Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama
|
||||
|
||||
|
||||
open-uni
|
||||
Suoritus on avoimessa korkeakouluopetuksessa suoritettu
|
||||
|
||||
incoming-exchange
|
||||
Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama
|
||||
|
||||
basic
|
||||
perustutkinto-opiskelija = 1
|
||||
ja
|
||||
Tuntematon
|
||||
tai (ehkä):
|
||||
Suoritus on kotimainen harjoittelujakso
|
||||
?? Opintosuoritus on täydennyskoulutuksessa suoritettu ??
|
||||
|
||||
other
|
||||
perustutkinto-opiskelija = 0 && tuntematon
|
||||
|
||||
*/
|
||||
|
||||
const getCategory = (typeName, isBasic, hyvaksiluettu) => {
|
||||
if (hyvaksiluettu) return 'transferred' // ?? unsure
|
||||
if (isBasic || ['Suoritus on kotimainen harjoittelujakso', 'Tuntematon'].includes(typeName)) return 'basic'
|
||||
if (typeName === 'Suoritus on avoimessa korkeakouluopetuksessa suoritettu') return 'open-uni'
|
||||
if (typeName === 'Suoritus on tulevan kansainvälisen vaihto-opiskelijan suorittama') return 'incoming-exchange'
|
||||
if (typeName.startsWith('Suoritus on erillisellä opiskeluoikeudella')) return 'separate'
|
||||
if (typeName === 'Suoritus on korkeakoulujen välisillä yhteistyösopimuksilla opiskelevan suorittama')
|
||||
return 'agreement'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const processIds = async (rawData, code, field) => {
|
||||
const data = rawData.slice(0, 21628) // 11476 = KTM viimeinen
|
||||
const rapoData = data
|
||||
.map(row => ({
|
||||
id: row[0].slice(11),
|
||||
type: getCategory(row[1], row[3] === '1', row[6] === '1'),
|
||||
typeCode: row[8],
|
||||
isModule: row[9] !== '0',
|
||||
credits: parseFloat(row[10]),
|
||||
included: row[4] === '1',
|
||||
}))
|
||||
.filter(course => !course.isModule && (field === 'transferred' || course.included))
|
||||
const rapoIds = rapoData.filter(course => course.credits > 0 && course.type === field).map(row => row.id)
|
||||
const rapoCredits = rapoData.reduce(
|
||||
(stats, cur) => {
|
||||
stats[cur.type] += cur.credits
|
||||
return stats
|
||||
},
|
||||
{
|
||||
transferred: 0,
|
||||
total: '_',
|
||||
basic: 0,
|
||||
'open-uni': 0,
|
||||
separate: 0,
|
||||
'incoming-exchange': 0,
|
||||
agreement: 0,
|
||||
other: 0,
|
||||
}
|
||||
)
|
||||
const stats = await computeCreditsProduced(code, false, true, field)
|
||||
if (!stats.stats.ids) {
|
||||
console.log({ rapoCredits })
|
||||
console.log('Edit getCreditsProduced code so that it saves a list of the relevant ids you want to compare.')
|
||||
return
|
||||
}
|
||||
const okIdMap = stats.stats.ids
|
||||
const rapoIdMap = rapoIds.reduce((obj, cur) => {
|
||||
obj[cur] = true
|
||||
return obj
|
||||
}, {})
|
||||
const onlyInRapo = Object.keys(rapoIdMap).filter(id => !okIdMap[id])
|
||||
const onlyInOk = Object.keys(okIdMap).filter(id => !rapoIdMap[id])
|
||||
console.log('\n onlyInRapo: ', onlyInRapo.length)
|
||||
console.dir(onlyInRapo.slice(0, 30))
|
||||
console.log('\n onlyInOk: ', onlyInOk.length)
|
||||
console.dir(onlyInOk.slice(0, 30))
|
||||
delete stats.ids
|
||||
console.log(stats.stats['2022'])
|
||||
console.log('rapoCredits: ', rapoCredits)
|
||||
}
|
||||
|
||||
const testNewCalc = async (fileName, code, field) => {
|
||||
await parseCsv(fileName, async data => processIds(data, code, field))
|
||||
}
|
||||
|
||||
module.exports = { programmeCreditsDiff, testNewCalc }
|
||||
@@ -1,36 +0,0 @@
|
||||
const fs = require('fs/promises')
|
||||
const Papa = require('papaparse')
|
||||
|
||||
const parseCsv = async (fileName, fun) => {
|
||||
try {
|
||||
const file = await fs.readFile(`${__dirname}/${fileName}`, 'utf8')
|
||||
Papa.parsePromise = file => {
|
||||
return new Promise((complete, error) => {
|
||||
Papa.parse(file, { complete, error })
|
||||
})
|
||||
}
|
||||
const data = await Papa.parsePromise(file)
|
||||
await fun(data?.data)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const printProgressBar = (total, current) => {
|
||||
const barLength = 30
|
||||
const progress = Math.floor((current / total) * barLength)
|
||||
const empty = barLength - progress
|
||||
|
||||
const progressBar = `[${'#'.repeat(progress)}${'-'.repeat(empty)}]`
|
||||
const percentage = Math.floor((current / total) * 100)
|
||||
|
||||
process.stdout.clearLine()
|
||||
process.stdout.cursorTo(0)
|
||||
process.stdout.write(`${progressBar} ${percentage} %`)
|
||||
|
||||
if (current >= total) {
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseCsv, printProgressBar }
|
||||
@@ -1,31 +0,0 @@
|
||||
const { programmeCreditsDiff, testNewCalc } = require('./creditsDiff')
|
||||
|
||||
// ** To add new mode, just type key and function here **
|
||||
const modes = {
|
||||
// Check credits with current local oodikone data against provided data.csv in rapodiff folder.
|
||||
credits: () => programmeCreditsDiff('data.csv'),
|
||||
// Provide programme code as argument. Parses credits from credits.csv in rapodiff folder. This must be obtained from Rapo people. This tells which credit ids are found in oodikone but not rapo, and vice versa.
|
||||
// notice: check mode requires writing ids in to the stats object in credits calculating function.
|
||||
// Add something like this there:
|
||||
// if (category === 'open-uni' && credits > 0 && new Date(attainmentDate).getFullYear() === 2022) stats.ids[id] = true
|
||||
check: (fileName, code, field) => testNewCalc(fileName, code, field),
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('**RAPODIFF**\n')
|
||||
const mode = process.argv[2]
|
||||
const modeFunction = modes[mode]
|
||||
const arg = process.argv[3]
|
||||
const arg2 = process.argv[4]
|
||||
const arg3 = process.argv[5]
|
||||
if (!modeFunction) {
|
||||
console.log(
|
||||
'Unknown or missing mode. Supply the mode as an argument (e.g. `npm run rapodiff -- credits`). Supported modes:\n'
|
||||
)
|
||||
console.log(Object.keys(modes).join('\n'))
|
||||
return
|
||||
}
|
||||
await modeFunction(arg, arg2, arg3)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0))
|
||||
@@ -42,7 +42,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(compression())
|
||||
|
||||
app.use(headersMiddleware)
|
||||
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
app.use(currentUserMiddleware)
|
||||
app.use(accessLogger)
|
||||
app.use(`${url}/login`, login)
|
||||
app.use(url, courses)
|
||||
@@ -52,8 +52,8 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/semesters/codes`, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(`${url}/v3/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
|
||||
app.use(`${url}/changelog`, changelog)
|
||||
@@ -72,7 +72,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
|
||||
app.use(`${url}/study-programme-pins`, studyProgrammePins)
|
||||
app.use(`${url}/curriculum-periods`, curriculumPeriods)
|
||||
app.get('*', (_, res) => {
|
||||
app.get('', (_, res) => {
|
||||
res.status(404).json({ error: 'unknown endpoint' })
|
||||
})
|
||||
Sentry.setupExpressErrorHandler(app)
|
||||
|
||||
@@ -14,7 +14,7 @@ router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
|
||||
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
|
||||
const result = await getCloseToGraduationData(studentNumbers)
|
||||
|
||||
res.json(result)
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
const result = await getCloseToGraduationData()
|
||||
|
||||
@@ -12,12 +12,17 @@ import {
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
import {
|
||||
getFullStudyProgrammeRights,
|
||||
handleQueryArrays,
|
||||
hasFullAccessToStudentData,
|
||||
validateParamLength,
|
||||
} from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
'/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, includeSpecial } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
@@ -49,11 +54,13 @@ router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMul
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
'/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { codes, combineSubstitutions, separate } = req.query
|
||||
|
||||
if (!codes) {
|
||||
const courseCodes = handleQueryArrays(codes)
|
||||
|
||||
if (!courseCodes) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
@@ -75,7 +82,7 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { FormattedUser } from '../types/user'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -26,7 +26,7 @@ import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -98,15 +98,11 @@ const obfuscateStuff = ({
|
||||
}
|
||||
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
'/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
|
||||
const { programme, combinedProgramme } = req.query
|
||||
|
||||
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
|
||||
const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
|
||||
return Array.isArray(requestField) ? requestField : [requestField]
|
||||
}
|
||||
const years = handleQueryArrays(req.query.years)
|
||||
const semesters = handleQueryArrays(req.query.semesters)
|
||||
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
|
||||
@@ -172,7 +168,7 @@ router.get<
|
||||
CanError<PopulationstatisticsbycourseResBody>,
|
||||
PopulationstatisticsbycourseReqBody,
|
||||
PopulationstatisticsbycourseParams
|
||||
>('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
>('/populationstatisticsbycourse', async (req, res) => {
|
||||
const {
|
||||
id: userId,
|
||||
roles: userRoles,
|
||||
@@ -222,13 +218,16 @@ router.get<
|
||||
|
||||
// Used in custom population and single study guidance groups
|
||||
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
'/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentNumbers, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
|
||||
const students = handleQueryArrays(studentNumbers)
|
||||
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
? students
|
||||
: intersection(students, studentsUserCanAccess)
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
@@ -242,14 +241,14 @@ router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
|
||||
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
|
||||
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
}
|
||||
)
|
||||
|
||||
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
|
||||
'/v3/populationstatistics/studyprogrammes',
|
||||
'/populationstatistics/studyprogrammes',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
@@ -275,7 +274,7 @@ router.get<
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
|
||||
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
>('/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CurriculumOption } from '@oodikone/shared/types'
|
||||
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
|
||||
type CurriculumOptionParams = { code: string }
|
||||
|
||||
router.get<CurriculumOptionParams, CurriculumOption[] | null>('/curriculum-options/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await getCurriculumOptions(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
|
||||
type CurriculumPeriodParams = { code: string; periodIds: string }
|
||||
|
||||
router.get<CurriculumPeriodParams>('/curriculum/:code/:periodIds', async (req, res) => {
|
||||
const { code, periodIds } = req.params
|
||||
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
|
||||
@@ -23,15 +23,15 @@ import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
type StudyProgrammeParams = { id: string }
|
||||
type UpdateStudyProgrammeQuery = { combined_programme: string }
|
||||
type CreditStatsQuery = {
|
||||
codes: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
|
||||
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, CreditStatsQuery>('/creditstats', async (req, res) => {
|
||||
const { codes, specialGroups, yearType } = req.query
|
||||
const stats: CreditStatsPayload = {}
|
||||
for (const code of JSON.parse(codes)) {
|
||||
@@ -40,15 +40,13 @@ router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) =>
|
||||
return res.json(stats)
|
||||
})
|
||||
|
||||
interface GetStatsRequest extends Request {
|
||||
query: {
|
||||
year_type: YearType
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
type StatsRequestQuery = {
|
||||
year_type: YearType
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
|
||||
router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/basicstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -75,7 +73,7 @@ router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/graduationstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { year_type: yearType, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -102,14 +100,12 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
interface GetCourseStatsRequest extends Request {
|
||||
query: {
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
type CourseStatsQuery = {
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
|
||||
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { combinedProgramme, yearType } = req.query
|
||||
const date = new Date()
|
||||
@@ -123,15 +119,13 @@ router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response)
|
||||
}
|
||||
})
|
||||
|
||||
interface GetStudyTrackStatsRequest extends Request {
|
||||
query: {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
type StudyTrackStatsQuery = {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
combined_programme: string
|
||||
}
|
||||
|
||||
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
|
||||
router.get<StudyProgrammeParams, unknown, never, StudyTrackStatsQuery>('/:id/studytrackstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
@@ -159,7 +153,7 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
|
||||
return res.json(updated)
|
||||
})
|
||||
|
||||
router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response) => {
|
||||
router.get<StudyProgrammeParams>('/:id/colorizedtablecoursestats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
try {
|
||||
const data = await getStudyProgrammeStatsForColorizedCoursesTable(code)
|
||||
@@ -169,7 +163,7 @@ router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
router.get<StudyProgrammeParams>('/:id/studytracks', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
@@ -182,42 +176,42 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
interface GetUpdateViewRequest extends Request {
|
||||
query: {
|
||||
combined_programme: string
|
||||
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
|
||||
'/:id/update_basicview',
|
||||
async (req, res) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateBasicView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.get('/:id/update_basicview', async (req: GetUpdateViewRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
router.get<StudyProgrammeParams, unknown, never, UpdateStudyProgrammeQuery>(
|
||||
'/:id/update_studytrackview',
|
||||
async (req, res) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateStudyTrackView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await updateBasicView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update basic stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateStudyTrackView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as auth from '../middleware/auth'
|
||||
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
|
||||
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
|
||||
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
|
||||
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
|
||||
import { getFullStudyProgrammeRights, handleQueryArrays, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -91,11 +91,12 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights, iamGroups } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const { providers, semesterStart, semesterEnd } = req.query
|
||||
if (!providers || !semesterStart) {
|
||||
const { providers: providerStack, semesterStart, semesterEnd } = req.query
|
||||
if (!providerStack || !semesterStart) {
|
||||
return res.status(422).send('Missing required query parameters')
|
||||
}
|
||||
|
||||
const providers = handleQueryArrays(providerStack)
|
||||
const providerRights = mapToProviders(fullStudyProgrammeRights)
|
||||
if (
|
||||
!(
|
||||
|
||||
@@ -26,14 +26,14 @@ const refreshProgrammesByList = (programmeCodes: string[]) => {
|
||||
return 'Added jobs for refreshing programme'
|
||||
}
|
||||
|
||||
router.get('/update/v2/meta', async (_req: Request, res: Response) => {
|
||||
router.get('/update/meta', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISMetadata()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS meta scheduled')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/students', async (_req: Request, res: Response) => {
|
||||
router.get('/update/students', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISStudents()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS students scheduled')
|
||||
@@ -47,7 +47,7 @@ interface PostCustomListRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
|
||||
router.post('/update/customlist/:type', async (req: PostCustomListRequest, res: Response) => {
|
||||
const { type } = req.params
|
||||
const list = req.body
|
||||
const typeToJob = {
|
||||
@@ -65,7 +65,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/programmes', async (_req: Request, res: Response) => {
|
||||
router.get('/update/programmes', async (_req: Request, res: Response) => {
|
||||
const response = await updateSISProgrammes()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS programmes scheduled')
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
|
||||
import { LANGUAGE_CODES } from '@oodikone/shared/language'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
import {
|
||||
ChangeLanguageReqBody,
|
||||
GetUserByIdParams,
|
||||
GetUserByIdResBody,
|
||||
GetUserRolesResBody,
|
||||
GetUsersResBody,
|
||||
ModifyUserRolesReqBody,
|
||||
PostUserEmailReqBody,
|
||||
UserEmailPreviewResBody,
|
||||
UserIDElementsParams,
|
||||
UserIDElementsReqBody,
|
||||
} from '@oodikone/shared/routes/users'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { roles } from '../config/roles'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
import * as userService from '../services/userService'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
|
||||
|
||||
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
|
||||
const results = await userService.findAll()
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
type GetUserRolesResBody = readonly string[]
|
||||
|
||||
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
type GetUserByIdParams = { uid: string }
|
||||
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
|
||||
router.get<never, GetUserRolesResBody>('/roles', auth.roles(['admin']), (_, res) => res.json(roles))
|
||||
|
||||
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
'/:uid',
|
||||
@@ -41,61 +42,35 @@ router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
}
|
||||
)
|
||||
|
||||
type ModifyUserRolesReqBody = {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
type ModifyUserRolesResBody = never
|
||||
|
||||
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
|
||||
router.post<never, CanError<void, Error>, ModifyUserRolesReqBody>(
|
||||
'/modify-roles',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
if (error) return res.status(400).json(error)
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
)
|
||||
|
||||
type UserEmailPreviewResBody = {
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
|
||||
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
|
||||
res.json({ subject: accessMessageSubject, html: accessMessageText })
|
||||
})
|
||||
|
||||
type PostUserEmailReqBody = { email: string }
|
||||
type PostUserEmailResBody = never
|
||||
router.post<never, CanError<void>, PostUserEmailReqBody>('/email', auth.roles(['admin']), async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
|
||||
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
|
||||
'/email',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
}
|
||||
)
|
||||
|
||||
type ElementsParams = { uid: string }
|
||||
type ElementsReqBody = { codes: string[] }
|
||||
|
||||
type PostUserUIDElementsResBody = void
|
||||
|
||||
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
router.post<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
@@ -106,9 +81,7 @@ router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, Elemen
|
||||
}
|
||||
)
|
||||
|
||||
type DeleteUserUIDElementsResBody = void
|
||||
|
||||
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
router.delete<never, CanError<void>, UserIDElementsReqBody, UserIDElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
@@ -119,10 +92,7 @@ router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, El
|
||||
}
|
||||
)
|
||||
|
||||
type ChangeLanguageReqBody = { language: Language }
|
||||
type PostChangeLanguageResBody = void
|
||||
|
||||
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
router.post<never, CanError<void>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
const { language } = req.body
|
||||
|
||||
if (!LANGUAGE_CODES.includes(language)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
import { isProduction, pateToken } from '../config'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const pateClient = axios.create({
|
||||
|
||||
@@ -3,7 +3,7 @@ const axios = require('axios')
|
||||
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 {
|
||||
@@ -25,20 +25,20 @@ const getSisuAccessToken = async eppn => {
|
||||
return sisuResponse.data.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,7 +52,7 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
}
|
||||
}`
|
||||
|
||||
const getGraphqlData = async (accessToken, queryObject) => {
|
||||
export const getGraphqlData = async (accessToken, queryObject) => {
|
||||
const graphQlUrl = `${sisUrl}/api`
|
||||
const authHeader = `Application ${accessToken}`
|
||||
const config = {
|
||||
@@ -73,11 +73,3 @@ const getGraphqlData = async (accessToken, queryObject) => {
|
||||
}
|
||||
return graphQlResponse.data.data.private_person
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSisuAccessToken,
|
||||
decodeJwtTokenPayloadToObject,
|
||||
getSisuAuthData,
|
||||
personSearchQuery,
|
||||
getGraphqlData,
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const getCurrentSemester = async () => {
|
||||
|
||||
export const getSemestersAndYears = async () => {
|
||||
const semesters = await SemesterModel.findAll()
|
||||
return semesters.reduce(
|
||||
return semesters.reduce<SemestersAndYears>(
|
||||
(acc, semester) => {
|
||||
const { semestercode, name, yearcode, yearname, startdate, enddate } = semester
|
||||
acc.semesters[semestercode] = { semestercode, name, yearcode, startdate, enddate }
|
||||
@@ -68,6 +68,6 @@ export const getSemestersAndYears = async () => {
|
||||
|
||||
return acc
|
||||
},
|
||||
{ years: {}, semesters: {} } as SemestersAndYears
|
||||
{ years: {}, semesters: {} }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,39 +8,39 @@ const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
const params = { params: { token: SECRET_TOKEN } }
|
||||
|
||||
export const abortUpdate = async () => {
|
||||
const response = await client.get('/v1/abort', params)
|
||||
const response = await client.get('/abort', params)
|
||||
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('/courses', data, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISMetadata = async () => {
|
||||
const response = await client.get('/v1/meta', params)
|
||||
const response = await client.get('/meta', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISProgrammes = async () => {
|
||||
const response = await client.get('/v1/programmes', params)
|
||||
const response = await client.get('/programmes', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISRedisCache = async () => {
|
||||
const response = await client.get('/v1/rediscache', params)
|
||||
const response = await client.get('/rediscache', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudents = async () => {
|
||||
const response = await client.get('/v1/students', params)
|
||||
const response = await client.get('/students', params)
|
||||
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('/students', data, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -54,6 +54,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('studyplans', { studentnumbers }, params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { isEqual, keyBy, omit, uniq } from 'lodash-es'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { User } from '@oodikone/shared/models/user'
|
||||
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
|
||||
import { DetailedProgrammeRights, Role, ExpandedUser, FormattedUser, IamAccess } from '@oodikone/shared/types'
|
||||
import { roles } from '../config/roles'
|
||||
import { sequelizeUser } from '../database/connection'
|
||||
import { UserModel } from '../models/user'
|
||||
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
|
||||
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import * as jami from '../util/jami'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type IamAccess = Record<string, Record<'read' | 'write' | 'admin', boolean>>
|
||||
@@ -1,3 +1 @@
|
||||
export { ParsedCourse } from './course'
|
||||
export { IamAccess } from './iamAccess'
|
||||
export { ExpandedUser, FormattedUser } from './user'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { FormattedUser } from './user'
|
||||
import { FormattedUser } from '@oodikone/shared/types'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
|
||||
@@ -100,3 +100,8 @@ export const safeJSONParse = async <T>(json: string): Promise<T | null> => {
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
|
||||
export const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
|
||||
return Array.isArray(requestField) ? requestField : [requestField]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import axios from 'axios'
|
||||
|
||||
import { IamAccess } from '@oodikone/shared/types'
|
||||
import { importerToken, isStaging, jamiUrl } from '../config'
|
||||
import { IamAccess } from '../types'
|
||||
import logger from './logger'
|
||||
|
||||
const jamiClient = axios.create({
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ import { Sequelize } from 'sequelize'
|
||||
import request, { Response } from 'supertest'
|
||||
|
||||
import { PopulationstatisticsResBody } from '@oodikone/shared/routes/populations'
|
||||
import { dbConnections } from '../src/database/connection'
|
||||
import { initTests } from './utils'
|
||||
import { dbConnections } from '../../src/database/connection'
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
let connection: Sequelize
|
||||
@@ -24,7 +24,7 @@ const populationUrl = (
|
||||
semesters: ('SPRING' | 'FALL')[] = [],
|
||||
students: string[] = []
|
||||
) => {
|
||||
return `/v3/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
|
||||
return `/populationstatistics?${programme && 'programme=' + programme}${years.map(year => '&years=' + year).join('')}${semesters.map(sem => '&semesters=' + sem).join('')}${students.map(s => '&studentStatuses=' + s).join('')}`
|
||||
}
|
||||
|
||||
void describe('Population statistics', async () => {
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics by course', async () => {
|
||||
await it('should not return anything with missing parameters', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbycourse')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.deepEqual(res.status, 400)
|
||||
assert.deepEqual(res.body.error, 'The body should have a yearcode and coursecode defined')
|
||||
})
|
||||
|
||||
await it('should return correct amount of students for a course', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbycourse?coursecodes=["MAT21005"]&from=70&to=70')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
// Copied from response
|
||||
const testStudent = {
|
||||
firstnames: 'Mira Ilmatar',
|
||||
lastname: 'Lintula',
|
||||
started: '2018-08-01T00:00:00.000Z',
|
||||
studentNumber: '394776',
|
||||
credits: 265,
|
||||
hopsCredits: 0,
|
||||
name: 'Lintula Mira Ilmatar',
|
||||
gender_code: '2',
|
||||
email: 'sisutestidata65689@testisisudata.fi',
|
||||
secondaryEmail: null,
|
||||
phoneNumber: '+358501234567',
|
||||
updatedAt: '2024-12-16T15:39:02.937Z',
|
||||
studyrightStart: '1899-12-31T22:20:11.000Z',
|
||||
option: null,
|
||||
birthdate: '1999-03-01T00:00:00.000Z',
|
||||
sis_person_id: 'hy-hlo-125389506',
|
||||
citizenships: [
|
||||
{
|
||||
en: 'Finland',
|
||||
fi: 'Suomi',
|
||||
sv: 'Finland',
|
||||
},
|
||||
],
|
||||
curriculumVersion: null,
|
||||
hasPersonalIdentityCode: null,
|
||||
tags: [],
|
||||
transferredStudyright: false,
|
||||
studyRights: [
|
||||
{
|
||||
admissionType: null,
|
||||
cancelled: false,
|
||||
extentCode: 5,
|
||||
facultyCode: 'H50',
|
||||
id: 'hy-opinoik-125389507',
|
||||
semesterEnrollments: [
|
||||
{ semester: 137, type: 1 },
|
||||
{ semester: 138, type: 1 },
|
||||
{ semester: 139, type: 1 },
|
||||
{ semester: 140, type: 1 },
|
||||
{ semester: 141, type: 1 },
|
||||
{ semester: 142, type: 1 },
|
||||
{ semester: 143, type: 1 },
|
||||
{ semester: 144, type: 1 },
|
||||
{ semester: 145, type: 1 },
|
||||
{ semester: 146, type: 1 },
|
||||
{ semester: 147, type: 1 },
|
||||
{ semester: 148, statutoryAbsence: false, type: 2 },
|
||||
{ semester: 149, type: 3 },
|
||||
{ semester: 150, type: 3 },
|
||||
{ semester: 151, type: 3 },
|
||||
],
|
||||
startDate: '2018-08-01T00:00:00.000Z',
|
||||
studyRightElements: [
|
||||
{
|
||||
code: 'KH50_001',
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
endDate: '2021-06-11T00:00:00.000Z',
|
||||
graduated: true,
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
phase: 1,
|
||||
startDate: '2018-08-01T00:00:00.000Z',
|
||||
studyTrack: {
|
||||
code: 'MAT-MAT',
|
||||
name: {
|
||||
en: 'Mathematics',
|
||||
fi: 'Matematiikka',
|
||||
sv: 'Matematik',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'MH50_001',
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:masters-degree',
|
||||
endDate: '2025-12-31T00:00:00.000Z',
|
||||
graduated: false,
|
||||
name: {
|
||||
en: "Master 's Programme in Mathematics and Statistics",
|
||||
fi: 'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
sv: 'Magisterprogrammet i matematik och statistik',
|
||||
},
|
||||
phase: 2,
|
||||
startDate: '2021-06-12T00:00:00.000Z',
|
||||
studyTrack: null,
|
||||
},
|
||||
],
|
||||
tvex: null,
|
||||
},
|
||||
],
|
||||
studyplans: [],
|
||||
}
|
||||
|
||||
assert.strictEqual(res.status, 200)
|
||||
assert.strictEqual(res.body.students.length, 40)
|
||||
assert.deepStrictEqual(res.body.students.sort((a, b) => b.studentNumber - a.studentNumber).at(-1), testStudent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics by student number', async () => {
|
||||
await it('should not return anything with missing parameters', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatisticsbystudentnumbers')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.deepEqual(res.status, 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Express } from 'express'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, before } from 'node:test'
|
||||
import request from 'supertest'
|
||||
|
||||
import { initTests } from '../utils'
|
||||
|
||||
let app: Express
|
||||
before(async () => {
|
||||
app = await initTests()
|
||||
})
|
||||
|
||||
void describe('Population statistics (study programme)', async () => {
|
||||
await it('should return correctly formatted responses', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
assert.strictEqual(res.status, 200)
|
||||
|
||||
assert.strictEqual(Object.keys(res.body).includes('allProgrammes'), true)
|
||||
assert.strictEqual(Object.keys(res.body).includes('filteredProgrammes'), true)
|
||||
|
||||
assert.notStrictEqual(Object.keys(res.body.allProgrammes).length, 0)
|
||||
assert.notStrictEqual(Object.keys(res.body.filteredProgrammes).length, 0)
|
||||
})
|
||||
|
||||
await it('should return correct programmes to allProgrammes', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
const testMathProgramme = {
|
||||
code: 'KH50_001',
|
||||
curriculumPeriodIds: [
|
||||
'hy-lv-72',
|
||||
'hy-lv-73',
|
||||
'hy-lv-71',
|
||||
'hy-lv-69',
|
||||
'hy-lv-68',
|
||||
'hy-lv-70',
|
||||
'hy-lv-76',
|
||||
'hy-lv-74',
|
||||
'hy-lv-75',
|
||||
],
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
progId: 'MAT',
|
||||
}
|
||||
|
||||
assert.strictEqual(Object.keys(res.body.allProgrammes).length, 972)
|
||||
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme)
|
||||
})
|
||||
|
||||
await it('should return the programmes, that user has access to, to filteredProgrammes', async () => {
|
||||
const res = await request(app)
|
||||
.get('/populationstatistics/studyprogrammes')
|
||||
.set('shib-session-id', 'test')
|
||||
.set('uid', 'basic')
|
||||
.set('hygroupcn', 'grp-oodikone-basic-users')
|
||||
|
||||
const testMathProgramme = {
|
||||
code: 'KH50_001',
|
||||
curriculumPeriodIds: [
|
||||
'hy-lv-72',
|
||||
'hy-lv-73',
|
||||
'hy-lv-71',
|
||||
'hy-lv-69',
|
||||
'hy-lv-68',
|
||||
'hy-lv-70',
|
||||
'hy-lv-76',
|
||||
'hy-lv-74',
|
||||
'hy-lv-75',
|
||||
],
|
||||
degreeProgrammeType: 'urn:code:degree-program-type:bachelors-degree',
|
||||
name: {
|
||||
en: "Bachelor's Programme in Mathematical Sciences",
|
||||
fi: 'Matemaattisten tieteiden kandiohjelma',
|
||||
sv: 'Kandidatsprogrammet i matematiska vetenskaper',
|
||||
},
|
||||
progId: 'MAT',
|
||||
}
|
||||
|
||||
assert.strictEqual(Object.keys(res.body.filteredProgrammes).length, 2) // Basic has access to math bachelor + master
|
||||
|
||||
assert.deepStrictEqual(res.body.filteredProgrammes.KH50_001, testMathProgramme)
|
||||
assert.deepStrictEqual(res.body.allProgrammes.KH50_001, testMathProgramme) // All programmes should still include filteredProgramme programmes
|
||||
})
|
||||
|
||||
await it.todo(
|
||||
'should return both degree levels of study programme when searching KH90_001 or MH90_001 (ELK, ELL) in filteredProgrammes'
|
||||
)
|
||||
})
|
||||
Generated
+8
-5
@@ -16,11 +16,10 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@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",
|
||||
"lodash-es": "^4.18.1",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -34,6 +33,7 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -46,6 +46,7 @@
|
||||
"../shared": {
|
||||
"name": "@oodikone/shared",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
@@ -2134,12 +2135,14 @@
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
@@ -3056,9 +3059,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@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",
|
||||
"lodash-es": "^4.18.1",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,6 +36,7 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
|
||||
@@ -3,7 +3,7 @@ import axios, { AxiosProgressEvent } from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const baseHeaders = isDev
|
||||
@@ -83,16 +83,7 @@ export const RTKApi = createApi({
|
||||
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||
return headers
|
||||
},
|
||||
paramsSerializer: params => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(params).map(([key, val]) => {
|
||||
const subfix = Array.isArray(val) ? '[]' : ''
|
||||
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
|
||||
})
|
||||
|
||||
return searchParams.toString()
|
||||
},
|
||||
paramsSerializer: params => queryParamsToString(params),
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import { maxBy, orderBy, range } from 'lodash-es'
|
||||
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import type { Absence } from '@/types/students'
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import {
|
||||
|
||||
@@ -90,8 +90,6 @@ export const FacultiesTab = () => {
|
||||
totalRow.bySemesters[numberMode]
|
||||
)
|
||||
|
||||
if (!facultyMap) return null
|
||||
|
||||
const tableOptions = {
|
||||
initialState: { columnPinning: { left: ['Course'] } },
|
||||
state: {
|
||||
|
||||
@@ -4,25 +4,14 @@ import Typography from '@mui/material/Typography'
|
||||
|
||||
import { createColumnHelper } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const getFacultyTitle = code => {
|
||||
if (!code) return 'No faculty' // Shouldn't happen
|
||||
if (code === 'OPEN') return 'Open\nuni'
|
||||
if (code === 'OTHER') return 'Other\nfaculty'
|
||||
return code
|
||||
return code ?? 'No faculty'
|
||||
}
|
||||
|
||||
const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
|
||||
title={getTextIn(name) ?? code}
|
||||
>
|
||||
<b>{code}</b>
|
||||
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
|
||||
</div>
|
||||
)
|
||||
|
||||
const columnHelper = createColumnHelper()
|
||||
|
||||
export const useColumns = (getTextIn, faculties, numberMode, colorMode, facultyMap, allTotal) => {
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
import { createColumnHelper } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { getColor } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'baseline', height: 'auto', padding: '0.5em 0' }}
|
||||
title={getTextIn(name) ?? code}
|
||||
>
|
||||
<b>{code}</b>
|
||||
<i style={{ color: 'gray', maxWidth: '100%' }}>{getTextIn(name) ?? null}</i>
|
||||
</div>
|
||||
)
|
||||
import { getColor, CourseContainer } from '@/components/ColorizedCoursesTable/common'
|
||||
|
||||
const columnHelper = createColumnHelper()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
export const ColorizedCoursesTableContext = createContext(null)
|
||||
export const ColorizedCoursesTableContext = createContext()
|
||||
|
||||
export const useColorizedCoursesTableContext = () => useContext(ColorizedCoursesTableContext)
|
||||
|
||||
@@ -95,3 +96,10 @@ export const CourseFilter = ({ setCourseFilter }) => {
|
||||
<TextField label="Course name/code filter" onChange={event => setTextField(event.target.value)} value={textField} />
|
||||
)
|
||||
}
|
||||
|
||||
export const CourseContainer = ({ getTextIn, name, code }) => (
|
||||
<Stack alignItems="baseline" flexDirection="column" py={1} title={getTextIn(name) ?? code}>
|
||||
<b>{code}</b>
|
||||
<i className="colored-info-expander">{getTextIn(name) ?? null}</i>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
.colorized-courses-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.colorized-courses-table > div {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -28,9 +14,9 @@ input[type='radio'] {
|
||||
max-width: 25vw;
|
||||
}
|
||||
|
||||
table tr th,
|
||||
td > div > i {
|
||||
max-width: 340px;
|
||||
.colored-info-expander {
|
||||
color: gray;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
+24
-33
@@ -1,39 +1,36 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { LoadingSection } from '@/components/Loading'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
import { SemestersTab } from './SemestersTab'
|
||||
import './index.css'
|
||||
|
||||
export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, panes, dividerText }) => {
|
||||
useTitle(title)
|
||||
const { data: semesterData } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesterData ?? { semesters: {}, currentSemester: null }
|
||||
export const ColorizedCoursesTable = ({ fetchDataHook, fetchDataHookParams, panes }) => {
|
||||
const { semesters: allSemesters, currentSemester } = useSemesters()
|
||||
const semesters = Object.values(allSemesters).filter(
|
||||
// 135 = Fall 2017
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester.semestercode
|
||||
// INFO: If semesters is defined, currentSemester will be defined.
|
||||
semester => semester.semestercode >= 135 && semester.semestercode <= currentSemester!.semestercode
|
||||
)
|
||||
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook({ id: studyProgramme })
|
||||
const { data, isFetching, isLoading, isError } = fetchDataHook(fetchDataHookParams)
|
||||
|
||||
const [tab, setTab] = useState(0)
|
||||
const [numberMode, setNumberMode] = useState('completions')
|
||||
const [colorMode, setColorMode] = useState('none')
|
||||
const [semesterFilter, setSemesterFilter] = useState(null)
|
||||
const [semesterFilter, setSemesterFilter] = useState<{ start: number; end: number }>({ start: 135, end: 135 })
|
||||
const [filterEmptyCourses, setFilterEmptyCourses] = useState(true)
|
||||
|
||||
const selectedSemesters = useMemo(() => {
|
||||
const selectedSemestersArray = []
|
||||
const selectedSemestersArray: number[] = []
|
||||
if (semesterFilter?.start && semesterFilter?.end) {
|
||||
for (let i = parseInt(semesterFilter.start, 10); i <= parseInt(semesterFilter.end, 10); i++) {
|
||||
for (let i = semesterFilter.start; i <= semesterFilter.end; i++) {
|
||||
selectedSemestersArray.push(i)
|
||||
}
|
||||
}
|
||||
@@ -91,25 +88,19 @@ export const ColorizedCoursesTable = ({ fetchDataHook, studyProgramme, title, pa
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
<div className="colorized-courses-table">
|
||||
{dividerText ? (
|
||||
<Divider sx={{ width: '100%', mt: 3, mb: 1 }}>
|
||||
<Typography fontSize="1.3rem" variant="overline">
|
||||
{dividerText}
|
||||
</Typography>
|
||||
</Divider>
|
||||
) : null}
|
||||
|
||||
{displayedPanes.length > 1 ? (
|
||||
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
|
||||
{displayedPanes.map(({ label }) => (
|
||||
<Tab key={label} label={label} />
|
||||
))}
|
||||
</Tabs>
|
||||
) : null}
|
||||
<Paper variant="outlined">{displayedPanes.at(tab)?.render() ?? null}</Paper>
|
||||
</div>
|
||||
</ColorizedCoursesTableContext.Provider>
|
||||
<Stack alignItems="center">
|
||||
{displayedPanes.length > 1 && (
|
||||
<Tabs onChange={(_, newTab) => setTab(newTab)} value={tab}>
|
||||
{displayedPanes.map(({ label }) => (
|
||||
<Tab key={label} label={label} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
<Paper variant="outlined">
|
||||
<ColorizedCoursesTableContext.Provider value={settingsContext}>
|
||||
{displayedPanes.at(tab)?.render() ?? null}
|
||||
</ColorizedCoursesTableContext.Provider>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
+3
-4
@@ -12,8 +12,8 @@ import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { findCorrectProgramme } from '@/components/CustomPopulation/CustomPopulationProgrammeDist/util'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Loading } from '@/components/Loading'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { FormattedStudent, Name } from '@oodikone/shared/types'
|
||||
|
||||
type CreditGainObj = Record<string, { credits: number; name: Name; students: string[] }>
|
||||
@@ -76,8 +76,7 @@ export const CoursePopulationCreditGainTable = ({
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
const { data: faculties } = useGetFacultiesQuery()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
|
||||
const { semesters, currentSemester } = useSemesters()
|
||||
|
||||
if (!faculties || !currentSemester) {
|
||||
return <Loading />
|
||||
@@ -95,7 +94,7 @@ export const CoursePopulationCreditGainTable = ({
|
||||
const programme = findCorrectProgramme(
|
||||
student,
|
||||
codes,
|
||||
allSemesters,
|
||||
semesters,
|
||||
new Date(from),
|
||||
new Date(to),
|
||||
currentSemester.semestercode
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ export const ExternalGradeFilterToggle = ({ grade }) => {
|
||||
active={isActive}
|
||||
applyFilter={() => filterDispatch(gradeFilter.actions.selectGrade(grade))}
|
||||
clearFilter={() => filterDispatch(gradeFilter.actions.unselectGrade(grade))}
|
||||
disabled={false}
|
||||
popupContent="Rajaa opiskelijat kurssin arvosanan perusteella."
|
||||
/>
|
||||
)
|
||||
@@ -28,8 +28,8 @@ import { PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/StudentTable/GeneralTab/format/index'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
@@ -81,12 +81,12 @@ export const CoursePopulation = () => {
|
||||
[population?.students, codes]
|
||||
)
|
||||
|
||||
const { data: semesters, isFetching: semestersFetching } = useGetSemestersQuery()
|
||||
const {
|
||||
semesters: allSemesters,
|
||||
years: semesterYears,
|
||||
currentSemester,
|
||||
} = semesters ?? { semesters: {}, years: {}, currentSemester: null }
|
||||
isLoading: semestersFetching,
|
||||
} = useSemesters()
|
||||
|
||||
const getFromToDates = (from: number, to: number, separate: boolean) => {
|
||||
if (semestersFetching) return {}
|
||||
@@ -115,7 +115,7 @@ export const CoursePopulation = () => {
|
||||
? (getTextIn(singleSemester.name) ?? '')
|
||||
: `${new Date(dateFrom).getFullYear()}-${new Date(dateTo).getFullYear()}`
|
||||
|
||||
if (!population || !semesters) return <PageLoading isLoading />
|
||||
if (!population || !currentSemester) return <PageLoading isLoading />
|
||||
|
||||
const getStudentRelevantProgrammes = (students: FormattedStudent[]) =>
|
||||
students.reduce<Map<string, string>>((programmes, student) => {
|
||||
@@ -199,48 +199,49 @@ export const CoursePopulation = () => {
|
||||
},
|
||||
]
|
||||
|
||||
const filters = [
|
||||
genderFilter(),
|
||||
studentNumberFilter(),
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
{
|
||||
key: 'attainment',
|
||||
label: 'Attainment',
|
||||
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
|
||||
const correctProgramme = findCorrectProgramme(
|
||||
student,
|
||||
codes,
|
||||
allSemesters,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo),
|
||||
currentSemester?.semestercode
|
||||
)
|
||||
return correctProgramme?.code === studyRightElement.code
|
||||
},
|
||||
description: 'Student had an active study right at the time of course attainment.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
gradeFilter({
|
||||
courseCodes: codes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
coursestatistics={population?.coursestatistics}
|
||||
displayTray={!isFetching}
|
||||
filters={[
|
||||
genderFilter(),
|
||||
studentNumberFilter(),
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
{
|
||||
key: 'attainment',
|
||||
label: 'Attainment',
|
||||
predicate: (student: FormattedStudent, studyRightElement: SISStudyRightElement) => {
|
||||
const correctProgramme = findCorrectProgramme(
|
||||
student,
|
||||
codes,
|
||||
allSemesters,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo),
|
||||
currentSemester?.semestercode
|
||||
)
|
||||
return correctProgramme?.code === studyRightElement.code
|
||||
},
|
||||
description: 'Student had an active study right at the time of course attainment.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
gradeFilter({
|
||||
courseCodes: codes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
]}
|
||||
filters={filters}
|
||||
initialOptions={{
|
||||
[programmeFilter.key]: { mode: 'attainment', selectedProgrammes: [] },
|
||||
}}
|
||||
name="CoursePopulation"
|
||||
students={population?.students ?? []}
|
||||
>
|
||||
{(filteredStudents, filteredCourses) => (
|
||||
|
||||
@@ -15,16 +15,15 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { PopulationStudents } from '@/components/PopulationStudents'
|
||||
import { useFormat as formatGeneralTab } from '@/components/PopulationStudents/StudentTable/GeneralTab/format/index'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { RightsNotification } from '@/components/RightsNotification'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
import { useProgress } from '@/hooks/progress'
|
||||
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
|
||||
import { KeyboardBackspaceIcon, LabelIcon } from '@/theme'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { PageTitle } from '../common/PageTitle'
|
||||
import { StudentAmountLimiter } from '../common/StudentAmountLimiter'
|
||||
import { Loading } from '../Loading'
|
||||
import { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
|
||||
import { useColumns as columnsGeneralTab } from './studentColumns'
|
||||
import { UnihowDataExport } from './UnihowDataExport'
|
||||
@@ -61,8 +60,6 @@ export const CustomPopulationContent = ({
|
||||
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
|
||||
}
|
||||
|
||||
const { progress } = useProgress(isFetchingPopulation)
|
||||
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
@@ -147,7 +144,7 @@ export const CustomPopulationContent = ({
|
||||
return (
|
||||
<Box>
|
||||
{isFetchingPopulation ? (
|
||||
<ProgressBar progress={progress} />
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<PageTitle title={populationName ? `Custom population: ${populationName}` : 'Custom population'}>
|
||||
|
||||
+4
-5
@@ -11,7 +11,7 @@ import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '@/components/FilterView/filters/programmes'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { FormattedStudent, Name } from '@oodikone/shared/types'
|
||||
import { findCorrectProgramme, NO_PROGRAMME } from './util'
|
||||
|
||||
@@ -27,9 +27,8 @@ export const CustomPopulationProgrammeDist = ({
|
||||
to?: string
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
|
||||
if (!allSemesters || !currentSemester) return null
|
||||
const { semesters, currentSemester } = useSemesters()
|
||||
if (!currentSemester) return null
|
||||
|
||||
const allProgrammes: Record<string, { name: Name; programmeStudents: number }> = {}
|
||||
for (const student of students) {
|
||||
@@ -39,7 +38,7 @@ export const CustomPopulationProgrammeDist = ({
|
||||
programme = findCorrectProgramme(
|
||||
student,
|
||||
coursecodes,
|
||||
allSemesters,
|
||||
semesters,
|
||||
new Date(from!),
|
||||
new Date(to!),
|
||||
currentSemester.semestercode
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import isBetween from 'dayjs/plugin/isBetween'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import { getNewestProgrammeOfStudentAt, getSemesterCodeAt } from '@/common'
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
tagsFilter,
|
||||
transferredToProgrammeFilter,
|
||||
} from '@/components/FilterView/filters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetCustomPopulationQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
import { CustomPopulationState } from '.'
|
||||
import { CustomPopulationContent } from './CustomPopulationContent'
|
||||
|
||||
@@ -28,8 +27,7 @@ export const CustomPopulationWrapper = ({
|
||||
customPopulationState: CustomPopulationState
|
||||
resetState: () => void
|
||||
}) => {
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} }
|
||||
const { semesters } = useSemesters()
|
||||
|
||||
const { data: population, isFetching } = useGetCustomPopulationQuery(
|
||||
{
|
||||
@@ -56,7 +54,7 @@ export const CustomPopulationWrapper = ({
|
||||
programmeFilter(),
|
||||
creditDateFilter(),
|
||||
enrollmentStatusFilter({
|
||||
allSemesters,
|
||||
allSemesters: semesters,
|
||||
programme: associatedProgramme,
|
||||
}),
|
||||
]
|
||||
@@ -64,7 +62,7 @@ export const CustomPopulationWrapper = ({
|
||||
filtersList.push(hopsFilter({ programmeCode: associatedProgramme, combinedProgrammeCode: '' }))
|
||||
}
|
||||
return filtersList
|
||||
}, [population, allSemesters, associatedProgramme])
|
||||
}, [population, semesters, associatedProgramme])
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
@@ -72,7 +70,6 @@ export const CustomPopulationWrapper = ({
|
||||
displayTray={populationStudents.length > 0}
|
||||
filters={filters}
|
||||
initialOptions={{}}
|
||||
name="CustomPopulation"
|
||||
students={populationStudents}
|
||||
>
|
||||
{(filteredStudents, filteredCourses) => (
|
||||
|
||||
@@ -6,27 +6,38 @@ import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker/DatePicker'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { useRef, type MutableRefObject } from 'react'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Ref, useRef, type MutableRefObject } from 'react'
|
||||
import 'dayjs/locale/fi'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { useGetSemestersQuery, type SemestersData } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
|
||||
export const DateSelector = ({ value, onChange, before, after, showSemesters }) => {
|
||||
const datetimeRef: MutableRefObject<HTMLInputElement | null> = useRef(null)
|
||||
const { data: semesters, isFetching } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? ({ semesters: {} } as SemestersData)
|
||||
export type DateSelectorValue = Dayjs | null
|
||||
|
||||
export const DateSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
before,
|
||||
after,
|
||||
showSemesters,
|
||||
}: {
|
||||
value: DateSelectorValue
|
||||
onChange: (input: DateSelectorValue) => void
|
||||
before?: DateSelectorValue
|
||||
after?: DateSelectorValue
|
||||
showSemesters: boolean
|
||||
}) => {
|
||||
const datetimeRef: MutableRefObject<Dayjs | null> = useRef(null)
|
||||
const { semesters, isLoading } = useSemesters()
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
if (isFetching) return null
|
||||
|
||||
if (!Object.keys(allSemesters).length) return null
|
||||
if (isLoading || !Object.keys(semesters).length) return null
|
||||
|
||||
const today = dayjs().startOf('day')
|
||||
|
||||
const triggerOnChange = date => {
|
||||
const triggerOnChange = (date: Dayjs) => {
|
||||
if (datetimeRef.current !== date) {
|
||||
datetimeRef.current = date
|
||||
onChange(datetimeRef.current)
|
||||
@@ -62,7 +73,7 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
|
||||
borderTop: '1px solid #f9f9f9',
|
||||
}}
|
||||
>
|
||||
{Object.values(allSemesters)
|
||||
{Object.values(semesters)
|
||||
.filter(({ startdate, enddate }) => {
|
||||
const start = dayjs(startdate)
|
||||
const end = dayjs(enddate)
|
||||
@@ -91,7 +102,7 @@ export const DateSelector = ({ value, onChange, before, after, showSemesters })
|
||||
className="date-picker"
|
||||
format={DateFormat.DISPLAY_DATE}
|
||||
formatDensity="dense"
|
||||
inputRef={datetimeRef}
|
||||
inputRef={datetimeRef as Ref<HTMLInputElement>}
|
||||
maxDate={dayjs(before ?? today)}
|
||||
minDate={after ? dayjs(after) : undefined}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -44,7 +44,7 @@ const dummyData = names.map(name => ({
|
||||
|
||||
export const ErrorBackground = ({ header, content }) => {
|
||||
useTitle(header)
|
||||
const [logout] = useLogoutMutation()
|
||||
const logout = useLogoutMutation()
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
|
||||
@@ -4,8 +4,8 @@ import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { setViewFilterOptions, resetViewFilter, resetAllViewFilters, selectViewFilters } from '@/redux/filters'
|
||||
import { useAppDispatch, useAppSelector } from '@/redux/hooks'
|
||||
import { setViewFilterOptions, resetViewFilter, resetAllViewFilters } from '@/redux/filters'
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
|
||||
import { FilterViewContext } from './context'
|
||||
import type { FilterContext } from './context'
|
||||
@@ -18,42 +18,18 @@ export type FilterTrayProps = {
|
||||
} & FilterContext
|
||||
|
||||
export const FilterTray = ({
|
||||
numberOfFilteredStudents,
|
||||
allStudents,
|
||||
filters,
|
||||
numberOfFilteredStudents,
|
||||
filtersInUse,
|
||||
}: {
|
||||
numberOfFilteredStudents: number
|
||||
allStudents: Student[]
|
||||
filters: Filter[]
|
||||
numberOfFilteredStudents: number
|
||||
filtersInUse: boolean
|
||||
}) => {
|
||||
const { viewName, getContextByKey } = useContext(FilterViewContext)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const storedOptions = useAppSelector(state => selectViewFilters(state, viewName))
|
||||
|
||||
const resetAllFilters = () => dispatch(resetAllViewFilters({ view: viewName }))
|
||||
|
||||
const filterSet = filters
|
||||
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
|
||||
.map(filter => {
|
||||
const { key, isActive, render } = filter
|
||||
const ctx = getContextByKey(key)
|
||||
|
||||
const active = isActive(ctx.options)
|
||||
const onClear = () => dispatch(resetViewFilter({ view: viewName, filter: key }))
|
||||
|
||||
const props: FilterTrayProps = {
|
||||
students: allStudents,
|
||||
onOptionsChange: options => dispatch(setViewFilterOptions({ view: viewName, filter: key, options })),
|
||||
...ctx,
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard active={active} filter={filter} key={key} onClear={onClear}>
|
||||
{render(props)}
|
||||
</FilterCard>
|
||||
)
|
||||
})
|
||||
const { getContextByKey } = useContext(FilterViewContext)
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -61,8 +37,8 @@ export const FilterTray = ({
|
||||
py: 3,
|
||||
px: 2,
|
||||
borderRadius: 0,
|
||||
maxWidth: '18em',
|
||||
flex: '0 0 18em',
|
||||
width: '20em',
|
||||
minWidth: '20em',
|
||||
height: 'fit-content',
|
||||
}}
|
||||
variant="outlined"
|
||||
@@ -78,14 +54,36 @@ export const FilterTray = ({
|
||||
color="inherit"
|
||||
data-cy="reset-all-filters"
|
||||
disableElevation
|
||||
disabled={!filters.some(({ key }) => !!storedOptions[key])}
|
||||
onClick={resetAllFilters}
|
||||
disabled={!filtersInUse}
|
||||
onClick={() => dispatch(resetAllViewFilters())}
|
||||
variant="contained"
|
||||
>
|
||||
Reset All Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
{filterSet}
|
||||
{filters
|
||||
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
|
||||
.map(filter => {
|
||||
const { key, isActive, render } = filter
|
||||
const ctx = getContextByKey(key)
|
||||
|
||||
const props: FilterTrayProps = {
|
||||
...ctx,
|
||||
students: allStudents,
|
||||
onOptionsChange: options => dispatch(setViewFilterOptions({ filter: key, options })),
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
active={isActive(ctx.options)}
|
||||
filter={filter}
|
||||
key={key}
|
||||
onClear={() => dispatch(resetViewFilter({ filter: key }))}
|
||||
>
|
||||
{render(props)}
|
||||
</FilterCard>
|
||||
)
|
||||
})}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ export type FilterContext = {
|
||||
}
|
||||
|
||||
export type FilterViewContextState = {
|
||||
viewName: string
|
||||
getContextByKey: (key: string) => FilterContext
|
||||
}
|
||||
|
||||
const defaultState: FilterViewContextState = {
|
||||
viewName: '<Unset>',
|
||||
getContextByKey: () => ({
|
||||
precomputed: null,
|
||||
options: {},
|
||||
|
||||
@@ -66,7 +66,7 @@ export const admissionTypeFilter = createFilter({
|
||||
.flatMap(student =>
|
||||
findAllStudyRightsForProgramme(student, args.programme)
|
||||
.filter(studyRight => !studyRight.cancelled && !!studyRight.admissionType)
|
||||
.map(studyRight => studyRight.admissionType)
|
||||
.map(studyRight => (studyRight.admissionType !== 'Koepisteet' ? studyRight.admissionType : 'Valintakoe'))
|
||||
)
|
||||
.reduce((acc, cur) => {
|
||||
acc[cur] ??= 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useDebounce } from '@/hooks/debounce'
|
||||
import { getAge } from '@/util/timeAndDate'
|
||||
@@ -9,12 +9,9 @@ import { createFilter } from './createFilter'
|
||||
const AgeFilterCard = ({ options, onOptionsChange, precomputed: bounds }: FilterTrayProps) => {
|
||||
const { min, max } = bounds
|
||||
|
||||
const onChange = useCallback(
|
||||
([min, max]) => {
|
||||
onOptionsChange({ min, max })
|
||||
},
|
||||
[onOptionsChange]
|
||||
)
|
||||
const onChange = ([min, max]) => {
|
||||
onOptionsChange({ min, max })
|
||||
}
|
||||
|
||||
const value: [number, number] = useMemo(
|
||||
() => [options.min ?? min, options.max ?? max],
|
||||
|
||||
@@ -32,24 +32,20 @@ export const FilterCard = ({
|
||||
|
||||
const handleOnClick = () => setOpened(state => !state)
|
||||
|
||||
const iconStyle = {
|
||||
transition: 'transform 300ms',
|
||||
...(opened ? { transform: 'rotate(90deg)' } : {}),
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
data-active={active}
|
||||
data-cy={`${key}-filter-card`}
|
||||
padding={0.5}
|
||||
spacing={Number(opened) * 1.2}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<Stack data-active={active} data-cy={`${key}-filter-card`} padding={0.5} spacing={Number(opened) * 1.2}>
|
||||
<Box
|
||||
data-cy={`${key}-header`}
|
||||
sx={{ alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'inline-flex' }}
|
||||
>
|
||||
<IconButton onClick={handleOnClick} size="small" sx={iconStyle}>
|
||||
<IconButton
|
||||
onClick={handleOnClick}
|
||||
size="small"
|
||||
sx={{
|
||||
transition: 'transform 300ms',
|
||||
...(opened && { transform: 'rotate(90deg)' }),
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
@@ -64,7 +60,7 @@ export const FilterCard = ({
|
||||
{active ? (
|
||||
<ClearIcon
|
||||
data-cy={`${key}-clear`}
|
||||
onClick={() => onClear()}
|
||||
onClick={onClear}
|
||||
sx={{
|
||||
color: theme => theme.palette.error.dark,
|
||||
m: '0 0.5',
|
||||
|
||||
@@ -78,10 +78,7 @@ type FilterFactory = {
|
||||
key: string
|
||||
actions: Record<
|
||||
string,
|
||||
<P>(payload: P) => (
|
||||
view: string,
|
||||
getContext: FilterViewContextState['getContextByKey']
|
||||
) => {
|
||||
<P>(payload: P) => (getContext: FilterViewContextState['getContextByKey']) => {
|
||||
payload: P
|
||||
type: string
|
||||
}
|
||||
@@ -136,11 +133,10 @@ export const createFilter = (options: FilterOptions): FilterFactory => {
|
||||
const actions = mapValues(opt_actions, ([key, action]) => {
|
||||
return [
|
||||
key,
|
||||
payload => (view: string, getContext: FilterViewContextState['getContextByKey']) => {
|
||||
payload => (getContext: FilterViewContextState['getContextByKey']) => {
|
||||
const ctx = getContext(options.key)
|
||||
|
||||
return setViewFilterOptions({
|
||||
view,
|
||||
filter: options.key,
|
||||
options: action(structuredClone(ctx.options), payload),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from '@mui/material/Stack'
|
||||
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import type { SemestersData } from '@/redux/semesters'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import { FilterTrayProps } from '../FilterTray'
|
||||
import { FilterSelect } from './common/FilterSelect'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
@@ -71,8 +71,9 @@ export const studentNumberFilter = createFilter({
|
||||
filter(student, { options }) {
|
||||
const { allowlist, blocklist } = options
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
|
||||
return (
|
||||
(!allowlist.length || allowlist.includes(student.studentNumber)) && !blocklist.includes(student.studentNumber)
|
||||
)
|
||||
},
|
||||
|
||||
render: StudentNumberFilterCard,
|
||||
@@ -100,6 +101,6 @@ export const studentNumberFilter = createFilter({
|
||||
},
|
||||
|
||||
selectors: {
|
||||
studentListIsEqualToAllowlist: ({ allowlist }, students) => isEqual(allowlist, students),
|
||||
studentListIsEqualToAllowlist: ({ allowlist }, students) => students.length && isEqual(students, allowlist),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { useMemo, type FC, type ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import { selectViewFilters } from '@/redux/filters'
|
||||
import { useAppSelector } from '@/redux/hooks'
|
||||
import type { ExpandedCourseStats } from '@/redux/populations/util'
|
||||
import { filterCourses, type FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { filterCourses } from '@/util/coursesOfPopulation'
|
||||
import type { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
|
||||
|
||||
import { PageLayout } from '../common/PageLayout'
|
||||
import { FilterViewContext } from './context'
|
||||
import type { FilterContext, FilterViewContextState } from './context'
|
||||
import type { FilterContext } from './context'
|
||||
|
||||
import type { Filter } from './filters/createFilter'
|
||||
import { FilterTray } from './FilterTray'
|
||||
|
||||
export const FilterView: FC<{
|
||||
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
|
||||
name: string
|
||||
filters: Filter[]
|
||||
students: Student[]
|
||||
coursestatistics: ExpandedCourseStats | undefined
|
||||
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
|
||||
filters: Filter[]
|
||||
displayTray: boolean
|
||||
initialOptions: Record<Filter['key'], Filter['defaultOptions']>
|
||||
}> = ({ children, name, filters, students, coursestatistics, displayTray, initialOptions }) => {
|
||||
const storedOptions = useAppSelector(state => selectViewFilters(state, name))
|
||||
}> = ({ children, filters, students, coursestatistics, initialOptions }) => {
|
||||
const storedOptions = useAppSelector(state => selectViewFilters(state))
|
||||
const filtersInUse = filters.some(({ key }) => !!storedOptions[key])
|
||||
|
||||
const filterArgs = Object.fromEntries(filters.map(({ key, args }) => [key, args]))
|
||||
const filterOptions = useMemo(
|
||||
@@ -69,14 +71,17 @@ export const FilterView: FC<{
|
||||
return [fstudents, fcourses]
|
||||
}, [filters, filterOptions])
|
||||
|
||||
const ctxState: FilterViewContextState = { viewName: name, getContextByKey: getFilterContext }
|
||||
|
||||
return (
|
||||
<FilterViewContext.Provider value={ctxState}>
|
||||
<Stack direction="row" id="filterview-stack">
|
||||
{displayTray ? (
|
||||
<FilterTray allStudents={students} filters={filters} numberOfFilteredStudents={filteredStudents.length} />
|
||||
) : null}
|
||||
<FilterViewContext.Provider value={{ getContextByKey: getFilterContext }}>
|
||||
<Stack direction="row">
|
||||
{!!coursestatistics && (
|
||||
<FilterTray
|
||||
allStudents={students}
|
||||
filters={filters}
|
||||
filtersInUse={filtersInUse}
|
||||
numberOfFilteredStudents={filteredStudents.length}
|
||||
/>
|
||||
)}
|
||||
<PageLayout maxWidth="80vw">{children(filteredStudents, filteredCourses)}</PageLayout>
|
||||
</Stack>
|
||||
</FilterViewContext.Provider>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import { FilterViewContext } from './context'
|
||||
|
||||
export const useFilters = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { getContextByKey, viewName } = useContext(FilterViewContext)
|
||||
const { getContextByKey } = useContext(FilterViewContext)
|
||||
|
||||
const filterDispatch = filterAction => dispatch(filterAction(viewName, getContextByKey))
|
||||
const filterDispatch = filterAction => dispatch(filterAction(getContextByKey))
|
||||
|
||||
const useFilterSelector = filterSelector => {
|
||||
const { options } = getContextByKey(filterSelector.filter)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ColorizedCoursesTable } from '@/components/ColorizedCoursesTable'
|
||||
import { useGetLanguageCenterDataQuery } from '@/redux/languageCenterView'
|
||||
|
||||
export const LanguageCenterView = () => (
|
||||
<ColorizedCoursesTable
|
||||
dividerText="Language center statistics"
|
||||
fetchDataHook={useGetLanguageCenterDataQuery}
|
||||
panes={['Faculties', 'Semesters']}
|
||||
title="Language center view"
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { ColorizedCoursesTable } from '@/components/ColorizedCoursesTable'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetLanguageCenterDataQuery } from '@/redux/languageCenterView'
|
||||
|
||||
export const LanguageCenterView = () => {
|
||||
useTitle('Language center view')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider sx={{ width: '100%', mt: 3, mb: 1 }}>
|
||||
<Typography fontSize="1.3rem" variant="overline">
|
||||
Language center statistics
|
||||
</Typography>
|
||||
</Divider>
|
||||
<ColorizedCoursesTable
|
||||
fetchDataHook={useGetLanguageCenterDataQuery}
|
||||
fetchDataHookParams={undefined}
|
||||
panes={['Faculties', 'Semesters']}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { useLogoutMutation } from '@/redux/auth'
|
||||
import { LogoutIcon } from '@/theme'
|
||||
|
||||
export const LogOutButton = () => {
|
||||
const [logout] = useLogoutMutation()
|
||||
const logout = useLogoutMutation()
|
||||
|
||||
return (
|
||||
<MenuItem disabled={isDev} onClick={() => logout()}>
|
||||
@@ -19,6 +19,8 @@ export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) =
|
||||
...getCommonPinningStyles(cell.column),
|
||||
width: size ?? undefined,
|
||||
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{flexRender(aggregate ? cell.column.columnDef.aggregatedCell : cell.column.columnDef.cell, cell.getContext())}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> })
|
||||
pageSize: Math.min(200, tableRows),
|
||||
})
|
||||
|
||||
// This hook is important to proc table re-render on page size change,
|
||||
// even when we are using the value from useState instead of table.getState().
|
||||
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
|
||||
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
|
||||
|
||||
@@ -30,7 +32,7 @@ export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> })
|
||||
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}
|
||||
rowsPerPage={pagination.pageSize}
|
||||
rowsPerPageOptions={pageRowOptions}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -234,11 +234,6 @@ export const PassFailEnrollments = ({
|
||||
state: { expanded },
|
||||
}
|
||||
|
||||
// HACK: Courses of class "curriculum" view doesn't render otherwise
|
||||
// Also if this is removed and "Show all ..." returns no fields,
|
||||
// the toggle Course <-> Module get's removed.
|
||||
if (courseTableMode === 'curriculum' && !data.length) return null
|
||||
|
||||
return (
|
||||
<OodiTable
|
||||
columns={columns}
|
||||
|
||||
@@ -22,12 +22,12 @@ type PopulationCourseStatsProps = {
|
||||
}
|
||||
|
||||
const visibleCoursesFilter = (course: CourseStats, mandatoryCourses: CurriculumDetails) =>
|
||||
mandatoryCourses?.defaultProgrammeCourses?.some(
|
||||
mandatoryCourses.defaultProgrammeCourses.some(
|
||||
programmeCourse => programmeCourse.code === course.code && programmeCourse.visible.visibility
|
||||
) ??
|
||||
mandatoryCourses?.secondProgrammeCourses?.some(
|
||||
) ||
|
||||
mandatoryCourses.secondProgrammeCourses.some(
|
||||
programmeCourse => programmeCourse.code === course.code && programmeCourse.visible.visibility
|
||||
) ??
|
||||
) ||
|
||||
false
|
||||
|
||||
export const PopulationCourseStats = ({
|
||||
@@ -79,7 +79,11 @@ export const PopulationCourseStats = ({
|
||||
Object.keys(modules).map(parentCode => {
|
||||
const moduleWithCode = filteredCourses.find(course => course.course.code === parentCode)
|
||||
const { students, passed, passedOfPopulation } = moduleWithCode?.stats ?? {}
|
||||
modules[parentCode].module.stats = { students, passed, passedOfPopulation }
|
||||
modules[parentCode].module.stats = {
|
||||
students: students ?? 0,
|
||||
passed: passed ?? 0,
|
||||
passedOfPopulation: passedOfPopulation ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
setModules(
|
||||
|
||||
+4
-5
@@ -22,7 +22,7 @@ import { studentNumberFilter } from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
@@ -175,8 +175,7 @@ export const CreditDistributionDevelopment = ({
|
||||
const { getTextIn } = useLanguage()
|
||||
const { filterDispatch } = useFilters()
|
||||
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} as SemestersData['semesters'] }
|
||||
const { semesters } = useSemesters()
|
||||
|
||||
const timeSlots = (() => {
|
||||
const startDate = dayjs().year(year).endOf('year')
|
||||
@@ -190,7 +189,7 @@ export const CreditDistributionDevelopment = ({
|
||||
}
|
||||
|
||||
if (timeDivision === TimeDivision.ACADEMIC_YEAR) {
|
||||
return Object.values(groupBy(allSemesters, 'yearcode'))
|
||||
return Object.values(groupBy(semesters, 'yearcode'))
|
||||
.map(([a, b]) => [dayjs(a.startdate), dayjs(b.enddate)])
|
||||
.filter(([a, b]) => startDate.isBefore(b) && dayjs().isAfter(a))
|
||||
.map(([start, end]) => ({
|
||||
@@ -201,7 +200,7 @@ export const CreditDistributionDevelopment = ({
|
||||
}
|
||||
|
||||
if (timeDivision === TimeDivision.SEMESTER) {
|
||||
return Object.values(allSemesters)
|
||||
return Object.values(semesters)
|
||||
.filter(semester => startDate.isBefore(semester.enddate) && dayjs().isAfter(semester.startdate))
|
||||
.map(semester => ({
|
||||
start: dayjs(semester.startdate),
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ export const ExternalCreditFilterToggle = ({ students, helpText }: { students: s
|
||||
active={currentFilterIsActive}
|
||||
applyFilter={() => filterDispatch(studentNumberFilter.actions.setAllowlist(students))}
|
||||
clearFilter={() => filterDispatch(studentNumberFilter.actions.setAllowlist([]))}
|
||||
disabled={someFilterIsActive ? !currentFilterIsActive : null}
|
||||
disabled={someFilterIsActive ? !currentFilterIsActive : !students.length}
|
||||
popupContent={helpText}
|
||||
/>
|
||||
)
|
||||
|
||||
+3
-6
@@ -8,12 +8,10 @@ import { useState, Fragment } from 'react'
|
||||
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { VisibilityIcon } from '@/theme'
|
||||
|
||||
export const FilterDegreeCoursesModal = ({ degreeProgramme, years }) => {
|
||||
export const FilterDegreeCoursesModal = ({ degreeProgramme }: { degreeProgramme: string }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const setModalOpenState = state => {
|
||||
setOpen(state)
|
||||
}
|
||||
const setModalOpenState = (state: boolean) => setOpen(state)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -23,8 +21,7 @@ export const FilterDegreeCoursesModal = ({ degreeProgramme, years }) => {
|
||||
<Dialog className="MyDialogThings" maxWidth="md" onClose={() => setModalOpenState(false)} open={open}>
|
||||
<DialogTitle>Hide degree courses</DialogTitle>
|
||||
<DialogContent className="MyDialogContent">
|
||||
<DegreeCoursesTab degreeProgramme={degreeProgramme} years={years} />
|
||||
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setModalOpenState(false)}
|
||||
@@ -38,9 +38,7 @@ export const PopulationCourses = ({
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
|
||||
{query.programme && !onlyIamRights ? (
|
||||
<FilterDegreeCoursesModal degreeProgramme={query.programme} years={query.years} />
|
||||
) : null}
|
||||
{query.programme && !onlyIamRights ? <FilterDegreeCoursesModal degreeProgramme={query.programme} /> : null}
|
||||
<InfoBox content={tooltipText} />
|
||||
</Stack>
|
||||
<SegmentDimmer isLoading={isLoading} />
|
||||
|
||||
@@ -34,9 +34,9 @@ import { PopulationDetails } from '@/components/PopulationDetails'
|
||||
import { PopulationSearch } from '@/components/PopulationSearch'
|
||||
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useGetProgrammesQuery, useGetPopulationStatisticsQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { HelpOutlineIcon } from '@/theme'
|
||||
import { DegreeProgramme } from '@/types/api/faculty'
|
||||
import type { PopulationQuery } from '@/types/populationSearch'
|
||||
@@ -99,18 +99,19 @@ const useGetProgrammes = (): Record<string, DegreeProgramme> => {
|
||||
}
|
||||
}
|
||||
|
||||
const useGetProgrammeText = (programmeCode: string, combinedProgrammeCode?: string): string => {
|
||||
const useGetProgrammeTexts = (programmeCode: string, combinedProgrammeCode?: string) => {
|
||||
const { language, getTextIn } = useLanguage()
|
||||
|
||||
const programmes = useGetProgrammes()
|
||||
const programmeName = getTextIn(programmes[programmeCode]?.name) ?? ''
|
||||
const programmeId = programmes[programmeCode]?.progId ?? ''
|
||||
|
||||
if (combinedProgrammeCode) {
|
||||
const combinedProgrammeName = getTextIn(programmes[combinedProgrammeCode]?.name) ?? ''
|
||||
return getCombinedProgrammeName(programmeName, combinedProgrammeName, language)
|
||||
return { programmeName: getCombinedProgrammeName(programmeName, combinedProgrammeName, language), programmeId }
|
||||
}
|
||||
|
||||
return programmeName
|
||||
return { programmeName, programmeId }
|
||||
}
|
||||
|
||||
const HelpInfoCard = ({
|
||||
@@ -183,10 +184,9 @@ export const PopulationStatistics = () => {
|
||||
)
|
||||
|
||||
const showBachelorAndMaster = !!combinedProgrammeCode || !!query.showBachelorAndMaster
|
||||
const programmeText = useGetProgrammeText(programmeCode, combinedProgrammeCode)
|
||||
const { programmeName, programmeId } = useGetProgrammeTexts(programmeCode, combinedProgrammeCode)
|
||||
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters, currentSemester } = semesters ?? { semesters: {}, currentSemester: null }
|
||||
const { semesters: allSemesters, currentSemester } = useSemesters()
|
||||
|
||||
const enableStudyRightTypeFilter =
|
||||
useDegreeProgrammeTypes([programmeCode])?.[programmeCode] === 'urn:code:degree-program-type:masters-degree'
|
||||
@@ -257,7 +257,8 @@ export const PopulationStatistics = () => {
|
||||
in the class. For example, if you are looking for students of a specialist training in
|
||||
medicine or dentistry, you must choose “Students with non-degree study right”.`
|
||||
|
||||
const title = `${programmeText} ${getYearText(query.years)}${showBachelorAndMaster ? ' (Bachelor & Master view)' : ''} ${programmeCode}`
|
||||
const title = programmeName
|
||||
const semesterText = query.semesters.length === 1 ? ` (${query.semesters.at(0)})` : ''
|
||||
|
||||
// Show search form if URL contains no query
|
||||
if (skipQuery) return <PopulationSearch />
|
||||
@@ -269,7 +270,6 @@ export const PopulationStatistics = () => {
|
||||
displayTray={!isLoading}
|
||||
filters={filters}
|
||||
initialOptions={initialOptions}
|
||||
name="PopulationStatistics"
|
||||
students={students}
|
||||
>
|
||||
{(filteredStudents, filteredCourses) => (
|
||||
@@ -278,8 +278,17 @@ export const PopulationStatistics = () => {
|
||||
<PageTitle title={title}>
|
||||
<StudyTrackNames />
|
||||
<Typography color="text.secondary" fontWeight="standard" variant="h6">
|
||||
Class size {students.length} students
|
||||
{programmeId} - {programmeCode}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" fontWeight="standard" variant="h6">
|
||||
Class of {getYearText(query.years)}
|
||||
{semesterText}, {students.length} students
|
||||
</Typography>
|
||||
{showBachelorAndMaster ? (
|
||||
<Typography color="text.secondary" fontWeight="standard" variant="h6">
|
||||
Includes Master's studies
|
||||
</Typography>
|
||||
) : null}
|
||||
</PageTitle>
|
||||
{isError ? <HelpInfoCard body={errorCardBody} severity="error" title={errorCardTitle} /> : null}
|
||||
{dataIsReady && !students.length ? <HelpInfoCard body={helpCardBody} title={helpCardTitle} /> : null}
|
||||
|
||||
+2
@@ -81,6 +81,8 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
}),
|
||||
columnHelper.accessor('firstNames', { header: 'First names' }),
|
||||
columnHelper.accessor('lastName', { header: 'Last name' }),
|
||||
columnHelper.accessor('email', { header: 'email' }),
|
||||
columnHelper.accessor('secondaryEmail', { header: 'secondaryEmail' }),
|
||||
columnHelper.group({
|
||||
id: 'total',
|
||||
header: _ => <Typography fontWeight="bold">Summary</Typography>,
|
||||
|
||||
+4
-2
@@ -213,6 +213,8 @@ const studentMapper = (
|
||||
return {
|
||||
firstNames: student.firstnames,
|
||||
lastName: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
studentNumber: student.studentNumber,
|
||||
sisuID: student.sis_person_id,
|
||||
totalPassed,
|
||||
@@ -249,8 +251,8 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(true)
|
||||
|
||||
const columnVisibility: VisibilityState | undefined = useMemo(
|
||||
() => (!namesVisible ? { firstNames: false, lastName: false } : undefined),
|
||||
const columnVisibility: VisibilityState = useMemo(
|
||||
() => ({ firstNames: namesVisible, lastName: namesVisible, email: false, secondaryEmail: false }),
|
||||
[namesVisible]
|
||||
)
|
||||
|
||||
|
||||
+4
-18
@@ -93,7 +93,7 @@ export const useGetColumnDefinitions = ({
|
||||
if (!programmeName) return null
|
||||
return (
|
||||
<Tooltip arrow title={programmeName}>
|
||||
<span style={{ display: 'block', textOverflow: 'ellipsis', overflow: 'hidden' }}>{programmeName}</span>
|
||||
<span>{programmeName}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
@@ -111,7 +111,7 @@ export const useGetColumnDefinitions = ({
|
||||
if (!programmeName) return null
|
||||
return (
|
||||
<Tooltip arrow title={programmeName}>
|
||||
<span style={{ display: 'block', textOverflow: 'ellipsis', overflow: 'hidden' }}>{programmeName}</span>
|
||||
<span>{programmeName}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
@@ -253,12 +253,6 @@ export const useGetColumnDefinitions = ({
|
||||
|
||||
columnHelper.accessor('studyTrack', {
|
||||
header: 'Study track',
|
||||
cell: cell => {
|
||||
const studyTrack = cell.getValue()
|
||||
if (!studyTrack) return null
|
||||
|
||||
return <span style={{ display: 'block', textOverflow: 'ellipsis', overflow: 'hidden' }}>{studyTrack}</span>
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor('option', {
|
||||
@@ -269,15 +263,7 @@ export const useGetColumnDefinitions = ({
|
||||
|
||||
return (
|
||||
<Tooltip arrow title={value}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span>{value}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
@@ -328,7 +314,7 @@ export const useGetColumnDefinitions = ({
|
||||
|
||||
return (
|
||||
<Tooltip arrow title={transferSource}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{transferSource}</span>
|
||||
<span>{transferSource}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
|
||||
+12
-13
@@ -2,24 +2,15 @@ import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
|
||||
import { isFall, isMastersProgramme } from '@/common'
|
||||
import { type SemestersData } from '@/redux/semesters'
|
||||
import type { GetTextIn } from '@/components/LanguagePicker/useLanguage'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
|
||||
import './semestersPresent.css'
|
||||
import { CloseToGraduationData } from '@oodikone/shared/routes/populations'
|
||||
import type { CloseToGraduationData } from '@oodikone/shared/routes/populations'
|
||||
import { EnrollmentType } from '@oodikone/shared/types'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
|
||||
type SemesterPresentFnsProps = {
|
||||
getTextIn: (arg0: any) => string | null | undefined
|
||||
programme: string | null
|
||||
studentToSecondStudyrightEndMap: Map<string, string | null> | null
|
||||
studentToStudyrightEndMap: Map<string, string | null> | null
|
||||
year: number
|
||||
semestersToAddToStart: number | null
|
||||
semesters: SemestersData | null | undefined
|
||||
}
|
||||
|
||||
export const getSemestersPresentFunctions = ({
|
||||
getTextIn,
|
||||
programme,
|
||||
@@ -28,7 +19,15 @@ export const getSemestersPresentFunctions = ({
|
||||
year,
|
||||
semestersToAddToStart,
|
||||
semesters,
|
||||
}: SemesterPresentFnsProps) => {
|
||||
}: {
|
||||
getTextIn: GetTextIn
|
||||
programme: string | null
|
||||
studentToSecondStudyrightEndMap: Map<string, string | null> | null
|
||||
studentToStudyrightEndMap: Map<string, string | null> | null
|
||||
year: number
|
||||
semestersToAddToStart: number | null
|
||||
semesters: SemestersData
|
||||
}) => {
|
||||
const allSemesters = semesters?.semesters ?? {}
|
||||
const { semestercode: currentSemesterCode } = semesters?.currentSemester ?? { semestercode: 0 }
|
||||
|
||||
|
||||
+12
-11
@@ -1,8 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getAllProgrammesOfStudent, isFall } from '@/common'
|
||||
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
import { type SemestersData, useSemesters } from '@/hooks/useSemesters'
|
||||
import { DegreeProgrammeType, EnrollmentType, type FormattedStudent as Student } from '@oodikone/shared/types'
|
||||
import { StudentStudyRight } from '@oodikone/shared/types/studentData'
|
||||
|
||||
@@ -23,18 +23,16 @@ export const useGetRelevantSemesterData = (
|
||||
lastSemester: number
|
||||
}
|
||||
} => {
|
||||
const { isSuccess, data: semesters } = useGetSemestersQuery()
|
||||
const { semesters, currentSemester } = useSemesters()
|
||||
|
||||
if (!isSuccess) return { data: null, isSuccess }
|
||||
|
||||
const { currentSemester, semesters: allSemesters } = semesters
|
||||
const [firstSemester, lastSemester] = getFirstAndLastSemester(semesters, year)
|
||||
if (!currentSemester) return { data: null, isSuccess: false }
|
||||
const [firstSemester, lastSemester] = getFirstAndLastSemester(semesters, currentSemester, year)
|
||||
|
||||
return {
|
||||
isSuccess,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
currentSemester,
|
||||
allSemesters,
|
||||
allSemesters: semesters,
|
||||
|
||||
firstSemester,
|
||||
lastSemester,
|
||||
@@ -45,14 +43,17 @@ export const useGetRelevantSemesterData = (
|
||||
/*
|
||||
* NOTE: Developer HAS to make sure that useGetSemesters has cached data before this call
|
||||
*/
|
||||
const getFirstAndLastSemester = (semesters: SemestersData, year: number | undefined | null): [number, number] => {
|
||||
const { semesters: allSemesters, currentSemester } = semesters
|
||||
const getFirstAndLastSemester = (
|
||||
semesters: SemestersData['semesters'],
|
||||
currentSemester: SemestersData['currentSemester'],
|
||||
year: number | undefined | null
|
||||
): [number, number] => {
|
||||
const { semestercode: currentSemesterCode } = currentSemester ?? { semestercode: 0 }
|
||||
|
||||
const lastSemester = currentSemesterCode + 1 * Number(isFall(currentSemesterCode))
|
||||
|
||||
const firstSemester = year
|
||||
? (Object.values(allSemesters).find(
|
||||
? (Object.values(semesters).find(
|
||||
semester => new Date(semester.startdate).getTime() === new Date(Date.UTC(year, 7, 1)).getTime()
|
||||
)?.semestercode ?? lastSemester)
|
||||
: lastSemester - 13
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { SemestersData } from '@/hooks/useSemesters'
|
||||
import { formatDate, monthsVisited } from '@/util/timeAndDate'
|
||||
|
||||
import { CreditTypeCode, EnrollmentType } from '@oodikone/shared/types'
|
||||
|
||||
+4
-10
@@ -17,12 +17,11 @@ import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { CheckIcon, CloseIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { formatDate } from '@/util/timeAndDate'
|
||||
import { Semester } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, FormattedStudent, Name, ProgressCriteria, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
import { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { TableInfo } from './info'
|
||||
@@ -171,10 +170,7 @@ export const ProgressTable = ({
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
|
||||
const { data } = useGetSemestersQuery()
|
||||
// HACK: We want data to be semesters or empty object, but eslint doesn't like it. {} accepts also 0 and "" but that isn't possible here
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
const { semesters: allSemesters }: { semesters: Record<string, Semester> | {} } = data ?? { semesters: {} }
|
||||
const { semesters: allSemesters } = useSemesters()
|
||||
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
|
||||
const creditMonths = [12, 24, 36, 48, 60, 72]
|
||||
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'code')
|
||||
@@ -360,7 +356,7 @@ export const ProgressTable = ({
|
||||
}
|
||||
|
||||
const generateYearColumns = (startYear: dayjs.Dayjs, endYear: dayjs.Dayjs, criteriaIndex: number) => {
|
||||
const semesters = Object.values<Semester>(allSemesters)
|
||||
const semesters = Object.values(allSemesters)
|
||||
.filter(
|
||||
semester =>
|
||||
dayjs(semester.startdate).isSameOrAfter(startYear) && dayjs(semester.enddate).isSameOrBefore(endYear)
|
||||
@@ -401,7 +397,7 @@ export const ProgressTable = ({
|
||||
const startYear = dayjs(academicYearStart).add(year, 'years')
|
||||
const endYear = dayjs(academicYearEnd).add(year, 'years')
|
||||
|
||||
const semesters = Object.values<Semester>(allSemesters)
|
||||
const semesters = Object.values(allSemesters)
|
||||
.filter(
|
||||
semester =>
|
||||
dayjs(semester.startdate).isSameOrAfter(startYear) &&
|
||||
@@ -508,8 +504,6 @@ export const ProgressTable = ({
|
||||
|
||||
const tableOptions = {}
|
||||
|
||||
if (!students.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isStudyGuidanceGroupProgramme && (
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import Progress from '@mui/material/LinearProgress'
|
||||
import { number } from 'prop-types'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useDidMount } from '@/hooks/didMount'
|
||||
import './progressBar.css'
|
||||
|
||||
export const ProgressBar = ({ progress, fixed = false }) => {
|
||||
const timeoutId = useRef()
|
||||
const [hidden, setHidden] = useState(true)
|
||||
const [visible, setVisible] = useState(true)
|
||||
const didMount = useDidMount()
|
||||
|
||||
useEffect(() => {
|
||||
if (progress !== 100) {
|
||||
setHidden(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId.current) clearTimeout(timeoutId.current)
|
||||
}
|
||||
}, [didMount])
|
||||
|
||||
useEffect(() => {
|
||||
if (!didMount) return
|
||||
if (progress === 100) {
|
||||
setVisible(false)
|
||||
timeoutId.current = setTimeout(() => {
|
||||
setHidden(true)
|
||||
}, 2000)
|
||||
} else if (hidden || visible) {
|
||||
setVisible(true)
|
||||
setHidden(false)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
const classNames = []
|
||||
classNames.push(visible ? 'progressBar' : 'progressBarHidden')
|
||||
if (fixed) classNames.push('fixed')
|
||||
|
||||
return hidden ? null : (
|
||||
<Progress
|
||||
className={classNames.join(' ')}
|
||||
color="info"
|
||||
sx={{ height: '2em', borderRadius: '0.375em' }}
|
||||
value={progress}
|
||||
variant="determinate"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ProgressBar.propTypes = {
|
||||
progress: number.isRequired,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.progressBar,
|
||||
.progressBarHidden {
|
||||
margin: 10px 0 !important;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.progressBarHidden {
|
||||
animation-name: hide;
|
||||
animation-duration: 2s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.progressBar.fixed,
|
||||
.progressBarHidden.fixed {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,6 @@ export const SingleStudyGuidanceGroupContainer = ({ group }: { group: GroupsWith
|
||||
displayTray={!!population.coursestatistics}
|
||||
filters={viewFilters}
|
||||
initialOptions={initialOptions}
|
||||
name={`StudyGuidanceGroup(${group.id})`}
|
||||
students={population.students}
|
||||
>
|
||||
{(filteredStudents, filteredCourses) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import dayjs from 'dayjs'
|
||||
|
||||
import { isMastersProgramme } from '@/common'
|
||||
import * as filters from '@/components/FilterView/filters'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { GetCustomPopulationResBody } from '@oodikone/shared/routes/populations'
|
||||
import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
|
||||
|
||||
@@ -15,8 +15,7 @@ export const useGetFilters = (
|
||||
group: GroupsWithTags | undefined,
|
||||
population: Pick<GetCustomPopulationResBody, 'coursestatistics'> | undefined
|
||||
) => {
|
||||
const { data } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = data ?? { semesters: {} }
|
||||
const { semesters } = useSemesters()
|
||||
|
||||
const initialOptions = {}
|
||||
|
||||
@@ -28,7 +27,7 @@ export const useGetFilters = (
|
||||
const viewFilters = [
|
||||
filters.studentNumberFilter(),
|
||||
filters.enrollmentStatusFilter({
|
||||
allSemesters: allSemesters ?? [],
|
||||
allSemesters: semesters ?? [],
|
||||
programme: groupProgramme,
|
||||
}),
|
||||
filters.ageFilter(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user