mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96df524a7d | ||
|
|
c8a0826ac1 | ||
|
|
20306135fa | ||
|
|
74ccf48b8c | ||
|
|
87fab55064 | ||
|
|
8d952032b3 | ||
|
|
687372cdc1 | ||
|
|
066dc3afd6 | ||
|
|
9c1c3f5936 | ||
|
|
051e35eca8 | ||
|
|
0329a5f76b | ||
|
|
c72f559518 | ||
|
|
d186976046 | ||
|
|
8b17acac72 | ||
|
|
15eac1312e | ||
|
|
ac063950b3 | ||
|
|
66f8c61ea2 | ||
|
|
caed6946c0 | ||
|
|
683f7abea7 | ||
|
|
368acef144 | ||
|
|
c1b9e34a11 | ||
|
|
8bda55db5e | ||
|
|
f4a1efed15 | ||
|
|
617f13d08d | ||
|
|
b5d78239bc | ||
|
|
fcbb22ef58 | ||
|
|
472d2994e7 | ||
|
|
9da8ccf2dd | ||
|
|
74616b8fc4 | ||
|
|
5cb34997a9 | ||
|
|
abb0eb4787 | ||
|
|
1813168789 | ||
|
|
21d2e8b75d | ||
|
|
3474d6bfc3 | ||
|
|
2001eed8bc | ||
|
|
947f2e9b0f | ||
|
|
fef71263c1 | ||
|
|
55da115c19 | ||
|
|
f8b832c487 | ||
|
|
a270d2772d | ||
|
|
ee449ca4bd | ||
|
|
4b24187b54 | ||
|
|
84806e74b8 | ||
|
|
a4c0a4e602 | ||
|
|
c96e7b4bbd | ||
|
|
2064581fe8 | ||
|
|
e8b5eba560 | ||
|
|
a9a928575c | ||
|
|
3b4b54e9eb | ||
|
|
f11df69bcc | ||
|
|
493c424dd8 | ||
|
|
b4bf9be314 | ||
|
|
64fe802fc5 | ||
|
|
9e8f0c92c6 | ||
|
|
4b764f69d7 | ||
|
|
379a47aaee | ||
|
|
c5d16f411f | ||
|
|
18136274f5 | ||
|
|
76a7f3e597 | ||
|
|
be41b30a9b | ||
|
|
e46c718f86 | ||
|
|
b14d8fe085 | ||
|
|
06b8def25a | ||
|
|
841c30a3e1 | ||
|
|
bf29468f43 | ||
|
|
2902e5f072 | ||
|
|
766d2eb672 | ||
|
|
9d5c4f230e | ||
|
|
f658ef087d | ||
|
|
24692cf469 | ||
|
|
2d94e92350 | ||
|
|
4d029dc416 | ||
|
|
33a7740011 | ||
|
|
f3687b2e85 | ||
|
|
26e153ed05 | ||
|
|
cce80ca01e | ||
|
|
17266587b8 | ||
|
|
b6e477dfe9 | ||
|
|
e25bf0968a | ||
|
|
fcb5da2d84 | ||
|
|
cb0456c57c | ||
|
|
95e2bc1dfd | ||
|
|
4e2cd8198c | ||
|
|
b8915e8179 | ||
|
|
23e9a6d2cb | ||
|
|
107d83e254 | ||
|
|
08248da57d | ||
|
|
f7ae0e7f73 | ||
|
|
34047cbefe | ||
|
|
66e96f47ca | ||
|
|
c7b7a7b75d | ||
|
|
70cf120d09 | ||
|
|
1c1305c839 | ||
|
|
b5e3ba4212 | ||
|
|
7f262875fc | ||
|
|
9837231125 | ||
|
|
7339456f99 | ||
|
|
e65b860b1e | ||
|
|
2555991b3b | ||
|
|
18016688fb | ||
|
|
76edc86d01 | ||
|
|
9795c1b172 | ||
|
|
ff2f5d5770 | ||
|
|
990b778338 | ||
|
|
2dcf460a60 | ||
|
|
010a3435a8 | ||
|
|
136ca9f2d5 | ||
|
|
f243e996d5 | ||
|
|
336337728a | ||
|
|
ba2191790f | ||
|
|
690ab3c996 | ||
|
|
7b7c17e5b2 | ||
|
|
7bcbbf0bba | ||
|
|
e432c8ea07 | ||
|
|
3773c9f766 | ||
|
|
80ee1f0eb4 | ||
|
|
3475ca7691 | ||
|
|
b56383b4ef | ||
|
|
3fa91c715b | ||
|
|
539c8401fd | ||
|
|
48afd61942 | ||
|
|
671e0c62a3 | ||
|
|
f1c03b4425 | ||
|
|
c3da2389aa | ||
|
|
870664a99c | ||
|
|
4c85b5a240 | ||
|
|
7796c515a8 | ||
|
|
530c066993 | ||
|
|
8247ce80c3 | ||
|
|
7b8088e25c | ||
|
|
d2aebfb529 | ||
|
|
0d39a500c2 | ||
|
|
3ec2bf67c2 | ||
|
|
67ffcb0936 | ||
|
|
30861dbd2b | ||
|
|
522497a458 | ||
|
|
f8cb49da85 | ||
|
|
2689390ff5 | ||
|
|
da89addb64 | ||
|
|
e808233592 | ||
|
|
d3eb17b3c8 | ||
|
|
be23bb702d | ||
|
|
beb78f260c | ||
|
|
0dfc356f26 | ||
|
|
f195cdc923 | ||
|
|
234bb52a98 | ||
|
|
23972100b3 | ||
|
|
9ffee4e1b7 | ||
|
|
e7d01bb7e5 | ||
|
|
515ea95b5a | ||
|
|
ba69a8ceaa | ||
|
|
efa03ad2f7 | ||
|
|
25967f3904 | ||
|
|
5cabec2a65 | ||
|
|
bff11a5e10 | ||
|
|
5230df7fa4 | ||
|
|
47b15d7bd7 | ||
|
|
e35a44687f | ||
|
|
322e7d9fb0 | ||
|
|
4c293b2668 | ||
|
|
d1c95d96a6 | ||
|
|
7722db6944 | ||
|
|
0de825f726 | ||
|
|
a4d6d3e17e | ||
|
|
f33d9a5f70 | ||
|
|
639ee6b875 | ||
|
|
a5957ca119 | ||
|
|
6783919799 | ||
|
|
f6951b6749 | ||
|
|
05055ec7ba | ||
|
|
2d1659325c | ||
|
|
2ce4130988 | ||
|
|
011d70df19 | ||
|
|
46f8c36ec0 | ||
|
|
1edc18e7c2 | ||
|
|
553fcaa062 | ||
|
|
98a359f5e3 | ||
|
|
c929b51fbe | ||
|
|
9f0a0b6263 | ||
|
|
8d9868cbea | ||
|
|
96f9d710af | ||
|
|
0e7a8be6f0 | ||
|
|
edab4c7be9 | ||
|
|
4dc592beb3 | ||
|
|
7ec551d04c | ||
|
|
7335072042 | ||
|
|
6b64de8639 | ||
|
|
09ad10d3f6 | ||
|
|
ea52669d60 | ||
|
|
ec78960166 | ||
|
|
34da9e4b51 | ||
|
|
4d56d3033b | ||
|
|
45f4bf2f98 | ||
|
|
6cad2430f6 | ||
|
|
7e68fdc537 |
@@ -98,8 +98,8 @@ jobs:
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: toska
|
||||
SENTRY_PROJECT: oodikone
|
||||
with:
|
||||
|
||||
@@ -46,61 +46,60 @@ jobs:
|
||||
with:
|
||||
path: /tmp/scheduler.tar
|
||||
|
||||
test_updater:
|
||||
name: 'Test Updater'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build_worker
|
||||
- build_scheduler
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Download built images'
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: /tmp/
|
||||
- name: 'Load worker image'
|
||||
run: docker load --input /tmp/artifact/worker.tar
|
||||
- name: 'Load scheduler image'
|
||||
run: docker load --input /tmp/artifact/scheduler.tar
|
||||
- name: 'Start worker'
|
||||
run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
|
||||
- name: 'Run hourly jobs'
|
||||
run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: hourly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
- name: 'Run daily jobs'
|
||||
run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: daily
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
- name: 'Run weekly jobs'
|
||||
run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: weekly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
- name: 'Run prepurge'
|
||||
run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: prepurge
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
- name: 'Run purge'
|
||||
run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: purge
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
# test_updater:
|
||||
# name: 'Test Updater'
|
||||
# runs-on: ubuntu-latest
|
||||
# needs:
|
||||
# - build_worker
|
||||
# - build_scheduler
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - name: 'Login to toska docker registry'
|
||||
# uses: docker/login-action@v2
|
||||
# with:
|
||||
# registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
# username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
# password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
# - name: 'Download built images'
|
||||
# uses: actions/download-artifact@v3
|
||||
# with:
|
||||
# path: /tmp/
|
||||
# - name: 'Load worker image'
|
||||
# run: docker load --input /tmp/artifact/worker.tar
|
||||
# - name: 'Load scheduler image'
|
||||
# run: docker load --input /tmp/artifact/scheduler.tar
|
||||
# - name: 'Start worker'
|
||||
# run: docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
|
||||
# - name: 'Run hourly jobs'
|
||||
# run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
# env:
|
||||
# SCHEDULE_IMMEDIATE: hourly
|
||||
# EXIT_AFTER_IMMEDIATES: yes
|
||||
# - name: 'Run daily jobs'
|
||||
# run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
# env:
|
||||
# SCHEDULE_IMMEDIATE: daily
|
||||
# EXIT_AFTER_IMMEDIATES: yes
|
||||
# - name: 'Run weekly jobs'
|
||||
# run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
# env:
|
||||
# SCHEDULE_IMMEDIATE: weekly
|
||||
# EXIT_AFTER_IMMEDIATES: yes
|
||||
# - name: 'Run prepurge'
|
||||
# run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
# env:
|
||||
# SCHEDULE_IMMEDIATE: prepurge
|
||||
# EXIT_AFTER_IMMEDIATES: yes
|
||||
# - name: 'Run purge'
|
||||
# run: docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
|
||||
# env:
|
||||
# SCHEDULE_IMMEDIATE: purge
|
||||
# EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
release_worker:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
|
||||
# needs: test_updater
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -126,7 +125,7 @@ jobs:
|
||||
release_scheduler:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
# needs: test_updater
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -45,6 +45,10 @@ What different CLI options do:
|
||||
- Creates real data databases and populates them with downloaded dumps.
|
||||
3. Reset single database
|
||||
- Like option 2, but only for single database. Leaves other databases untouched.
|
||||
4. Restore data from dumps
|
||||
- Like option 2, but skips downloading, and simply restores databases from the dumps downloaded earlier.
|
||||
5. Docker system prune
|
||||
- Runs `docker system prune -a && docker volume prune -a`. This will remove all your docker data, also related to any other applications than oodikone! It is sometimes necessary after strange errors due to caching.
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
@@ -112,6 +116,8 @@ Some useful commands are defined in `package.json` and can be run with `npm run
|
||||
- `npm run both`: starts oodikone and updater with anonymized data
|
||||
- `npm run both:real`: starts oodikone and updater with real data
|
||||
- `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.
|
||||
|
||||
Once you have ran setup for oodikone , you can just execute the first one (`npm run oodikone`). After starting and waiting for a while for containers to compile, oodikone can be accessed at [http://localhost:3000/](http://localhost:3000/) and Adminer (database investigation tool) at [http://localhost:5050/](http://localhost:5050/). Adminer requires you to login with username `postgres` and with any password you choose (for example `p`).
|
||||
|
||||
@@ -177,9 +183,9 @@ Make sure you have your VPN on.
|
||||
|
||||
### Everything is broken, can't get oodikone running, data is not there etc.
|
||||
|
||||
Just do clean install by launching cli with `npm cli` and running option 1: _Set up oodikone from scratch_.
|
||||
First: Try `npm run cli` option 1: _Set up oodikone from scratch_ and then option 2 to download and restore databases (or option 4 to skip downloading, if you've done it recently).
|
||||
|
||||
If you don't want to reset all data, you can delete the docker images `docker compose down --rmi` (or just one) and they will be rebuilt when you start them. This is sometimes necessary if some commit has updated images or you switch to an older commit which has changed the images.
|
||||
If that does not help, try option 5: _Docker system prune_. Notice that this clears up **all docker data**, including data related to other projects. If this is not ok, you'll need to identify the oodikone-specific docker volumes yourself and caches and clear those invididually.
|
||||
|
||||
## ✌🏼 Maintainers and contribution
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ set_custom_select_prompt() {
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset single database."
|
||||
"Restore data from dumps"
|
||||
"Docker system prune"
|
||||
"Quit."
|
||||
)
|
||||
}
|
||||
@@ -72,6 +74,10 @@ while true; do
|
||||
reset_single_database
|
||||
set_custom_select_prompt # Set prompt back to initial values
|
||||
;;
|
||||
"Restore data from dumps")
|
||||
restore_data_from_dumps;;
|
||||
"Docker system prune")
|
||||
docker_prune;;
|
||||
"Quit.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
|
||||
@@ -15,5 +15,6 @@ module.exports = defineConfig({
|
||||
},
|
||||
baseUrl: 'http://localhost:3000',
|
||||
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
|
||||
testIsolation: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -90,7 +90,12 @@ describe('When search modal is opened', () => {
|
||||
'/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&courseList=TKT10003&courseList=TKT10004&studentList=010450938&studentList=010589388&studentList=010614509'
|
||||
)
|
||||
})
|
||||
it('Finds correct students and courses', () => {
|
||||
/*
|
||||
TODO: FIX
|
||||
This is skipped because it's flaky in github actions for unknown reasons.
|
||||
Instead of the results, a text appears saying "Something went wrong, please try refreshing the page".
|
||||
*/
|
||||
it.skip('Finds correct students and courses', () => {
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(5000)
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('Course Statistics tests', () => {
|
||||
it('Population of course shows grades for each student', () => {
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT20001')
|
||||
cy.wait(2000)
|
||||
cy.contains(/^TKT20001, AYTKT20001$/).click({ force: true })
|
||||
cy.contains(/^TKT20001, 58131, AYTKT20001$/).click({ force: true })
|
||||
cy.wait(2000)
|
||||
cy.contains('Fetch statistics').should('be.enabled').click({ force: true })
|
||||
cy.wait(2000)
|
||||
@@ -184,7 +184,7 @@ describe('Course Statistics tests', () => {
|
||||
|
||||
it('Language distribution is correct', () => {
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
|
||||
cy.contains(/^TKT20003$/).click()
|
||||
cy.contains(/^TKT20003, 582219$/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('TKT20003, 582219 Käyttöjärjestelmät')
|
||||
cy.get('[data-cy=unify_radio_reqular]').click()
|
||||
@@ -196,12 +196,87 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('td', 'english').siblings().contains('5')
|
||||
})
|
||||
|
||||
it('Shows correct statistics when course is not combined with its substitutions', () => {
|
||||
cy.get('[data-cy="combine-substitutions-toggle"]').should('have.class', 'checked').click()
|
||||
cy.get('[data-cy="combine-substitutions-toggle"]').should('not.have.class', 'checked')
|
||||
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT10002')
|
||||
cy.contains('td', /^TKT10002$/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 Ohjelmoinnin perusteet')
|
||||
|
||||
// Statistics
|
||||
const yearRange = { from: '2010-2011', to: '2020-2021' }
|
||||
const attemptsTableContents = [
|
||||
// [time, attempts, passed, failed]
|
||||
['Total', null, 290, 278, 12],
|
||||
]
|
||||
|
||||
const gradesTableContents = [
|
||||
// [time, attempts, 0, 1, 2, 3, 4, 5, other passed]
|
||||
['Total', null, 290, 12, 8, 9, 12, 17, 223, 9],
|
||||
['2020-2021', null, 1, 0, 0, 0, 0, 0, 1, 0],
|
||||
['2019-2020', null, 37, 1, 2, 0, 3, 3, 28],
|
||||
['2018-2019', null, 109, 4, 2, 5, 4, 6, 88, 0],
|
||||
['2017-2018', null, 129, 7, 3, 3, 3, 4, 101, 8],
|
||||
['2016-2017', null, 9, 0, 0, 1, 2, 2, 4, 0],
|
||||
['2015-2016', null, 2, 0, 0, 0, 0, 1, 1, 0],
|
||||
['2014-2015', null, 1, 0, 0, 0, 0, 0, 0, 1],
|
||||
['2013-2014', null, 1, 0, 1, 0, 0, 0, 0, 0],
|
||||
['2010-2011', null, 1, 0, 0, 0, 0, 1, 0, 0],
|
||||
]
|
||||
// Time range
|
||||
cy.get("div[name='fromYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', yearRange.to)
|
||||
cy.contains("div[role='option']", yearRange.from).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', '2010-2011')
|
||||
cy.get("div[role='option']").should('have.length', 11)
|
||||
})
|
||||
cy.get("div[name='toYear']").within(() => {
|
||||
cy.get("div[role='option']").first().should('have.text', '2020-2021')
|
||||
cy.contains("div[role='option']", yearRange.to).should('have.class', 'selected')
|
||||
cy.get("div[role='option']").last().should('have.text', yearRange.from)
|
||||
cy.get("div[role='option']").should('have.length', 11)
|
||||
})
|
||||
|
||||
cy.contains('#CourseStatPanes a.item', 'Table').click()
|
||||
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
|
||||
cy.get('#CourseStatPanes table>tbody').within(() => {
|
||||
attemptsTableContents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
if (value === null) return
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('tr').should('have.length', 10)
|
||||
})
|
||||
cy.get('[data-cy=gradeToggle]', { force: true }).click({ force: true })
|
||||
cy.get('#CourseStatPanes table>tbody').within(() => {
|
||||
gradesTableContents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
if (value === null) return
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('tr').should('have.length', 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('When searching unified course stats', () => {
|
||||
beforeEach(() => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT10002')
|
||||
cy.contains('td', 'TKT10002, AYTKT10002').click()
|
||||
cy.contains('td', /^TKT10002, 581325, A581325, AYTKT10002$/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
|
||||
@@ -367,6 +442,7 @@ describe('Course Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ! This test does not pass locally but it does pass on GitHub Actions (see issue #4317)
|
||||
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
cy.init('/coursestatistics', 'onlycoursestatistics')
|
||||
cy.get('[data-cy=navbar-courseStatistics]').click()
|
||||
|
||||
@@ -41,7 +41,7 @@ const deleteAllSearches = () => {
|
||||
}
|
||||
|
||||
const fillForm = content => {
|
||||
cy.contains('Insert studentnumbers you wish to use for population here')
|
||||
cy.contains('Insert student numbers you wish to use for population here')
|
||||
.siblings()
|
||||
.get('textarea')
|
||||
.type(content.join('\n'))
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('Faculty overview', () => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('1 graduated (100 % of class)').should('have.length', 1)
|
||||
cy.contains('1 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2018, 1/undefined students have graduated')
|
||||
cy.contains('From class of 2018, 1/1 students have graduated')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains('This view displays amounts')
|
||||
cy.contains('Show number of')
|
||||
}
|
||||
|
||||
describe('When language center is opened', () => {
|
||||
before(() => {
|
||||
cy.init('/languagecenterview', 'admin')
|
||||
hasLanded()
|
||||
cy.contains('All courses total')
|
||||
cy.contains('Academic and Professional')
|
||||
cy.contains('AYKK-RUKIRJ')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.get('[data-cy="completions-button"]').click()
|
||||
cy.get('[data-cy="semester-from"]').click()
|
||||
cy.contains('Syksy 2017').click()
|
||||
cy.get('[data-cy="semester-to"]').click()
|
||||
})
|
||||
|
||||
it('Faculties tab shows numbers', () => {
|
||||
cy.contains('All courses total')
|
||||
cy.contains('1339')
|
||||
cy.contains('832')
|
||||
cy.contains('505')
|
||||
})
|
||||
|
||||
it('Faculties tab ratio button works', () => {
|
||||
cy.get('[data-cy="ratio-button"]').click()
|
||||
cy.contains('0')
|
||||
cy.contains('Total ratio')
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
cy.get('[data-cy="semester-from"]').click()
|
||||
cy.contains('Syksy 2020').click()
|
||||
cy.contains('1339').should('not.exist')
|
||||
cy.contains('3')
|
||||
cy.contains('1')
|
||||
cy.contains('4')
|
||||
cy.contains('All courses total')
|
||||
})
|
||||
|
||||
it('Semester tab opens and contains coloring filter', () => {
|
||||
cy.contains('By semesters').click()
|
||||
cy.contains('50')
|
||||
cy.contains('191')
|
||||
cy.contains('210')
|
||||
cy.contains('308')
|
||||
cy.contains('1339')
|
||||
cy.contains('Coloring mode')
|
||||
})
|
||||
|
||||
it('Not visible to a user without right to see it', () => {
|
||||
cy.init('/languagecenterview')
|
||||
cy.contains('Access denied')
|
||||
cy.contains("You're currently not allowed to see this page.")
|
||||
cy.contains('Custom populations').click()
|
||||
cy.contains('Language center view').should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -67,31 +67,35 @@ describe('Population Statistics tests', () => {
|
||||
it('Population statistics is usable on general level', () => {
|
||||
cy.visit(pathToCSBach2017)
|
||||
cy.cs('filtered-students')
|
||||
cy.get('.header').within(() => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('class size 170 students')
|
||||
})
|
||||
cy.get('.card').within(() => {
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('class size 170 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')
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Courses of class').click()
|
||||
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(25000) // a bit hacky way, wait until ui is ready
|
||||
cy.get('[data-cy=toggle-group-module-TKT1]').click()
|
||||
cy.contains('td', 'TKT10002').siblings().find('i.level.up').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
|
||||
// Tests that use curriculum structure are commented out until
|
||||
// the test database is fixed to the same structure as real database
|
||||
// after the modules rework
|
||||
// cy.contains('Courses of class').click()
|
||||
|
||||
// cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// // eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
// cy.wait(25000) // a bit hacky way, wait until ui is ready
|
||||
// cy.get('[data-cy=toggle-group-module-TKT1]').click()
|
||||
// cy.contains('td', 'TKT10002').siblings().find('i.level.up').click()
|
||||
// cy.wait('@coursePage')
|
||||
// cy.url().should('include', '/coursestatistics')
|
||||
// cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.visit(pathToCSBach2017)
|
||||
cy.contains('Students (170)').click()
|
||||
cy.contains('Students (170)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (170)').click()
|
||||
})
|
||||
cy.get('[data-cy=student-table-tabs]').contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.contains('Tags').click()
|
||||
@@ -123,15 +127,21 @@ describe('Population Statistics tests', () => {
|
||||
|
||||
it('Credit Statistics, Credits Gained tab works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits Gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const today = moment().endOf('month')
|
||||
const months = Math.round(moment.duration(moment(today).diff(moment('2017-08-01'))).asMonths())
|
||||
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
|
||||
// Months are capped to goal time of the degree, 36 months is the goal time for bachelor's degree
|
||||
const monthsForLimits = Math.min(36, months)
|
||||
const limits = [1, ..._.range(1, 5).map(p => Math.ceil(monthsForLimits * ((p * 15) / 12))), null]
|
||||
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', '(n=170)')
|
||||
cy.get('.credits-gained-table').should('contain', '(n = 170)')
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
let value = 'credits'
|
||||
@@ -159,7 +169,11 @@ describe('Population Statistics tests', () => {
|
||||
cy.visit(pathToCSMaster2019)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Credit statistics')
|
||||
cy.get("[data-cy='credit-statistics']").click()
|
||||
cy.get("[data-cy='Credit statistics']")
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.get("[data-cy='Credit statistics']").click()
|
||||
})
|
||||
cy.contains('Credits Gained').click()
|
||||
cy.get('.credits-gained-divider').click()
|
||||
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
|
||||
@@ -170,7 +184,11 @@ describe('Population Statistics tests', () => {
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click({ force: true })
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
|
||||
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 170 Students')
|
||||
@@ -189,7 +207,11 @@ describe('Population Statistics tests', () => {
|
||||
const existing = '010113437'
|
||||
const nonExisting = '66666666'
|
||||
cy.visit(pathToCSBach2017)
|
||||
cy.contains('Students (170)').click()
|
||||
cy.contains('Students (170)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (170)').click()
|
||||
})
|
||||
cy.contains(existing)
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check studentnumbers').click()
|
||||
|
||||
@@ -39,7 +39,6 @@ describe('Student Statistics tests', () => {
|
||||
cy.url().should('include', '/students')
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Started: 01.08.2017')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma (01.08.2017 - 31.07.2025')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
@@ -66,6 +65,7 @@ describe('Student Statistics tests', () => {
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Introduction to Machine Learning (DATA11002)')
|
||||
.siblings()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.get('.level').click()
|
||||
})
|
||||
@@ -103,7 +103,6 @@ describe('Testing with admin rights', () => {
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
const studentNumber = '011825096'
|
||||
cy.init(`/students/${studentNumber}`, 'admin') // use admin to see all students
|
||||
cy.contains('Started: Unavailable')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -284,11 +284,14 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const tableContents = [
|
||||
['2020 - 2021', 12, 12, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2019 - 2020', 86, 86, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2018 - 2019', 162, 162, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2017 - 2018', 171, 171, 0, 0, 0, 0, 0, 0, 0],
|
||||
['Total', 431, 431, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2023 - 2024', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2021 - 2022', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2020 - 2021', 12, 12, 0, 0, 0, 0, 0, 0],
|
||||
['2019 - 2020', 86, 86, 0, 0, 0, 0, 0, 0],
|
||||
['2018 - 2019', 162, 162, 0, 0, 0, 0, 0, 0],
|
||||
['2017 - 2018', 171, 171, 0, 0, 0, 0, 0, 0],
|
||||
['Total', 431, 431, 0, 0, 0, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackProgress')
|
||||
@@ -296,9 +299,9 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
it('Studytrack overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudytrackProgress]')
|
||||
.should('contain', 'Less than 15 credits')
|
||||
.should('contain', '30-59 credits')
|
||||
.should('contain', '180 or more credits')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '100.0%') // The percentage for less than 15 credits in 2017-2018, to check that the graph renders
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
|
||||
+6
-5
@@ -89,6 +89,8 @@ services:
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
@@ -98,6 +100,8 @@ services:
|
||||
- updater
|
||||
volumes:
|
||||
- sis-importer-db-data:/data
|
||||
ports:
|
||||
- 12346:5432
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
@@ -173,14 +177,11 @@ services:
|
||||
|
||||
jami:
|
||||
<<: *defaults
|
||||
image: toska/jami
|
||||
image: quay.io/toska/jami:production
|
||||
environment:
|
||||
- PORT=3003
|
||||
- NODE_ENV=dev
|
||||
- POSTGRES_HOST=jami-db
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
Generated
+172
-78
@@ -15,7 +15,7 @@
|
||||
"@babel/eslint-parser": "^7.19.1",
|
||||
"@babel/plugin-syntax-jsx": "^7.16.7",
|
||||
"concurrently": "^7.4.0",
|
||||
"cypress": "^10.9.0",
|
||||
"cypress": "^12.17.4",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -447,9 +447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/request": {
|
||||
"version": "2.88.10",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz",
|
||||
"integrity": "sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg==",
|
||||
"version": "2.88.12",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
|
||||
"integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
@@ -465,9 +465,9 @@
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.2",
|
||||
"qs": "~6.10.3",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "~2.5.0",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
@@ -681,9 +681,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "14.18.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.22.tgz",
|
||||
"integrity": "sha512-qzaYbXVzin6EPjghf/hTdIbnVW1ErMx8rPzwRNJhlbyJhu2SyqlvjGOY/tbUt6VFyzg56lROcOeSQRInpt63Yw==",
|
||||
"version": "16.18.54",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.54.tgz",
|
||||
"integrity": "sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/normalize-package-data": {
|
||||
@@ -991,9 +991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws4": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
|
||||
"integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz",
|
||||
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
@@ -1352,9 +1352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
|
||||
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -1571,15 +1571,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cypress": {
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.9.0.tgz",
|
||||
"integrity": "sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA==",
|
||||
"version": "12.17.4",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz",
|
||||
"integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cypress/request": "^2.88.10",
|
||||
"@cypress/request": "2.88.12",
|
||||
"@cypress/xvfb": "^1.2.4",
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/node": "^16.18.39",
|
||||
"@types/sinonjs__fake-timers": "8.1.1",
|
||||
"@types/sizzle": "^2.3.2",
|
||||
"arch": "^2.2.0",
|
||||
@@ -1591,10 +1591,10 @@
|
||||
"check-more-types": "^2.24.0",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"cli-table3": "~0.6.1",
|
||||
"commander": "^5.1.0",
|
||||
"commander": "^6.2.1",
|
||||
"common-tags": "^1.8.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"debug": "^4.3.2",
|
||||
"debug": "^4.3.4",
|
||||
"enquirer": "^2.3.6",
|
||||
"eventemitter2": "6.4.7",
|
||||
"execa": "4.1.0",
|
||||
@@ -1609,12 +1609,13 @@
|
||||
"listr2": "^3.8.3",
|
||||
"lodash": "^4.17.21",
|
||||
"log-symbols": "^4.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"minimist": "^1.2.8",
|
||||
"ospath": "^1.2.2",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"process": "^0.11.10",
|
||||
"proxy-from-env": "1.0.0",
|
||||
"request-progress": "^3.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"semver": "^7.5.3",
|
||||
"supports-color": "^8.1.1",
|
||||
"tmp": "~0.2.1",
|
||||
"untildify": "^4.0.0",
|
||||
@@ -1624,7 +1625,7 @@
|
||||
"cypress": "bin/cypress"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
"node": "^14.0.0 || ^16.0.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cypress/node_modules/ansi-styles": {
|
||||
@@ -1698,9 +1699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cypress/node_modules/semver": {
|
||||
"version": "7.3.7",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
||||
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
@@ -4765,10 +4766,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
|
||||
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
|
||||
"dev": true
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist-options": {
|
||||
"version": "4.1.0",
|
||||
@@ -5400,6 +5404,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -5443,14 +5456,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
|
||||
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
|
||||
"integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5708,6 +5733,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
@@ -6470,16 +6501,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie/node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
@@ -6640,6 +6682,16 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -7176,9 +7228,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"@cypress/request": {
|
||||
"version": "2.88.10",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz",
|
||||
"integrity": "sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg==",
|
||||
"version": "2.88.12",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
|
||||
"integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"aws-sign2": "~0.7.0",
|
||||
@@ -7194,9 +7246,9 @@
|
||||
"json-stringify-safe": "~5.0.1",
|
||||
"mime-types": "~2.1.19",
|
||||
"performance-now": "^2.1.0",
|
||||
"qs": "~6.5.2",
|
||||
"qs": "~6.10.3",
|
||||
"safe-buffer": "^5.1.2",
|
||||
"tough-cookie": "~2.5.0",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
@@ -7365,9 +7417,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "14.18.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.22.tgz",
|
||||
"integrity": "sha512-qzaYbXVzin6EPjghf/hTdIbnVW1ErMx8rPzwRNJhlbyJhu2SyqlvjGOY/tbUt6VFyzg56lROcOeSQRInpt63Yw==",
|
||||
"version": "16.18.54",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.54.tgz",
|
||||
"integrity": "sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
@@ -7592,9 +7644,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"aws4": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
|
||||
"integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz",
|
||||
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==",
|
||||
"dev": true
|
||||
},
|
||||
"axe-core": {
|
||||
@@ -7846,9 +7898,9 @@
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
|
||||
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
|
||||
"dev": true
|
||||
},
|
||||
"common-tags": {
|
||||
@@ -8006,14 +8058,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"cypress": {
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.9.0.tgz",
|
||||
"integrity": "sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA==",
|
||||
"version": "12.17.4",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz",
|
||||
"integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@cypress/request": "^2.88.10",
|
||||
"@cypress/request": "2.88.12",
|
||||
"@cypress/xvfb": "^1.2.4",
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/node": "^16.18.39",
|
||||
"@types/sinonjs__fake-timers": "8.1.1",
|
||||
"@types/sizzle": "^2.3.2",
|
||||
"arch": "^2.2.0",
|
||||
@@ -8025,10 +8077,10 @@
|
||||
"check-more-types": "^2.24.0",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"cli-table3": "~0.6.1",
|
||||
"commander": "^5.1.0",
|
||||
"commander": "^6.2.1",
|
||||
"common-tags": "^1.8.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"debug": "^4.3.2",
|
||||
"debug": "^4.3.4",
|
||||
"enquirer": "^2.3.6",
|
||||
"eventemitter2": "6.4.7",
|
||||
"execa": "4.1.0",
|
||||
@@ -8043,12 +8095,13 @@
|
||||
"listr2": "^3.8.3",
|
||||
"lodash": "^4.17.21",
|
||||
"log-symbols": "^4.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"minimist": "^1.2.8",
|
||||
"ospath": "^1.2.2",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"process": "^0.11.10",
|
||||
"proxy-from-env": "1.0.0",
|
||||
"request-progress": "^3.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"semver": "^7.5.3",
|
||||
"supports-color": "^8.1.1",
|
||||
"tmp": "~0.2.1",
|
||||
"untildify": "^4.0.0",
|
||||
@@ -8107,9 +8160,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.3.7",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
||||
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
@@ -10312,9 +10365,9 @@
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
|
||||
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true
|
||||
},
|
||||
"minimist-options": {
|
||||
@@ -10761,6 +10814,12 @@
|
||||
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
|
||||
"dev": true
|
||||
},
|
||||
"process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"dev": true
|
||||
},
|
||||
"prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -10801,9 +10860,18 @@
|
||||
"dev": true
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
|
||||
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
|
||||
"integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"side-channel": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true
|
||||
},
|
||||
"queue-microtask": {
|
||||
@@ -10990,6 +11058,12 @@
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true
|
||||
},
|
||||
"requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
@@ -11572,13 +11646,23 @@
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tree-kill": {
|
||||
@@ -11695,6 +11779,16 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"both": "./run.sh both anon up --detach",
|
||||
"both:real": "./run.sh both real up --detach",
|
||||
"docker:down": "./run.sh both anon down --remove-orphans; ./run.sh both real down --remove-orphans",
|
||||
"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 install",
|
||||
"rapodiff": "docker exec backend node /opt/app-root/src/src/rapodiff.js"
|
||||
@@ -34,7 +35,7 @@
|
||||
"@babel/eslint-parser": "^7.19.1",
|
||||
"@babel/plugin-syntax-jsx": "^7.16.7",
|
||||
"concurrently": "^7.4.0",
|
||||
"cypress": "^10.9.0",
|
||||
"cypress": "^12.17.4",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
|
||||
@@ -132,6 +132,11 @@ reset_all_real_data() {
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
restore_data_from_dumps() {
|
||||
infomsg "Restoring earlier-downloaded dumps to databases"
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
reset_single_database() {
|
||||
# Define custom shell prompt for the nested interactive select loop
|
||||
PS3="Please enter which database to reset: "
|
||||
@@ -227,6 +232,11 @@ linting and formatting"
|
||||
successmsg "Setup ready, oodikone can be started! See README for more info."
|
||||
}
|
||||
|
||||
docker_prune() {
|
||||
infomsg "Running: docker system prune -a && docker volume prune -a"
|
||||
docker system prune -a && docker volume prune -a
|
||||
}
|
||||
|
||||
init_dirs() {
|
||||
if [[ ! -d "$DUMP_DIR" ]]; then
|
||||
infomsg "Creating directory for dumps"
|
||||
|
||||
@@ -74,4 +74,5 @@ module.exports = {
|
||||
backendPort,
|
||||
importerUrl,
|
||||
importerToken,
|
||||
isDev,
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.sequelize.query('TRUNCATE TABLE progress_criteria')
|
||||
await queryInterface.sequelize.query('TRUNCATE TABLE excluded_courses')
|
||||
await queryInterface.removeConstraint('excluded_courses', 'excluded_courses_programme_code_course_code_key')
|
||||
await queryInterface.addColumn('progress_criteria', 'curriculum_version', {
|
||||
type: Sequelize.STRING,
|
||||
})
|
||||
await queryInterface.addColumn('excluded_courses', 'curriculum_version', {
|
||||
type: Sequelize.STRING,
|
||||
})
|
||||
},
|
||||
down: async queryInterface => {
|
||||
await queryInterface.removeColumn('progress_criteria', 'curriculum_version')
|
||||
await queryInterface.removeColumn('excluded_courses', 'curriculum_version')
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,8 @@ const { isProduction } = require('./conf-backend')
|
||||
const { getCurrentSemester } = require('./services/semesters')
|
||||
const logger = require('./util/logger')
|
||||
const { getAssociations } = require('./services/studyrights')
|
||||
const { redisClient } = require('./services/redis')
|
||||
const { computeLanguageCenterData, LANGUAGE_CENTER_REDIS_KEY } = require('./services/languageCenterData')
|
||||
|
||||
const schedule = (cronTime, func) => new CronJob({ cronTime, onTick: func, start: true, timeZone: 'Europe/Helsinki' })
|
||||
|
||||
@@ -137,11 +139,18 @@ const refreshTrends = async () => {
|
||||
logger.info('Trends refreshed!')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const refreshLanguageCenterData = async () => {
|
||||
const freshData = await computeLanguageCenterData()
|
||||
await redisClient.setAsync(LANGUAGE_CENTER_REDIS_KEY, JSON.stringify(freshData))
|
||||
logger.info('Language center data refreshed!')
|
||||
}
|
||||
|
||||
const startCron = () => {
|
||||
if (isProduction) {
|
||||
logger.info('Cronjob for refreshing stats started: runs at 3am every day.')
|
||||
// refresh 3am every day
|
||||
schedule('0 3 * * *', async () => {
|
||||
logger.info('Cronjob for refreshing stats started: runs at 4am saturday.')
|
||||
// refresh on saturday only because was causing lag. Investigate & fix, then return to 3am daily
|
||||
schedule('0 4 * * 6', async () => {
|
||||
for (const func of [refreshStatistics, refreshTrends, refreshNewOverviews, refreshFaculties]) {
|
||||
await func()
|
||||
}
|
||||
@@ -155,4 +164,5 @@ module.exports = {
|
||||
refreshTrends,
|
||||
refreshFaculties,
|
||||
refreshNewOverviews,
|
||||
refreshLanguageCenterData,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const accessLogger = morgan((tokens, req, res) => {
|
||||
|
||||
const message = [
|
||||
user.name,
|
||||
':',
|
||||
tokens['method'](req, res),
|
||||
tokens['url'](req, res),
|
||||
tokens['status'](req, res),
|
||||
|
||||
@@ -81,6 +81,14 @@ Credit.init(
|
||||
is_open: {
|
||||
type: BOOLEAN,
|
||||
},
|
||||
studyright_id: {
|
||||
type: STRING,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'studyright',
|
||||
key: 'studyrightid',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
underscored: false,
|
||||
|
||||
@@ -57,6 +57,14 @@ Enrollment.init(
|
||||
is_open: {
|
||||
type: BOOLEAN,
|
||||
},
|
||||
studyright_id: {
|
||||
type: STRING,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'studyright',
|
||||
key: 'studyrightid',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
underscored: false,
|
||||
|
||||
@@ -16,6 +16,9 @@ ExcludedCourse.init(
|
||||
course_code: {
|
||||
type: STRING,
|
||||
},
|
||||
curriculum_version: {
|
||||
type: STRING,
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,8 @@ Credit.passed = ({ credittypecode }) =>
|
||||
credittypecode === CREDIT_TYPE_CODES.PASSED || credittypecode === CREDIT_TYPE_CODES.APPROVED
|
||||
Credit.failed = credit => credit.credittypecode === CREDIT_TYPE_CODES.FAILED
|
||||
Credit.improved = credit => credit.credittypecode === CREDIT_TYPE_CODES.IMPROVED
|
||||
Credit.belongsTo(Studyright, { foreignKey: 'studyright_id', targetKey: 'studyrightid', constraints: false })
|
||||
Studyright.hasMany(Credit, { foreignKey: 'studyright_id', constraints: false })
|
||||
|
||||
Credit.belongsTo(Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
Student.hasMany(Credit, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
@@ -97,6 +99,9 @@ Transfer.belongsTo(ElementDetail, { as: 'source', foreignKey: 'sourcecode' })
|
||||
Transfer.belongsTo(ElementDetail, { as: 'target', foreignKey: 'targetcode' })
|
||||
|
||||
Enrollment.belongsTo(Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
Enrollment.belongsTo(Studyright, { foreignKey: 'studyright_id', targetKey: 'studyrightid', constraints: false })
|
||||
Studyright.hasMany(Enrollment, { foreignKey: 'studyright_id', constraints: false })
|
||||
|
||||
Student.hasMany(Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
Enrollment.belongsTo(Course, { foreignKey: 'course_id' })
|
||||
Course.hasMany(Enrollment, { foreignKey: 'course_id' })
|
||||
|
||||
@@ -18,6 +18,9 @@ const ExcludedCourse = sequelizeKone.define(
|
||||
programme_code: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
curriculum_version: {
|
||||
type: STRING,
|
||||
},
|
||||
course_code: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
@@ -123,6 +126,9 @@ const ProgressCriteria = sequelizeKone.define(
|
||||
primaryKey: true,
|
||||
type: STRING,
|
||||
},
|
||||
curriculumVersion: {
|
||||
type: STRING,
|
||||
},
|
||||
coursesYearOne: {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { Model, STRING, DATE, JSONB } = require('sequelize')
|
||||
const { Model, STRING, DATE, INTEGER, JSONB, ARRAY, TEXT } = require('sequelize')
|
||||
const { dbConnections } = require('../database/connection')
|
||||
|
||||
class ProgrammeModule extends Model {}
|
||||
@@ -9,6 +9,9 @@ ProgrammeModule.init(
|
||||
primaryKey: true,
|
||||
type: STRING,
|
||||
},
|
||||
group_id: {
|
||||
type: STRING,
|
||||
},
|
||||
code: {
|
||||
type: STRING,
|
||||
},
|
||||
@@ -18,6 +21,9 @@ ProgrammeModule.init(
|
||||
type: {
|
||||
type: STRING,
|
||||
},
|
||||
order: {
|
||||
type: INTEGER,
|
||||
},
|
||||
studyLevel: {
|
||||
type: STRING,
|
||||
},
|
||||
@@ -28,6 +34,15 @@ ProgrammeModule.init(
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
valid_from: {
|
||||
type: DATE,
|
||||
},
|
||||
valid_to: {
|
||||
type: DATE,
|
||||
},
|
||||
curriculum_period_ids: {
|
||||
type: ARRAY(TEXT),
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE,
|
||||
},
|
||||
|
||||
@@ -5,19 +5,19 @@ class ProgrammeModuleChild extends Model {}
|
||||
|
||||
ProgrammeModuleChild.init(
|
||||
{
|
||||
parent_id: {
|
||||
composite: {
|
||||
type: STRING,
|
||||
primaryKey: true,
|
||||
},
|
||||
parentId: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
child_id: {
|
||||
childId: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE,
|
||||
|
||||
@@ -9,6 +9,9 @@ Studyright.init(
|
||||
primaryKey: true,
|
||||
type: STRING,
|
||||
},
|
||||
actual_studyrightid: {
|
||||
type: STRING,
|
||||
},
|
||||
startdate: {
|
||||
type: DATE,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ const shibbolethCharsetMiddleware = require('./middleware/shibbolethCharsetMiddl
|
||||
const currentUserMiddleware = require('./middleware/currentUserMiddleware')
|
||||
const accessLogger = require('./middleware/accessLogger')
|
||||
const auth = require('./middleware/auth')
|
||||
const changelog = require('./routes/changelog')
|
||||
const courses = require('./routes/courses')
|
||||
const students = require('./routes/students')
|
||||
const population = require('./routes/population')
|
||||
@@ -16,6 +17,7 @@ const elementdetails = require('./routes/elementdetails')
|
||||
const teachers = require('./routes/teachers')
|
||||
const providers = require('./routes/providers')
|
||||
const faculties = require('./routes/faculties')
|
||||
const courseExclusions = require('./routes/courseExclusions')
|
||||
const semesters = require('./routes/semesters')
|
||||
const feedback = require('./routes/feedback')
|
||||
const tags = require('./routes/tags')
|
||||
@@ -31,7 +33,6 @@ const studyProgrammeCriteria = require('./routes/studyProgrammeCriteria')
|
||||
const initializeSentry = require('./util/sentry')
|
||||
const completedCoursesSearch = require('./routes/completedCoursesSearch')
|
||||
const errorMiddleware = require('./middleware/errorMiddleware')
|
||||
|
||||
module.exports = (app, url) => {
|
||||
initializeSentry(app)
|
||||
app.use(Sentry.Handlers.requestHandler())
|
||||
@@ -53,9 +54,11 @@ module.exports = (app, url) => {
|
||||
app.use(url, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(url, courseExclusions)
|
||||
app.use(url, studyProgramme)
|
||||
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
|
||||
app.use(`${url}/changelog`, changelog)
|
||||
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
|
||||
app.use(`${url}/languagecenterdata`, languageCenterData)
|
||||
app.use(`${url}/faculties`, auth.roles(['facultyStatistics']), faculties)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const router = require('express').Router()
|
||||
const axios = require('axios')
|
||||
const { isDev } = require('../conf-backend')
|
||||
const changelog = {}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
if (changelog?.data) return res.status(200).send(changelog.data)
|
||||
if (isDev)
|
||||
return res.status(200).json([
|
||||
{
|
||||
description: "### Fake release\nLet's not spam github api in development!",
|
||||
title: 'Fake title for fake release',
|
||||
time: new Date(),
|
||||
},
|
||||
])
|
||||
const result = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
|
||||
const formattedResult = result.data.map(r => ({ description: r.body, time: r.created_at, title: r.name }))
|
||||
changelog.data = formattedResult
|
||||
res.status(200).send(formattedResult)
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,25 @@
|
||||
const router = require('express').Router()
|
||||
const { addExcludedCourses, removeExcludedCourses } = require('../services/excludedCourses')
|
||||
|
||||
router.delete('/v3/course_exclusions/:code', async (req, res) => {
|
||||
const { programmeCode, curriculumVersion, courseCodes } = req.body
|
||||
const result = await removeExcludedCourses({ programmeCode, curriculumVersion, courseCodes })
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.post('/v3/course_exclusions/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await addExcludedCourses(code, courseCodes, curriculumVersion.join(','))
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -6,13 +6,29 @@ const { validateParamLength } = require('../util')
|
||||
|
||||
router.get('/v2/coursesmulti', async (req, res) => {
|
||||
let results = { courses: [] }
|
||||
const { name, code } = req.query
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'name or code invalid' })
|
||||
}
|
||||
|
||||
results = await Course.byNameAndOrCodeLike(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = results.courses.map(course => course.code)
|
||||
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
results.courses
|
||||
.flatMap(course => course.substitutions)
|
||||
.filter(value => value !== null && !courseCodes.includes(value))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await Course.byCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
results.courses.push(substitution.dataValues)
|
||||
}
|
||||
}
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
@@ -30,6 +46,7 @@ router.get('/v3/courseyearlystats', async (req, res) => {
|
||||
}
|
||||
|
||||
const { codes, separate: sep } = req.query
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
|
||||
const separate = !sep ? false : JSON.parse(sep)
|
||||
if (!codes) {
|
||||
@@ -39,7 +56,7 @@ router.get('/v3/courseyearlystats', async (req, res) => {
|
||||
// and users with rights to any specific study programmes
|
||||
const anonymize = allowedRoles !== 'admin' && rights.length < 1
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await Course.courseYearlyStats(codes, separate, anonymizationSalt)
|
||||
const results = await Course.courseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -167,12 +167,7 @@ router.get('/:id/update_basicview', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const statsType = req.query?.stats_type
|
||||
if (code) {
|
||||
let result = null
|
||||
try {
|
||||
result = await updateFacultyOverview(code, statsType)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update faculty ${code} basic tab stats for ${statsType}: ${e}`)
|
||||
}
|
||||
const result = await updateFacultyOverview(code, statsType)
|
||||
return res.json(result)
|
||||
}
|
||||
return res.status(422).end()
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
const router = require('express').Router()
|
||||
const {
|
||||
byProgrammeCode,
|
||||
addExcludedCourses,
|
||||
removeExcludedCourses,
|
||||
modulesByProgrammeCode,
|
||||
} = require('../services/programmeModules')
|
||||
const { getCoursesAndModules, getCurriculumVersions } = require('../services/programmeModules')
|
||||
|
||||
router.get('/v3/programme_modules/:code', async (req, res) => {
|
||||
router.get('/v3/get_curriculum_options/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await byProgrammeCode(code)
|
||||
const result = await getCurriculumVersions(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/v3/programme_modules/:code/modules', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await modulesByProgrammeCode(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.delete('/v3/programme_modules', async (req, res) => {
|
||||
const { programmecode, ids } = req.body
|
||||
await removeExcludedCourses(ids)
|
||||
const result = await byProgrammeCode(programmecode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.post('/v3/programme_modules/:programmecode/', async (req, res) => {
|
||||
const { programmecode, excludeFromProgramme, coursecodes } = req.body
|
||||
await addExcludedCourses(excludeFromProgramme, coursecodes)
|
||||
const result = await byProgrammeCode(programmecode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
router.get('/v3/get_curriculum/:code/:period_ids', async (req, res) => {
|
||||
const { code, period_ids } = req.params
|
||||
const result = await getCoursesAndModules(code, period_ids.replace(' ', '').split(','))
|
||||
res.json({
|
||||
defaultProgrammeCourses: result.defaultProgrammeCourses.courses,
|
||||
defaultProgrammeModules: result.defaultProgrammeCourses.modules,
|
||||
secondProgrammeCourses: result.secondProgrammeCourses?.courses,
|
||||
secondProgrammeModules: result.secondProgrammeCourses?.modules,
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -227,20 +227,10 @@ router.get('/v2/studyprogrammes/:id/evaluationstats', async (req, res) => {
|
||||
status: gradData?.status,
|
||||
lastUpdated: gradData.lastUpdated,
|
||||
graduations: gradData,
|
||||
progress: {
|
||||
creditTableStats: {},
|
||||
creditTableTitles: progressData?.creditTableTitles,
|
||||
graphData: {
|
||||
creditGraphStats: {},
|
||||
id: progressData?.id,
|
||||
years: progressData?.years,
|
||||
},
|
||||
},
|
||||
creditCounts: progressData?.creditCounts,
|
||||
years: progressData?.years,
|
||||
}
|
||||
|
||||
data.progress.creditTableStats[code] = progressData?.creditTableStats[code]
|
||||
data.progress.graphData.creditGraphStats[code] = progressData?.creditGraphStats[code]
|
||||
|
||||
return res.json(data)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ const {
|
||||
abort,
|
||||
updateCoursesByCourseCode,
|
||||
} = require('../services/sisUpdaterService')
|
||||
const { refreshStatistics, refreshTrends, refreshFaculties, refreshNewOverviews } = require('../events')
|
||||
const {
|
||||
refreshStatistics,
|
||||
refreshTrends,
|
||||
refreshFaculties,
|
||||
refreshNewOverviews,
|
||||
refreshLanguageCenterData,
|
||||
} = require('../events')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
router.get('/update/v2/meta', async (req, res) => {
|
||||
const response = await updateSISMetadata(req)
|
||||
@@ -46,6 +53,7 @@ router.get('/update/v2/programmes', async (req, res) => {
|
||||
})
|
||||
|
||||
router.get('/refresh_redis_cache', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of redis cache`)
|
||||
const response = await updateSISRedisCache()
|
||||
if (response) {
|
||||
res.status(200).json('Refreshing SIS redis cache scheduled')
|
||||
@@ -53,25 +61,35 @@ router.get('/refresh_redis_cache', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/refresh_statistic_v2', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of statistics`)
|
||||
await refreshStatistics()
|
||||
res.status(200).json('Teacher and study programme statistics refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_study_programmes_v2', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of study programmes`)
|
||||
await refreshNewOverviews()
|
||||
res.status(200).json('New study programme overviews refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_faculties_v2', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of faculties`)
|
||||
await refreshFaculties()
|
||||
res.status(200).json('Faculties refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_trends', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of trends`)
|
||||
await refreshTrends()
|
||||
res.status(200).json('Trends refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_language_center_data', async (req, res) => {
|
||||
logger.info(`${req.user.userId} requested refresh of language center data`)
|
||||
await refreshLanguageCenterData()
|
||||
res.status(200).json('Language center data refreshed')
|
||||
})
|
||||
|
||||
router.get('/abort', async (req, res) => {
|
||||
await abort()
|
||||
res.status(200).json()
|
||||
|
||||
@@ -240,12 +240,15 @@ const allCodeAlternatives = async code => {
|
||||
return sortMainCode([...course.substitutions, code])
|
||||
}
|
||||
|
||||
const yearlyStatsOfNew = async (coursecode, separate, unification, anonymizationSalt) => {
|
||||
const yearlyStatsOfNew = async (coursecode, separate, unification, anonymizationSalt, combineSubstitutions) => {
|
||||
const courseForSubs = await Course.findOne({
|
||||
where: { code: coursecode },
|
||||
})
|
||||
|
||||
let codes = courseForSubs.substitutions ? sortMainCode([...courseForSubs.substitutions, coursecode]) : [coursecode]
|
||||
let codes =
|
||||
combineSubstitutions && courseForSubs.substitutions
|
||||
? sortMainCode([...courseForSubs.substitutions, coursecode])
|
||||
: [coursecode]
|
||||
|
||||
if (unification === 'reqular') {
|
||||
codes = codes.filter(course => !isOpenUniCourseCode(course))
|
||||
@@ -398,12 +401,12 @@ const maxYearsToCreatePopulationFrom = async (coursecodes, unifyCourses) => {
|
||||
return maxYearsToCreatePopulationFrom
|
||||
}
|
||||
|
||||
const courseYearlyStats = async (coursecodes, separate, anonymizationSalt) => {
|
||||
const courseYearlyStats = async (coursecodes, separate, anonymizationSalt, combineSubstitutions) => {
|
||||
const statsReqular = await Promise.all(
|
||||
coursecodes.map(async code => {
|
||||
const unifyStats = await yearlyStatsOfNew(code, separate, 'unify', anonymizationSalt)
|
||||
const reqularStats = await yearlyStatsOfNew(code, separate, 'reqular', anonymizationSalt)
|
||||
const openStats = await yearlyStatsOfNew(code, separate, 'open', anonymizationSalt)
|
||||
const unifyStats = await yearlyStatsOfNew(code, separate, 'unify', anonymizationSalt, combineSubstitutions)
|
||||
const reqularStats = await yearlyStatsOfNew(code, separate, 'reqular', anonymizationSalt, combineSubstitutions)
|
||||
const openStats = await yearlyStatsOfNew(code, separate, 'open', anonymizationSalt, combineSubstitutions)
|
||||
|
||||
return { unifyStats, reqularStats, openStats }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const { ExcludedCourse } = require('../models/models_kone')
|
||||
const { Op } = Sequelize
|
||||
|
||||
const addExcludedCourses = async (programmecode, coursecodes, curriculum) => {
|
||||
return ExcludedCourse.bulkCreate(
|
||||
coursecodes.map(c => ({ programme_code: programmecode, curriculum_version: curriculum, course_code: c }))
|
||||
)
|
||||
}
|
||||
|
||||
const removeExcludedCourses = async ({ programmeCode, curriculumVersion, courseCodes }) => {
|
||||
return ExcludedCourse.destroy({
|
||||
where: {
|
||||
programme_code: programmeCode,
|
||||
curriculum_version: curriculumVersion,
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { addExcludedCourses, removeExcludedCourses }
|
||||
@@ -289,6 +289,7 @@ const degreeProgrammesOfFaculty = async facultyCode =>
|
||||
},
|
||||
},
|
||||
},
|
||||
group: ['programme_module.code', 'programme_module.name', 'organization.id'],
|
||||
})
|
||||
).map(facultyFormatProgramme)
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ const getClassSizes = async (faculty, programmeCodes, since, classSizes, program
|
||||
}
|
||||
}
|
||||
|
||||
if (extentcode === 2) {
|
||||
if (extentcode === 2 && ['master', 'bcMsCombo'].includes(level)) {
|
||||
classSizes.programmes[code][level][startYear] += 1
|
||||
} else {
|
||||
classSizes.programmes[code][startYear] += 1
|
||||
@@ -314,7 +314,7 @@ const countGraduationTimes = async (faculty, programmeFilter) => {
|
||||
MH30_001: 12,
|
||||
'320011-ma': 12,
|
||||
'320001-ma': 12,
|
||||
MH30_003: 12,
|
||||
MH30_003: 6,
|
||||
'320002-ma': 12,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
const { indexOf } = require('lodash')
|
||||
const moment = require('moment')
|
||||
const { getAcademicYearDates } = require('../../util/semester')
|
||||
const {
|
||||
getCreditThresholds,
|
||||
getBachelorCreditGraphStats,
|
||||
getMasterCreditGraphStats,
|
||||
getBachelorMasterCreditGraphStats,
|
||||
getDoctoralCreditGraphStats,
|
||||
getYearsArray,
|
||||
tableTitles,
|
||||
getVetenaryCreditGraphStats,
|
||||
} = require('../studyprogrammeHelpers')
|
||||
const { getCreditThresholds, getYearsArray } = require('../studyprogrammeHelpers')
|
||||
const { studytrackStudents } = require('../studyprogramme')
|
||||
const { getStudyRightsByExtent, getStudyRightsByBachelorStart, getTransfersIn, getTransfersOut } = require('./faculty')
|
||||
const { checkTransfers } = require('./facultyHelpers')
|
||||
|
||||
const getStudentData = (startDate, students, thresholdKeys, thresholdAmounts, limits = [], limitKeys = [], prog) => {
|
||||
const getStudentData = (
|
||||
startDate,
|
||||
students,
|
||||
prog,
|
||||
limits = [],
|
||||
limitKeys = [],
|
||||
thresholdKeys = [],
|
||||
thresholdAmounts = []
|
||||
) => {
|
||||
let data = {}
|
||||
let programmeData = {}
|
||||
const creditCounts = []
|
||||
thresholdKeys.forEach(t => (data[t] = 0))
|
||||
limitKeys.forEach(t => (programmeData[t] = 0))
|
||||
students.forEach(({ credits }) => {
|
||||
const creditcount = credits
|
||||
.filter(credit => moment(credit.attainment_date).isSameOrAfter(startDate))
|
||||
.reduce((prev, curr) => prev + curr.credits, 0)
|
||||
data[thresholdKeys[0]] += creditcount < thresholdAmounts[0] ? 1 : 0
|
||||
data[thresholdKeys[1]] += creditcount >= thresholdAmounts[0] && creditcount < thresholdAmounts[1] ? 1 : 0
|
||||
data[thresholdKeys[2]] += creditcount >= thresholdAmounts[1] && creditcount < thresholdAmounts[2] ? 1 : 0
|
||||
data[thresholdKeys[3]] += creditcount >= thresholdAmounts[2] && creditcount < thresholdAmounts[3] ? 1 : 0
|
||||
if (thresholdKeys.length === 8) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] && creditcount < thresholdAmounts[6] ? 1 : 0
|
||||
data[thresholdKeys[7]] += creditcount >= thresholdAmounts[6] ? 1 : 0
|
||||
} else if (thresholdKeys.length === 7) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] ? 1 : 0
|
||||
} else if (thresholdKeys.length === 6) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] ? 1 : 0
|
||||
creditCounts.push(creditcount)
|
||||
// Data is only used for doctoral programmes, otherwise only programmeData is needed
|
||||
if (prog === 'T') {
|
||||
data[thresholdKeys[0]] += creditcount < thresholdAmounts[0] ? 1 : 0
|
||||
data[thresholdKeys[1]] += creditcount >= thresholdAmounts[0] && creditcount < thresholdAmounts[1] ? 1 : 0
|
||||
data[thresholdKeys[2]] += creditcount >= thresholdAmounts[1] && creditcount < thresholdAmounts[2] ? 1 : 0
|
||||
data[thresholdKeys[3]] += creditcount >= thresholdAmounts[2] && creditcount < thresholdAmounts[3] ? 1 : 0
|
||||
if (thresholdKeys.length === 8) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] && creditcount < thresholdAmounts[6] ? 1 : 0
|
||||
data[thresholdKeys[7]] += creditcount >= thresholdAmounts[6] ? 1 : 0
|
||||
} else if (thresholdKeys.length === 7) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] ? 1 : 0
|
||||
} else if (thresholdKeys.length === 6) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] ? 1 : 0
|
||||
} else {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] ? 1 : 0
|
||||
}
|
||||
} else {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] ? 1 : 0
|
||||
}
|
||||
if (['KH', 'MH'].includes(prog)) {
|
||||
programmeData[limitKeys[0]] += creditcount <= limits[5][0] ? 1 : 0
|
||||
programmeData[limitKeys[1]] += limits[4][0] <= creditcount && limits[4][1] > creditcount ? 1 : 0
|
||||
programmeData[limitKeys[2]] += limits[3][0] <= creditcount && limits[3][1] > creditcount ? 1 : 0
|
||||
@@ -53,7 +56,7 @@ const getStudentData = (startDate, students, thresholdKeys, thresholdAmounts, li
|
||||
}
|
||||
})
|
||||
|
||||
return { data, progData: programmeData }
|
||||
return { data, progData: programmeData, creditCounts }
|
||||
}
|
||||
|
||||
const createLimits = (months, creditsToAdd) => {
|
||||
@@ -94,59 +97,38 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
graduationStatus = [0, 1]
|
||||
}
|
||||
const yearsArray = getYearsArray(since.getFullYear(), true, true)
|
||||
const titlesVet = [
|
||||
'',
|
||||
'All',
|
||||
'< 210 credits',
|
||||
'210-239 credits',
|
||||
'240-269 credits',
|
||||
'270-299 credits',
|
||||
'300-329 credits',
|
||||
'330-359 credits',
|
||||
' 360 ≤ credits ',
|
||||
]
|
||||
const progressStats = {
|
||||
id: faculty,
|
||||
years: yearsArray,
|
||||
bachelorsTableStats: new Array(yearsArray.length),
|
||||
bcMsTableStats: new Array(yearsArray.length),
|
||||
mastersTableStats: new Array(yearsArray.length),
|
||||
doctoralTableStats: new Array(yearsArray.length),
|
||||
bachelorsGraphStats: getBachelorCreditGraphStats(yearsArray),
|
||||
bcMsGraphStats:
|
||||
faculty === 'H90' ? getVetenaryCreditGraphStats(yearsArray) : getBachelorMasterCreditGraphStats(yearsArray),
|
||||
mastersGraphStats:
|
||||
faculty === 'H90' ? getBachelorCreditGraphStats(yearsArray) : getMasterCreditGraphStats(yearsArray),
|
||||
doctoralGraphStats: getDoctoralCreditGraphStats(yearsArray),
|
||||
bachelorTitles: tableTitles.creditProgress.bachelor,
|
||||
bcMsTitles: faculty === 'H90' ? titlesVet : tableTitles.creditProgress.bachelorMaster,
|
||||
mastersTitles: faculty === 'H90' ? tableTitles.creditProgress.bachelor : tableTitles.creditProgress.master,
|
||||
doctoralTitles: tableTitles.creditProgress.doctoral,
|
||||
yearlyBachelorTitles: [],
|
||||
yearlyBcMsTitles: [],
|
||||
yearlyMasterTitles: [],
|
||||
yearlyLicentiateTitles: [],
|
||||
programmeNames: {},
|
||||
bachelorsProgStats: {},
|
||||
bcMsProgStats: {},
|
||||
mastersProgStats: {},
|
||||
doctoralProgStats: {},
|
||||
creditCounts: {
|
||||
bachelor: {},
|
||||
bachelorMaster: {},
|
||||
master: {},
|
||||
licentiate: {}, // only used for programmes MH30_001 and MH30_003
|
||||
doctor: {},
|
||||
},
|
||||
}
|
||||
|
||||
const reversedYears = [...yearsArray].reverse()
|
||||
reversedYears.forEach(year => {
|
||||
progressStats.bachelorsTableStats[indexOf(reversedYears, year)] = [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
progressStats.bcMsTableStats[indexOf(reversedYears, year)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
progressStats.mastersTableStats[indexOf(reversedYears, year)] = [year, 0, 0, 0, 0, 0, 0, 0]
|
||||
progressStats.doctoralTableStats[indexOf(reversedYears, year)] = [year, 0, 0, 0, 0, 0, 0]
|
||||
})
|
||||
|
||||
const limitKeys = ['zero', 't5', 't4', 't3', 't2', 't1']
|
||||
let bachelorlimits = []
|
||||
let masterlimits = []
|
||||
let bcmslimits = []
|
||||
let licentiatelimits = []
|
||||
const bachelorProgress = {}
|
||||
const masterProgress = {}
|
||||
const bcmsProgress = {}
|
||||
const licentiateProgress = {}
|
||||
const doctoralProgress = {}
|
||||
for (const year of reversedYears) {
|
||||
if (year === 'Total') continue
|
||||
@@ -170,10 +152,27 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
} else {
|
||||
bcmslimits = createLimits(months, 0)
|
||||
}
|
||||
if (months >= 72 && faculty === 'H30') {
|
||||
licentiatelimits = createLimits(72, 0)
|
||||
} else if (months >= 60) {
|
||||
licentiatelimits = createLimits(60, 0)
|
||||
} else {
|
||||
licentiatelimits = createLimits(months, 0)
|
||||
}
|
||||
progressStats.yearlyBachelorTitles = [...progressStats.yearlyBachelorTitles, createYearlyTitles(0, bachelorlimits)]
|
||||
progressStats.yearlyMasterTitles = [...progressStats.yearlyMasterTitles, createYearlyTitles(0, masterlimits)]
|
||||
progressStats.yearlyLicentiateTitles = [
|
||||
...progressStats.yearlyLicentiateTitles,
|
||||
createYearlyTitles(0, licentiatelimits),
|
||||
]
|
||||
progressStats.yearlyBcMsTitles = [...progressStats.yearlyBcMsTitles, createYearlyTitles(0, bcmslimits)]
|
||||
|
||||
progressStats.creditCounts.bachelor[year] = []
|
||||
progressStats.creditCounts.bachelorMaster[year] = []
|
||||
progressStats.creditCounts.master[year] = []
|
||||
progressStats.creditCounts.licentiate[year] = []
|
||||
progressStats.creditCounts.doctor[year] = []
|
||||
|
||||
for (const { progId, code, name } of programmes) {
|
||||
let extentcodes = [1, 2, 3, 4]
|
||||
if (includeAllSpecials) {
|
||||
@@ -185,9 +184,7 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
: await getStudyRightsByExtent(faculty, startDate, endDate, code, extentcodes, graduationStatus)
|
||||
|
||||
// Get credit threshold values: 'combined study programme' is false and 'only master studyright' is true
|
||||
let { creditThresholdKeys, creditThresholdAmounts } = getCreditThresholds(code, false, true)
|
||||
|
||||
if (!creditThresholdKeys) continue
|
||||
if (!(progId in progressStats.programmeNames)) {
|
||||
progressStats.programmeNames[progId] = { code: code, ...name }
|
||||
}
|
||||
@@ -197,30 +194,14 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
if (code.includes('KH')) {
|
||||
const allBachelors = studyrights.filter(sr => sr.extentcode === 1).map(sr => sr.studentnumber)
|
||||
const students = await studytrackStudents(allBachelors)
|
||||
const { data: studentData, progData } = getStudentData(
|
||||
startDate,
|
||||
students,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
bachelorlimits,
|
||||
limitKeys,
|
||||
'KH'
|
||||
)
|
||||
const { progData, creditCounts } = getStudentData(startDate, students, 'KH', bachelorlimits, limitKeys)
|
||||
|
||||
progressStats.creditCounts.bachelor[year] = [...progressStats.creditCounts.bachelor[year], ...creditCounts]
|
||||
|
||||
if (!(progId in bachelorProgress)) {
|
||||
bachelorProgress[progId] = new Array(reversedYears.length - 1)
|
||||
}
|
||||
progressStats.bachelorsTableStats[indexOf(reversedYears, year)][1] += students.length || 0
|
||||
progressStats.bachelorsTableStats[indexOf(reversedYears, 'Total')][1] += students.length || 0
|
||||
bachelorProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => progData[key])
|
||||
for (const key of Object.keys(studentData)) {
|
||||
progressStats.bachelorsTableStats[indexOf(reversedYears, year)][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
studentData[key] || 0
|
||||
progressStats.bachelorsGraphStats[key].data[indexOf(yearsArray, year)] += studentData[key] || 0
|
||||
progressStats.bachelorsTableStats[indexOf(reversedYears, 'Total')][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
studentData[key] || 0
|
||||
progressStats.bachelorsGraphStats[key].data[indexOf(yearsArray, 'Total')] += studentData[key] || 0
|
||||
}
|
||||
} else if (code.includes('MH')) {
|
||||
const allMsStudents = studyrights
|
||||
.filter(sr => sr.extentcode === 2 && sr.studyrightid.slice(-2) !== '-2')
|
||||
@@ -231,75 +212,63 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
const bcMsStudents = await studytrackStudents(allBcMsStudents)
|
||||
const msStudents = await studytrackStudents(allMsStudents)
|
||||
|
||||
progressStats.mastersTableStats[indexOf(reversedYears, year)][1] += msStudents.length || 0
|
||||
progressStats.mastersTableStats[indexOf(reversedYears, 'Total')][1] += msStudents.length || 0
|
||||
progressStats.bcMsTableStats[indexOf(reversedYears, year)][1] += bcMsStudents.length || 0
|
||||
progressStats.bcMsTableStats[indexOf(reversedYears, 'Total')][1] += bcMsStudents.length || 0
|
||||
|
||||
// Get credit threshold values both 'combined study programme' and 'only master studyright' are false
|
||||
const { creditThresholdKeys: creditThresholdKeysBcMs, creditThresholdAmounts: creditThresholdAmountsBcMs } =
|
||||
getCreditThresholds(code, false, false)
|
||||
|
||||
const { data: bcMsStudentdata, progData: bcMsProgdata } = getStudentData(
|
||||
const { progData: bcMsProgdata, creditCounts: creditCountsBcMs } = getStudentData(
|
||||
startDate,
|
||||
bcMsStudents,
|
||||
creditThresholdKeysBcMs,
|
||||
creditThresholdAmountsBcMs,
|
||||
'MH',
|
||||
bcmslimits,
|
||||
limitKeys,
|
||||
'MH'
|
||||
limitKeys
|
||||
)
|
||||
const { data: msStudentdata, progData: msProgdata } = getStudentData(
|
||||
const { progData: msProgdata, creditCounts: creditCountsMaster } = getStudentData(
|
||||
startDate,
|
||||
msStudents,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
'MH',
|
||||
masterlimits,
|
||||
limitKeys,
|
||||
'MH'
|
||||
limitKeys
|
||||
)
|
||||
if (!(progId in masterProgress)) {
|
||||
masterProgress[progId] = new Array(reversedYears.length - 1)
|
||||
bcmsProgress[progId] = new Array(reversedYears.length - 1)
|
||||
}
|
||||
masterProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => msProgdata[key])
|
||||
bcmsProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => bcMsProgdata[key])
|
||||
for (const key of Object.keys(msStudentdata)) {
|
||||
progressStats.mastersTableStats[indexOf(reversedYears, year)][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
msStudentdata[key] || 0
|
||||
progressStats.mastersTableStats[indexOf(reversedYears, 'Total')][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
msStudentdata[key] || 0
|
||||
progressStats.mastersGraphStats[key].data[indexOf(yearsArray, year)] += msStudentdata[key] || 0
|
||||
progressStats.mastersGraphStats[key].data[indexOf(yearsArray, 'Total')] += msStudentdata[key] || 0
|
||||
}
|
||||
for (const key of Object.keys(bcMsStudentdata)) {
|
||||
progressStats.bcMsTableStats[indexOf(reversedYears, year)][indexOf(creditThresholdKeysBcMs, key) + 2] +=
|
||||
bcMsStudentdata[key] || 0
|
||||
progressStats.bcMsTableStats[indexOf(reversedYears, 'Total')][indexOf(creditThresholdKeysBcMs, key) + 2] +=
|
||||
bcMsStudentdata[key] || 0
|
||||
progressStats.bcMsGraphStats[key].data[indexOf(yearsArray, year)] += bcMsStudentdata[key] || 0
|
||||
progressStats.bcMsGraphStats[key].data[indexOf(yearsArray, 'Total')] += bcMsStudentdata[key] || 0
|
||||
if (['MH30_001', 'MH30_003'].includes(code)) {
|
||||
progressStats.creditCounts.licentiate[year] = [
|
||||
...progressStats.creditCounts.licentiate[year],
|
||||
...creditCountsMaster,
|
||||
]
|
||||
if (!(progId in licentiateProgress)) {
|
||||
licentiateProgress[progId] = new Array(reversedYears.length - 1)
|
||||
}
|
||||
licentiateProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => msProgdata[key])
|
||||
} else {
|
||||
progressStats.creditCounts.bachelorMaster[year] = [
|
||||
...progressStats.creditCounts.bachelorMaster[year],
|
||||
...creditCountsBcMs,
|
||||
]
|
||||
progressStats.creditCounts.master[year] = [...progressStats.creditCounts.master[year], ...creditCountsMaster]
|
||||
if (!(progId in masterProgress)) {
|
||||
masterProgress[progId] = new Array(reversedYears.length - 1)
|
||||
bcmsProgress[progId] = new Array(reversedYears.length - 1)
|
||||
}
|
||||
masterProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => msProgdata[key])
|
||||
bcmsProgress[progId][indexOf(reversedYears, year)] = limitKeys.map(key => bcMsProgdata[key])
|
||||
}
|
||||
} else {
|
||||
const all = studyrights.filter(sr => sr.extentcode === 4 || sr.extentcode === 3).map(sr => sr.studentnumber)
|
||||
const doctoralStudents = await studytrackStudents(all)
|
||||
const { data } = getStudentData(startDate, doctoralStudents, creditThresholdKeys, creditThresholdAmounts, 'T')
|
||||
let { creditThresholdKeys, creditThresholdAmounts } = getCreditThresholds(code)
|
||||
|
||||
const { data, creditCounts } = getStudentData(
|
||||
startDate,
|
||||
doctoralStudents,
|
||||
'T',
|
||||
[],
|
||||
[],
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts
|
||||
)
|
||||
|
||||
progressStats.creditCounts.doctor[year] = [...progressStats.creditCounts.doctor[year], ...creditCounts]
|
||||
|
||||
progressStats.doctoralTableStats[indexOf(reversedYears, year)][1] += doctoralStudents.length || 0
|
||||
progressStats.doctoralTableStats[indexOf(reversedYears, 'Total')][1] += doctoralStudents.length || 0
|
||||
if (!(progId in doctoralProgress)) {
|
||||
doctoralProgress[progId] = new Array(reversedYears.length - 1)
|
||||
}
|
||||
doctoralProgress[progId][indexOf(reversedYears, year)] = creditThresholdKeys.map(key => data[key])
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
progressStats.doctoralTableStats[indexOf(reversedYears, year)][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
data[key] || 0
|
||||
progressStats.doctoralTableStats[indexOf(reversedYears, 'Total')][indexOf(creditThresholdKeys, key) + 2] +=
|
||||
data[key] || 0
|
||||
progressStats.doctoralGraphStats[key].data[indexOf(yearsArray, year)] += data[key] || 0
|
||||
progressStats.doctoralGraphStats[key].data[indexOf(yearsArray, 'Total')] += data[key] || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,6 +282,9 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
|
||||
progressStats.bcMsProgStats = Object.keys(bcmsProgress)
|
||||
.filter(prog => !bcmsProgress[prog].every(year => year.every(value => value === 0)))
|
||||
.reduce((result, prog) => ({ ...result, [prog]: bcmsProgress[prog] }), {})
|
||||
progressStats.licentiateProgStats = Object.keys(licentiateProgress)
|
||||
.filter(prog => !licentiateProgress[prog].every(year => year.every(value => value === 0)))
|
||||
.reduce((result, prog) => ({ ...result, [prog]: licentiateProgress[prog] }), {})
|
||||
progressStats.doctoralProgStats = Object.keys(doctoralProgress)
|
||||
.filter(prog => !doctoralProgress[prog].every(year => year.every(value => value === 0)))
|
||||
.reduce((result, prog) => ({ ...result, [prog]: doctoralProgress[prog] }), {})
|
||||
|
||||
@@ -123,14 +123,10 @@ const updateFacultyOverview = async (faculty, statsType) => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTimesAll = await countGraduationTimes(faculty, 'ALL_PROGRAMMES')
|
||||
await setGraduationStats(updatedTimesAll, 'ALL_PROGRAMMES')
|
||||
const updatedTimesNew = await countGraduationTimes(faculty, 'NEW_STUDY_PROGRAMMES')
|
||||
await setGraduationStats(updatedTimesNew, 'NEW_STUDY_PROGRAMMES')
|
||||
} catch (e) {
|
||||
logger.error(`Faculty updates: graduation stats failed with error: ${e}`)
|
||||
}
|
||||
const updatedTimesAll = await countGraduationTimes(faculty, 'ALL_PROGRAMMES')
|
||||
await setGraduationStats(updatedTimesAll, 'ALL_PROGRAMMES')
|
||||
const updatedTimesNew = await countGraduationTimes(faculty, 'NEW_STUDY_PROGRAMMES')
|
||||
await setGraduationStats(updatedTimesNew, 'NEW_STUDY_PROGRAMMES')
|
||||
return 'OK'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,85 @@
|
||||
const { Course, Credit, Enrollment, StudyrightElement } = require('../models')
|
||||
const { Course, Credit, Enrollment, StudyrightElement, Studyright } = require('../models')
|
||||
const { Op } = require('sequelize')
|
||||
const { redisClient } = require('./redis')
|
||||
const REDIS_KEY = 'LANGUAGE_CENTER_DATA'
|
||||
const { orderBy } = require('lodash')
|
||||
const LANGUAGE_CENTER_REDIS_KEY = 'LANGUAGE_CENTER_DATA'
|
||||
|
||||
const getLanguageCenterData = async () => {
|
||||
const dataOnRedis = await redisClient.getAsync(REDIS_KEY)
|
||||
if (dataOnRedis) return JSON.parse(dataOnRedis)
|
||||
const freshData = await computeLanguageCenterData()
|
||||
redisClient.setAsync(REDIS_KEY, JSON.stringify(freshData))
|
||||
return freshData
|
||||
}
|
||||
const isBetween = (start, date, end) =>
|
||||
new Date(start).getTime() <= new Date(date).getTime() && new Date(date).getTime() <= new Date(end).getTime()
|
||||
|
||||
const computeLanguageCenterData = async () => {
|
||||
const findStudyright = (studyrights, date) => studyrights?.find(sr => isBetween(sr.startdate, date, sr.enddate))
|
||||
|
||||
const getLanguageCenterCourses = async () => {
|
||||
const courses = await Course.findAll({
|
||||
attributes: ['code', 'name'],
|
||||
where: {
|
||||
[Op.or]: [{ code: { [Op.like]: 'KK%' } }, { code: { [Op.like]: 'AYKK%' } }],
|
||||
},
|
||||
})
|
||||
return courses
|
||||
}
|
||||
|
||||
const getLanguageCenterData = async () => {
|
||||
const dataOnRedis = await redisClient.getAsync(LANGUAGE_CENTER_REDIS_KEY)
|
||||
if (dataOnRedis) return JSON.parse(dataOnRedis)
|
||||
const freshData = await computeLanguageCenterData()
|
||||
redisClient.setAsync(LANGUAGE_CENTER_REDIS_KEY, JSON.stringify(freshData))
|
||||
return freshData
|
||||
}
|
||||
|
||||
const computeLanguageCenterData = async () => {
|
||||
const courses = await getLanguageCenterCourses()
|
||||
|
||||
const credits = await Credit.findAll({
|
||||
attributes: ['course_code', 'credittypecode', 'student_studentnumber', 'semestercode', 'createdate'],
|
||||
attributes: [
|
||||
'course_code',
|
||||
'credittypecode',
|
||||
'student_studentnumber',
|
||||
'semestercode',
|
||||
'createdate',
|
||||
'studyright_id',
|
||||
],
|
||||
where: {
|
||||
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const enrollments = await Enrollment.findAll({
|
||||
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time'],
|
||||
attributes: ['studentnumber', 'semestercode', 'course_code', 'enrollment_date_time', 'studyright_id'],
|
||||
where: {
|
||||
[Op.or]: [{ course_code: { [Op.like]: 'KK%' } }, { course_code: { [Op.like]: 'AYKK%' } }],
|
||||
state: 'ENROLLED',
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const studyrights = await Studyright.findAll({ raw: true })
|
||||
|
||||
const attemptStudyrightToFacultyMap = studyrights.reduce((obj, cur) => {
|
||||
if (!cur.actual_studyrightid) return obj
|
||||
// ^The studyright id should exist, but there was once a faulty row temporarily in studyright-table,
|
||||
// which screwed up the faculties due to null getting a value to this map
|
||||
obj[cur.actual_studyrightid] = cur.facultyCode
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const studentnumberToStudyrightsMap = studyrights.reduce((obj, cur) => {
|
||||
if (!obj[cur.studentStudentnumber]) obj[cur.studentStudentnumber] = []
|
||||
obj[cur.studentStudentnumber].push(cur)
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const elementStudyrightIdToStudyrightMap = studyrights.reduce((obj, cur) => {
|
||||
obj[cur.studyrightid] = cur
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
credits.forEach(c => {
|
||||
c.faculty = attemptStudyrightToFacultyMap[c.studyright_id]
|
||||
})
|
||||
enrollments.forEach(c => {
|
||||
c.faculty = attemptStudyrightToFacultyMap[c.studyright_id]
|
||||
})
|
||||
|
||||
const studentList = new Set()
|
||||
@@ -45,54 +94,141 @@ const computeLanguageCenterData = async () => {
|
||||
if (!attemptsByStudents[sn]) {
|
||||
attemptsByStudents[sn] = []
|
||||
}
|
||||
attemptsByStudents[sn].push({ studentNumber: sn, courseCode: c.course_code, completed: true, date: c.createdate })
|
||||
attemptsByStudents[sn].push({
|
||||
studentNumber: sn,
|
||||
courseCode: c.course_code,
|
||||
completed: true,
|
||||
date: c.createdate,
|
||||
faculty: attemptStudyrightToFacultyMap[c.studyright_id],
|
||||
semestercode: c.semestercode,
|
||||
})
|
||||
})
|
||||
|
||||
enrollments.forEach(e => {
|
||||
const sn = e.studentnumber
|
||||
if (attemptsByStudents[sn] && attemptsByStudents[sn].find(att => att.courseCode === e.course_code)) return
|
||||
studentList.add(sn)
|
||||
if (!attemptsByStudents[sn]) {
|
||||
attemptsByStudents[sn] = []
|
||||
}
|
||||
studentList.add(sn)
|
||||
if (
|
||||
attemptsByStudents[sn].find(
|
||||
att => !att.completed && att.semestercode === e.semestercode && att.courseCode === e.course_code
|
||||
)
|
||||
)
|
||||
return
|
||||
attemptsByStudents[sn].push({
|
||||
studentNumber: sn,
|
||||
courseCode: e.course_code,
|
||||
completed: false,
|
||||
date: e.enrollment_date_time,
|
||||
faculty: attemptStudyrightToFacultyMap[e.studyright_id],
|
||||
semestercode: e.semestercode,
|
||||
})
|
||||
})
|
||||
|
||||
const studyrightElements = await StudyrightElement.findAll({
|
||||
attributes: ['studentnumber', 'startdate', 'enddate', 'code'],
|
||||
attributes: ['studentnumber', 'startdate', 'enddate', 'code', 'studyrightid'],
|
||||
where: {
|
||||
studentnumber: { [Op.in]: Array.from(studentList.values()) },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
const studyrightMap = studyrightElements.reduce((obj, cur) => {
|
||||
|
||||
const studyrightElementsMap = studyrightElements.reduce((obj, cur) => {
|
||||
if (!obj[cur.studentnumber]) {
|
||||
obj[cur.studentnumber] = [{ startdate: cur.startdate, enddate: cur.enddate, code: cur.code }]
|
||||
} else {
|
||||
obj[cur.studentnumber].push({ startdate: cur.startdate, enddate: cur.enddate, code: cur.code })
|
||||
obj[cur.studentnumber] = []
|
||||
}
|
||||
obj[cur.studentnumber].push({
|
||||
startdate: cur.startdate,
|
||||
enddate: cur.enddate,
|
||||
code: elementStudyrightIdToStudyrightMap[cur.studyrightid]?.facultyCode,
|
||||
})
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const getFaculty = (studentNumber, date) =>
|
||||
studyrightMap[studentNumber]?.find(sr => sr.startdate <= date && sr.enddate >= date)?.code
|
||||
findStudyright(studyrightElementsMap[studentNumber], date)?.code ||
|
||||
findStudyright(studentnumberToStudyrightsMap[studentNumber], date)?.facultyCode
|
||||
|
||||
const attemptsArray = []
|
||||
studentList.forEach(sn => attemptsArray.push(...attemptsByStudents[sn]))
|
||||
// 93033 Avoin yliopisto, yhteistyöoppilaitokset"
|
||||
// 93034 Kesäyliopistot
|
||||
const isOpenUni = code => ['9301', 'H930', '93033', '93034'].includes(code)
|
||||
const isMisc = code => ['H906', 'H401'].includes(code) // Language center, Alexander institute (very few numbers)
|
||||
|
||||
attemptsArray.forEach(item => {
|
||||
if (item.faculty) {
|
||||
if (isOpenUni(item.faculty)) {
|
||||
item.faculty = 'OPEN'
|
||||
} else if (isMisc(item.faculty) || !item.faculty.substring(0, 3).match(`^H\\d`)) {
|
||||
item.faculty = 'OTHER'
|
||||
}
|
||||
return
|
||||
}
|
||||
// Credit or enrollment did not have studyright_id. Find based on date,
|
||||
// and if studyrightElement is not found, check studyrights because open uni rights
|
||||
// do not have studyrightelements
|
||||
const faculty = getFaculty(item.studentNumber, item.date)
|
||||
if (faculty?.startsWith('MH') || faculty?.startsWith('KH')) {
|
||||
item.faculty = faculty.substring(1, 4)
|
||||
if (isOpenUni(faculty)) {
|
||||
item.faculty = 'OPEN'
|
||||
} else if (faculty?.substring(0, 3).match(`^H\\d`)) {
|
||||
item.faculty = !isMisc(faculty) ? faculty : 'OTHER'
|
||||
} else {
|
||||
item.faculty = faculty
|
||||
item.faculty = 'OTHER'
|
||||
}
|
||||
})
|
||||
return { attempts: attemptsArray, courses }
|
||||
|
||||
const faculties = [...new Set(attemptsArray.map(({ faculty }) => faculty))]
|
||||
|
||||
const unorderedTableData = await createArrayOfCourses(attemptsArray, courses)
|
||||
|
||||
const tableData = orderBy(unorderedTableData, 'code')
|
||||
|
||||
return { tableData, faculties }
|
||||
}
|
||||
|
||||
module.exports = { getLanguageCenterData }
|
||||
const createArrayOfCourses = async (attempts, courses) => {
|
||||
const fields = { completions: 0, enrollments: 0, ratio: 0 }
|
||||
const semesterStatsMap = attempts.reduce((obj, cur) => {
|
||||
const semester = cur.semestercode
|
||||
if (!obj[cur.courseCode]) {
|
||||
obj[cur.courseCode] = { ...fields }
|
||||
}
|
||||
if (!obj[cur.courseCode][semester]) {
|
||||
obj[cur.courseCode][semester] = { ...fields }
|
||||
}
|
||||
if (!obj[cur.courseCode][semester][cur.faculty]) {
|
||||
obj[cur.courseCode][semester][cur.faculty] = { ...fields }
|
||||
}
|
||||
if (!obj[cur.courseCode].byFaculties?.[cur.faculty]) {
|
||||
if (!obj[cur.courseCode].byFaculties) obj[cur.courseCode].byFaculties = { ...fields }
|
||||
obj[cur.courseCode].byFaculties[cur.faculty] = { ...fields }
|
||||
}
|
||||
const semesterFacultyStats = obj[cur.courseCode][semester][cur.faculty]
|
||||
const allFacultiesTotal = obj[cur.courseCode][semester]
|
||||
const allSemestersTotal = obj[cur.courseCode]
|
||||
const facultyTotalStats = obj[cur.courseCode].byFaculties[cur.faculty]
|
||||
const allStats = [semesterFacultyStats, allFacultiesTotal, allSemestersTotal, facultyTotalStats]
|
||||
for (const stats of allStats) {
|
||||
if (cur.completed) {
|
||||
stats.completions += 1
|
||||
} else {
|
||||
stats.enrollments += 1
|
||||
}
|
||||
stats.ratio = stats.enrollments === 0 ? 1 : stats.completions / stats.enrollments
|
||||
if (stats.ratio > 1) stats.ratio = 1
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
return courses
|
||||
.map(c => ({ ...c.dataValues, bySemesters: { ...semesterStatsMap[c.code], cellStats: {} } }))
|
||||
.filter(course => course.bySemesters)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLanguageCenterData,
|
||||
LANGUAGE_CENTER_REDIS_KEY,
|
||||
computeLanguageCenterData,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const { Organization, ProgrammeModule } = require('../models')
|
||||
const { Organization } = require('../models')
|
||||
const Op = Sequelize.Op
|
||||
const { dbConnections } = require('../database/connection')
|
||||
|
||||
@@ -222,19 +222,6 @@ const faculties = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const degreeProgrammeCodesOfFaculty = async facultyCode =>
|
||||
(
|
||||
await ProgrammeModule.findAll({
|
||||
attributes: ['code'],
|
||||
include: {
|
||||
model: Organization,
|
||||
where: {
|
||||
code: facultyCode,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(({ code }) => code)
|
||||
|
||||
const providersOfFaculty = async facultyCode => {
|
||||
const [result] = await dbConnections.sequelize.query(
|
||||
`SELECT childOrg.code
|
||||
@@ -251,7 +238,6 @@ const isFaculty = facultyCode => facultiesInOodi.includes(facultyCode)
|
||||
|
||||
module.exports = {
|
||||
faculties,
|
||||
degreeProgrammeCodesOfFaculty,
|
||||
isFaculty,
|
||||
providersOfFaculty,
|
||||
facultiesAndProgrammesForTrends,
|
||||
|
||||
@@ -392,11 +392,6 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
model: Semester,
|
||||
attributes: [],
|
||||
required: true,
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.between]: [startDate, endDate],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,119 +3,121 @@ const { ExcludedCourse } = require('../models/models_kone')
|
||||
const { Op } = Sequelize
|
||||
const logger = require('../util/logger')
|
||||
const { combinedStudyprogrammes } = require('./studyprogrammeHelpers.js')
|
||||
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { ProgrammeModule } = require('../models')
|
||||
|
||||
const recursivelyGetModuleAndChildren = async (code, type) => {
|
||||
const getCurriculumVersions = async code => {
|
||||
try {
|
||||
const result = await ProgrammeModule.findAll({ where: { code } })
|
||||
if (!result) return
|
||||
const modified = result.map(r => ({
|
||||
...r,
|
||||
curriculum_period_ids: r.curriculum_period_ids.map(id => parseInt(id.substring(6)) + 1949),
|
||||
}))
|
||||
return modified
|
||||
} catch (e) {
|
||||
logger.error(`Error when searching curriculum versions for code: ${code}`)
|
||||
logger.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const recursivelyGetModuleAndChildren = async (code, curriculum_period_ids) => {
|
||||
const connection = dbConnections.sequelize
|
||||
try {
|
||||
const [result] = await connection.query(
|
||||
`WITH RECURSIVE children as (
|
||||
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS label_name, NULL AS label_code FROM programme_modules pm
|
||||
WHERE pm.code = ?
|
||||
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS parent_name, NULL AS parent_code, NULL as parent_id FROM programme_modules pm
|
||||
WHERE pm.code = ? AND ARRAY[?]::text[] && curriculum_period_ids
|
||||
UNION ALL
|
||||
SELECT pm.*, c.order AS module_order, c.name AS label_name, c.code AS label_code
|
||||
SELECT pm.*, c.order AS module_order, c.name AS parent_name, c.code AS parent_code, c.id as parent_id
|
||||
FROM children c, programme_modules pm, programme_module_children pmc
|
||||
WHERE c.id = pmc.parent_id AND pm.id = pmc.child_id
|
||||
GROUP BY pm.id, c.name, c.code, c.order
|
||||
) SELECT * FROM children WHERE type = ?`,
|
||||
{ replacements: [code, type] }
|
||||
WHERE c.id = pmc.parent_id AND pm.group_id = pmc.child_id AND (ARRAY[?]::text[] && pm.curriculum_period_ids OR pm.type = 'course' OR pm.code is null)
|
||||
GROUP BY pm.id, c.name, c.code, c.order, c.id
|
||||
) SELECT * FROM children`,
|
||||
{ replacements: [code, curriculum_period_ids, curriculum_period_ids] }
|
||||
)
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error(`Error when searching modules and children with code: ${code}`)
|
||||
return []
|
||||
logger.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const labelProgammes = (filteredProgrammes, excludedProgrammes) => {
|
||||
return filteredProgrammes.map(module => {
|
||||
const label = {
|
||||
id: module.label_name.fi,
|
||||
label: `${module.label_code}\n${module.label_name.fi}`,
|
||||
orderNumber: module.module_order,
|
||||
}
|
||||
// check if course is excluded, and hide if it is
|
||||
const foundCourse = excludedProgrammes.find(course => course.course_code === module.code)
|
||||
const visible = { visibility: !foundCourse, id: foundCourse ? foundCourse.id : null }
|
||||
const modifyParent = (course, moduleMap) => {
|
||||
let parent = moduleMap[course.parent_id]
|
||||
const parents = []
|
||||
while (parent) {
|
||||
parents.push(parent)
|
||||
parent = moduleMap[parent.parent_id]
|
||||
}
|
||||
|
||||
return { ...module, label, visible }
|
||||
})
|
||||
const skip = 0
|
||||
const parentsWithCode = parents.filter(p => p.code)
|
||||
if (parentsWithCode.length > 0) {
|
||||
parent = parentsWithCode[skip >= parentsWithCode.length ? parentsWithCode.length - 1 : skip]
|
||||
} else {
|
||||
parent = parents.find(m => m.code)
|
||||
}
|
||||
if (!parent) {
|
||||
return { faulty: true }
|
||||
}
|
||||
return { ...course, parent_id: parent.id, parent_code: parent.code, parent_name: parent.name }
|
||||
}
|
||||
const byProgrammeCode = async code => {
|
||||
const result = await recursivelyGetModuleAndChildren(code, 'course')
|
||||
if (!result.length) return []
|
||||
const excluded = await ExcludedCourse.findAll({
|
||||
|
||||
const getCoursesAndModulesForProgramme = async (code, period_ids) => {
|
||||
if (!period_ids) return {}
|
||||
const result = await recursivelyGetModuleAndChildren(
|
||||
code,
|
||||
period_ids.map(id => `hy-lv-${id - 1949}`)
|
||||
)
|
||||
const courses = result.filter(r => r.type === 'course')
|
||||
const modules = result.filter(r => r.type === 'module')
|
||||
const excludedCourses = await ExcludedCourse.findAll({
|
||||
where: {
|
||||
programme_code: {
|
||||
[Op.eq]: code,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// filter out possible duplicates
|
||||
const filtered = result.filter(
|
||||
(course, index, array) =>
|
||||
array.findIndex(c => c.id == course.id && c.code == course.code && c.label_code == course.label_code) == index
|
||||
)
|
||||
|
||||
// this labels the modules to match the old system in frontend
|
||||
const labeled = labelProgammes(filtered, excluded)
|
||||
|
||||
// Some studyprogrammes (e.g. eläinlääkis) are combined as bachelor-master
|
||||
const isCombined = Object.keys(combinedStudyprogrammes).includes(code)
|
||||
let secondProgrammelabeled = []
|
||||
if (isCombined) {
|
||||
const secondProgCode = combinedStudyprogrammes[code]
|
||||
const secondProgrammeResult = await recursivelyGetModuleAndChildren(secondProgCode, 'course')
|
||||
// Now do the same things for the second set of results as for the first
|
||||
if (secondProgrammeResult.length) {
|
||||
const excludedSecondProgrammes = await ExcludedCourse.findAll({
|
||||
where: {
|
||||
programme_code: {
|
||||
[Op.eq]: secondProgCode,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// filter out possible duplicates
|
||||
const secondFiltered = secondProgrammeResult.filter(
|
||||
(course, index, array) =>
|
||||
array.findIndex(c => c.id == course.id && c.code == course.code && c.label_code == course.label_code) == index
|
||||
)
|
||||
|
||||
// this labels the modules to match the old system in frontend
|
||||
secondProgrammelabeled = labelProgammes(secondFiltered, excludedSecondProgrammes)
|
||||
}
|
||||
}
|
||||
// The second results are for combined programme views
|
||||
return { defaultProgrammeCourses: labeled, secondProgrammeCourses: secondProgrammelabeled }
|
||||
}
|
||||
|
||||
const addExcludedCourses = async (programmecode, coursecodes) => {
|
||||
return ExcludedCourse.bulkCreate(coursecodes.map(c => ({ programme_code: programmecode, course_code: c })))
|
||||
}
|
||||
|
||||
const modulesByProgrammeCode = async code => {
|
||||
const programmeResults = await recursivelyGetModuleAndChildren(code, 'module')
|
||||
|
||||
let secondProgrammeResults = []
|
||||
if (Object.keys(combinedStudyprogrammes).includes(code)) {
|
||||
const masterProgCode = combinedStudyprogrammes[code]
|
||||
secondProgrammeResults = await recursivelyGetModuleAndChildren(masterProgCode, 'module')
|
||||
}
|
||||
// The second results are for combined programme views
|
||||
return { defaultProgrammeResults: programmeResults, secondProgrammeResults: secondProgrammeResults }
|
||||
}
|
||||
|
||||
const removeExcludedCourses = async ids => {
|
||||
return ExcludedCourse.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Op.or]: ids,
|
||||
curriculum_version: {
|
||||
[Op.eq]: period_ids.join(','),
|
||||
},
|
||||
},
|
||||
})
|
||||
const modulesMap = modules.reduce((obj, cur) => ({ ...obj, [cur.id]: cur }), {})
|
||||
const modifiedCourses = courses
|
||||
.map(c => modifyParent(c, modulesMap, code))
|
||||
.filter(c => !c.faulty)
|
||||
.filter(
|
||||
(course1, index, array) =>
|
||||
array.findIndex(course2 => course1.code === course2.code && course1.parent_code === course2.parent_code) ===
|
||||
index
|
||||
)
|
||||
|
||||
return { courses: labelProgammes(modifiedCourses, excludedCourses), modules }
|
||||
}
|
||||
|
||||
module.exports = { byProgrammeCode, addExcludedCourses, removeExcludedCourses, modulesByProgrammeCode }
|
||||
const getCoursesAndModules = async (code, period_ids) => {
|
||||
const defaultProgrammeCourses = await getCoursesAndModulesForProgramme(code, period_ids)
|
||||
if (Object.keys(combinedStudyprogrammes).includes(code)) {
|
||||
const secondProgramme = combinedStudyprogrammes[code]
|
||||
const secondProgrammeCourses = await getCoursesAndModulesForProgramme(secondProgramme, period_ids)
|
||||
return { defaultProgrammeCourses, secondProgrammeCourses }
|
||||
}
|
||||
return { defaultProgrammeCourses, secondProgrammeCourses: { courses: [], modules: [] } }
|
||||
}
|
||||
|
||||
const labelProgammes = (modules, excludedCourses) => {
|
||||
return modules.map(module => {
|
||||
const label = {
|
||||
id: module.parent_name.fi,
|
||||
label: `${module.parent_code}\n${module.parent_name.fi}`,
|
||||
orderNumber: module.module_order,
|
||||
}
|
||||
const foundCourse = excludedCourses.find(course => course.course_code === module.code)
|
||||
const visible = { visibility: !foundCourse, id: foundCourse?.id ?? null }
|
||||
return { ...module, label, visible }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { getCoursesAndModules, getCurriculumVersions }
|
||||
|
||||
@@ -42,20 +42,6 @@ const byId = async id => {
|
||||
model: Course,
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Studyright,
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
where: {
|
||||
type: {
|
||||
[Op.in]: [10, 20, 30],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: SemesterEnrollment,
|
||||
},
|
||||
@@ -79,6 +65,24 @@ const byId = async id => {
|
||||
},
|
||||
}),
|
||||
])
|
||||
const studyrights = await Studyright.findAll({
|
||||
where: {
|
||||
student_studentnumber: id,
|
||||
[Op.not]: { prioritycode: 6 },
|
||||
},
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
where: {
|
||||
type: {
|
||||
[Op.in]: [10, 20, 30],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
student.studyrights = studyrights
|
||||
const tagprogrammes = await ElementDetail.findAll({
|
||||
where: {
|
||||
code: {
|
||||
@@ -98,9 +102,6 @@ const byId = async id => {
|
||||
})
|
||||
student.semester_enrollments = mappedEnrollments
|
||||
|
||||
const filteredStudyrights = student.studyrights.filter(sr => sr.prioritycode !== 6)
|
||||
student.studyrights = filteredStudyrights
|
||||
|
||||
student.tags = tags.map(t => ({
|
||||
...t.get(),
|
||||
programme: tagprogrammes.find(p => p.code === t.tag.studytrack),
|
||||
|
||||
@@ -537,29 +537,30 @@ const getStudentsForProgrammeCourses = async (from, to, programmeCourses) => {
|
||||
`
|
||||
WITH Dist AS (
|
||||
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
|
||||
co.code AS code, co.name AS course_name FROM credit cr
|
||||
co.code AS code, co.name AS course_name, cr."isStudyModule" FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
WHERE cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.course_code IN (:programmeCourses)
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode = 4
|
||||
)
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name, "isStudyModule"
|
||||
FROM Dist
|
||||
GROUP BY dist.code, dist.course_name;
|
||||
GROUP BY dist.code, dist.course_name, dist."isStudyModule";
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { from, to, programmeCourses },
|
||||
}
|
||||
)
|
||||
return res.map(course => ({
|
||||
const result = res.map(course => ({
|
||||
code: course.code,
|
||||
name: course.course_name,
|
||||
totalPassed: parseInt(course.total_students),
|
||||
totalAllcredits: parseInt(course.total_credits),
|
||||
type: 'passed',
|
||||
isStudyModule: course.isStudyModule,
|
||||
}))
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error(`getStudentsForProgrammeCourses() function failed ${e}`)
|
||||
}
|
||||
@@ -597,7 +598,7 @@ const getNotCompletedForProgrammeCourses = async (from, to, programmeCourses) =>
|
||||
}
|
||||
|
||||
const credits = await Credit.findAll({
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode'],
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'isStudyModule'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code', 'name'],
|
||||
@@ -607,9 +608,6 @@ const getNotCompletedForProgrammeCourses = async (from, to, programmeCourses) =>
|
||||
},
|
||||
},
|
||||
where: {
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.between]: [from, to],
|
||||
},
|
||||
@@ -622,6 +620,7 @@ const getNotCompletedForProgrammeCourses = async (from, to, programmeCourses) =>
|
||||
studentnumber: credit.student_studentnumber,
|
||||
credittype: credit.credittypecode,
|
||||
courseName: credit.course.name,
|
||||
isStudyModule: credit.isStudyModule,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -633,6 +632,7 @@ const getNotCompletedForProgrammeCourses = async (from, to, programmeCourses) =>
|
||||
courses[course.code] = {
|
||||
code: course.code,
|
||||
name: course.courseName,
|
||||
isStudyModule: course.isStudyModule,
|
||||
}
|
||||
|
||||
passedByCourseCodes[course.code] = []
|
||||
@@ -663,6 +663,7 @@ const getNotCompletedForProgrammeCourses = async (from, to, programmeCourses) =>
|
||||
name: course.name,
|
||||
totalNotCompleted: [...new Set(notCompletedByCourseCodes[course.code])].length,
|
||||
type: 'notCompleted',
|
||||
isStudyModule: course.isStudyModule,
|
||||
}))
|
||||
} catch (e) {
|
||||
logger.error(`getNotCompletedForProgrammeCourses failed ${e}`)
|
||||
@@ -674,20 +675,19 @@ const getOwnStudentsForProgrammeCourses = async (from, to, programmeCourses, stu
|
||||
`
|
||||
WITH Dist AS (
|
||||
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
|
||||
co.code AS code, co.name AS course_name FROM credit cr
|
||||
co.code AS code, co.name AS course_name, cr."isStudyModule" FROM credit cr
|
||||
INNER JOIN studyright_elements se ON se.studentnumber = cr.student_studentnumber
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.id
|
||||
INNER JOIN organization o ON o.id = cp.organizationcode
|
||||
WHERE cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.course_code IN (:programmeCourses)
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode = 4
|
||||
AND (se.code = :studyprogramme AND cr.attainment_date BETWEEN se.startdate AND se.enddate)
|
||||
)
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name, "isStudyModule"
|
||||
FROM Dist
|
||||
GROUP BY dist.code, course_name;
|
||||
GROUP BY dist.code, course_name, "isStudyModule";
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
@@ -712,12 +712,11 @@ const getOtherStudentsForProgrammeCourses = async (from, to, programmeCourses, s
|
||||
`
|
||||
WITH Dist AS (
|
||||
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
|
||||
co.code AS code, co.name AS course_name FROM credit cr
|
||||
co.code AS code, co.name AS course_name, cr."isStudyModule" FROM credit cr
|
||||
INNER JOIN studyright_elements se ON se.studentnumber = cr.student_studentnumber
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
WHERE cr.attainment_date BETWEEN :from AND :to
|
||||
AND cr.course_code IN (:programmeCourses)
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode = 4
|
||||
AND cr.student_studentnumber IN
|
||||
(
|
||||
@@ -731,9 +730,9 @@ const getOtherStudentsForProgrammeCourses = async (from, to, programmeCourses, s
|
||||
AND (studyright_elements.code = :studyprogramme AND cr.attainment_date BETWEEN studyright_elements.startdate AND studyright_elements.enddate))
|
||||
)
|
||||
)
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name, "isStudyModule"
|
||||
FROM Dist
|
||||
GROUP BY dist.code, course_name;
|
||||
GROUP BY dist.code, course_name, "isStudyModule";
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
@@ -746,6 +745,7 @@ const getOtherStudentsForProgrammeCourses = async (from, to, programmeCourses, s
|
||||
totalOtherProgrammeStudents: parseInt(course.total_students),
|
||||
totalOtherProgrammeCredits: parseInt(course.total_credits),
|
||||
type: 'otherProgramme',
|
||||
isStudyModule: course.isStudyModule,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -755,16 +755,15 @@ const getTransferStudentsForProgrammeCourses = async (from, to, programmeCourses
|
||||
`
|
||||
WITH Dist AS (
|
||||
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
|
||||
co.code AS code, co.name AS course_name FROM credit cr
|
||||
co.code AS code, co.name AS course_name, cr."isStudyModule" FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
WHERE cr.attainment_date BETWEEN :from AND :to
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.course_code IN (:programmeCourses)
|
||||
AND cr.credittypecode = 9
|
||||
)
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name, "isStudyModule"
|
||||
FROM Dist
|
||||
GROUP BY dist.code, course_name;
|
||||
GROUP BY dist.code, course_name, "isStudyModule";
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
@@ -776,6 +775,7 @@ const getTransferStudentsForProgrammeCourses = async (from, to, programmeCourses
|
||||
name: course.course_name,
|
||||
totalTransferStudents: parseInt(course.total_students),
|
||||
totalTransferCredits: parseInt(course.total_credits),
|
||||
isStudyModule: course.isStudyModule,
|
||||
type: 'transfer',
|
||||
}))
|
||||
}
|
||||
@@ -786,10 +786,9 @@ const getStudentsWithoutStudyrightForProgrammeCourses = async (from, to, program
|
||||
`
|
||||
WITH Dist AS (
|
||||
SELECT DISTINCT cr.student_studentnumber AS student, cr.credits AS credits,
|
||||
co.code AS code, co.name AS course_name FROM credit cr
|
||||
co.code AS code, co.name AS course_name, cr."isStudyModule" FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
WHERE cr.attainment_date BETWEEN :from AND :to
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.course_code IN (:programmeCourses)
|
||||
AND cr.credittypecode = 4
|
||||
AND cr.student_studentnumber NOT IN
|
||||
@@ -798,9 +797,9 @@ const getStudentsWithoutStudyrightForProgrammeCourses = async (from, to, program
|
||||
WHERE studyright_elements.studentnumber = cr.student_studentnumber
|
||||
AND cr.attainment_date BETWEEN studyright_elements.startdate AND studyright_elements.enddate)
|
||||
)
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name
|
||||
SELECT COUNT(student) AS total_students, SUM(credits) AS total_credits, code, course_name, "isStudyModule"
|
||||
FROM Dist
|
||||
GROUP BY dist.code, course_name;
|
||||
GROUP BY dist.code, course_name, "isStudyModule";
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
|
||||
@@ -145,6 +145,7 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
|
||||
acc[curr.code] = {
|
||||
code: curr.code,
|
||||
name: curr.name,
|
||||
isStudyModule: curr.isStudyModule,
|
||||
years: {},
|
||||
}
|
||||
}
|
||||
@@ -163,6 +164,7 @@ const getStudyprogrammeCoursesForStudytrack = async (unixMillis, studyprogramme,
|
||||
totalWithoutStudyrightCredits: 0,
|
||||
totalTransferStudents: 0,
|
||||
totalTransferCredits: 0,
|
||||
isStudyModule: curr.isStudyModule,
|
||||
}
|
||||
}
|
||||
switch (curr.type) {
|
||||
|
||||
@@ -261,6 +261,7 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, combinedProgram
|
||||
isAcademicYear,
|
||||
includeAllSpecials,
|
||||
}
|
||||
|
||||
const thesis = await getThesisStats(queryParameters)
|
||||
const thesisSecondProgramme = await getThesisStats(combinedQueryParameters)
|
||||
|
||||
|
||||
@@ -246,230 +246,12 @@ const getPercentage = (value, total) => {
|
||||
return `${((value / total) * 100).toFixed(1)} %`
|
||||
}
|
||||
|
||||
const getEmptyArray = length => new Array(length).fill(0)
|
||||
|
||||
const getBachelorCreditGraphStats = years => ({
|
||||
lte15: {
|
||||
name: 'Less than 15 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte30: {
|
||||
name: '15-29 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte60: {
|
||||
name: '30-59 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte90: {
|
||||
name: '60-89 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte120: {
|
||||
name: '90-119 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte150: {
|
||||
name: '120-149 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte180: {
|
||||
name: '150-179 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte180: {
|
||||
name: '180 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getBachelorMasterCreditGraphStats = years => ({
|
||||
lte200: {
|
||||
name: 'Less than 200 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte220: {
|
||||
name: '200-219 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte240: {
|
||||
name: '220-239 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte260: {
|
||||
name: '240-259 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte280: {
|
||||
name: '260-279 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte300: {
|
||||
name: '280-299 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte300: {
|
||||
name: '300 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getVetenaryCreditGraphStats = years => ({
|
||||
lte210: {
|
||||
name: 'Less than 210 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte240: {
|
||||
name: '210-239 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte270: {
|
||||
name: '240-269 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte300: {
|
||||
name: '270-299 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte330: {
|
||||
name: '300-329 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte360: {
|
||||
name: '330-359 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte360: {
|
||||
name: '360 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getCombinedVetenaryCreditGraphStats = years => ({
|
||||
lte30: {
|
||||
name: 'Less than 30 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte60: {
|
||||
name: '30-59 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte120: {
|
||||
name: '60-179 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte180: {
|
||||
name: '180-239 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte240: {
|
||||
name: '240-299 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte300: {
|
||||
name: '300-359 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte360: {
|
||||
name: '300-359 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte360: {
|
||||
name: '360 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getMasterCreditGraphStats = years => ({
|
||||
lte15: {
|
||||
name: 'Less than 15 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte30: {
|
||||
name: '15-29 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte60: {
|
||||
name: '30-59 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte90: {
|
||||
name: '60-89 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte120: {
|
||||
name: '90-119 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte120: {
|
||||
name: '120 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getDoctoralCreditGraphStats = years => ({
|
||||
lte10: {
|
||||
name: 'Less than 10 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte20: {
|
||||
name: '10-19 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte30: {
|
||||
name: '20-29 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
lte40: {
|
||||
name: '30-39 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
mte40: {
|
||||
name: '40 or more credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getCreditGraphStats = (studyprogramme, years, combinedVetenary, masterStudyRightOnly = false) => {
|
||||
if (combinedVetenary) return getCombinedVetenaryCreditGraphStats(years)
|
||||
if (studyprogramme.includes('KH')) return getBachelorCreditGraphStats(years)
|
||||
if (studyprogramme === 'MH90_001')
|
||||
return masterStudyRightOnly ? getBachelorCreditGraphStats(years) : getVetenaryCreditGraphStats(years)
|
||||
if (studyprogramme.includes('MH'))
|
||||
return masterStudyRightOnly ? getMasterCreditGraphStats(years) : getBachelorMasterCreditGraphStats(years)
|
||||
return getDoctoralCreditGraphStats(years)
|
||||
}
|
||||
|
||||
const bachelorCreditThresholds = ['lte15', 'lte30', 'lte60', 'lte90', 'lte120', 'lte150', 'lte180', 'mte180']
|
||||
const bachelorMasterCreditThresholds = ['lte200', 'lte220', 'lte240', 'lte260', 'lte280', 'lte300', 'mte300']
|
||||
const masterCreditThresholds = ['lte15', 'lte30', 'lte60', 'lte90', 'lte120', 'mte120']
|
||||
const doctoralCreditThresholds = ['lte10', 'lte20', 'lte30', 'lte40', 'mte40']
|
||||
const bachelorCreditAmounts = [15, 30, 60, 90, 120, 150, 180, 180]
|
||||
const bachelorMasterCreditAmounts = [200, 220, 240, 260, 280, 300, 300]
|
||||
const masterCreditAmounts = [15, 30, 60, 90, 120, 120]
|
||||
const doctoralCreditAmounts = [10, 20, 30, 40, 40]
|
||||
const vetenaryCreditThresholds = ['lte210', 'lte240', 'lte270', 'lte300', 'lte330', 'lte360', 'mte360']
|
||||
const vetenaryCreditAmounts = [210, 240, 270, 300, 330, 360, 360]
|
||||
const combinedVetenaryThresholds = ['lte30', 'lte60', 'lte120', 'lte180', 'lte240', 'lte300', 'lte360', 'mte360']
|
||||
const combinedVetenaryAmounts = [30, 60, 120, 180, 240, 300, 360, 360]
|
||||
|
||||
const getCreditThresholds = (studyprogramme, combinedVetenary, masterStudyRightOnly = false) => {
|
||||
if (combinedVetenary)
|
||||
return { creditThresholdKeys: combinedVetenaryThresholds, creditThresholdAmounts: combinedVetenaryAmounts }
|
||||
if (studyprogramme === 'MH90_001') {
|
||||
return masterStudyRightOnly
|
||||
? { creditThresholdKeys: bachelorCreditThresholds, creditThresholdAmounts: bachelorCreditAmounts }
|
||||
: { creditThresholdKeys: vetenaryCreditThresholds, creditThresholdAmounts: vetenaryCreditAmounts }
|
||||
const getCreditThresholds = () => {
|
||||
// Only doctoral and licentiate study programmes (40 study credits) use this as of September 2023
|
||||
return {
|
||||
creditThresholdKeys: ['lte10', 'lte20', 'lte30', 'lte40', 'mte40'],
|
||||
creditThresholdAmounts: [10, 20, 30, 40, 40],
|
||||
}
|
||||
if (studyprogramme.includes('KH')) {
|
||||
return { creditThresholdKeys: bachelorCreditThresholds, creditThresholdAmounts: bachelorCreditAmounts }
|
||||
}
|
||||
if (studyprogramme.includes('MH')) {
|
||||
return masterStudyRightOnly
|
||||
? { creditThresholdKeys: masterCreditThresholds, creditThresholdAmounts: masterCreditAmounts }
|
||||
: { creditThresholdKeys: bachelorMasterCreditThresholds, creditThresholdAmounts: bachelorMasterCreditAmounts }
|
||||
}
|
||||
return { creditThresholdKeys: doctoralCreditThresholds, creditThresholdAmounts: doctoralCreditAmounts }
|
||||
}
|
||||
|
||||
const tableTitles = {
|
||||
@@ -504,54 +286,6 @@ const tableTitles = {
|
||||
'Transferred credits',
|
||||
],
|
||||
},
|
||||
creditProgress: {
|
||||
bachelor: [
|
||||
'',
|
||||
'All',
|
||||
'< 15 credits',
|
||||
'15-29 credits',
|
||||
'30-59 credits',
|
||||
'60-89 credits',
|
||||
'90-119 credits',
|
||||
'120-149 credits',
|
||||
'150-179 credits',
|
||||
'180 ≤ credits',
|
||||
],
|
||||
bachelorMaster: [
|
||||
'',
|
||||
'All',
|
||||
'< 200 credits',
|
||||
'200-219 credits',
|
||||
'220-239 credits',
|
||||
'240-259 credits',
|
||||
'260-279 credits',
|
||||
'280-299 credits',
|
||||
'300 ≤ credits',
|
||||
],
|
||||
master: [
|
||||
'',
|
||||
'All',
|
||||
'< 15 credits',
|
||||
'15-29 credits',
|
||||
'30-59 credits',
|
||||
'60-89 credits',
|
||||
'90-119 credits',
|
||||
'120 ≤ credits',
|
||||
],
|
||||
combinedVetenary: [
|
||||
'',
|
||||
'All',
|
||||
'< 30 credits',
|
||||
'30-59 credits',
|
||||
'60-119 credits',
|
||||
'120-179 credits',
|
||||
'180-239 credits',
|
||||
'240-299 credits',
|
||||
'300-359 credits',
|
||||
'360 ≤ credits',
|
||||
],
|
||||
doctoral: ['', 'All', '< 10 credits', '10-19 credits', '20-29 credits', '30-39 credits', '40 ≤ credits'],
|
||||
},
|
||||
studytracksStart: ['', 'All', 'Started studying', 'Currently enrolled', 'Absent', 'Inactive'],
|
||||
studytracksBasic: ['Graduated'],
|
||||
studytracksCombined: {
|
||||
@@ -561,14 +295,6 @@ const tableTitles = {
|
||||
studytracksEnd: ['Men', 'Women', 'Other/\nUnknown', 'Finland', 'Other'],
|
||||
}
|
||||
|
||||
const getCreditProgressTableTitles = (studyprogramme, combinedVetenaryProgramme, masterProgrammeOnly = false) => {
|
||||
if (combinedVetenaryProgramme) return tableTitles.creditProgress.combinedVetenary
|
||||
if (studyprogramme.includes('KH')) return tableTitles.creditProgress.bachelor
|
||||
if (studyprogramme.includes('MH'))
|
||||
return masterProgrammeOnly ? tableTitles.creditProgress.master : tableTitles.creditProgress.bachelorMaster
|
||||
return tableTitles.creditProgress.doctoral
|
||||
}
|
||||
|
||||
const mapCodesToIds = data => {
|
||||
// Add programme id e.g. TKT
|
||||
const keys = Object.keys(codes)
|
||||
@@ -632,15 +358,8 @@ module.exports = {
|
||||
combinedStudyprogrammes,
|
||||
getThesisType,
|
||||
getPercentage,
|
||||
getBachelorCreditGraphStats,
|
||||
getBachelorMasterCreditGraphStats,
|
||||
getMasterCreditGraphStats,
|
||||
getVetenaryCreditGraphStats,
|
||||
getDoctoralCreditGraphStats,
|
||||
getCreditGraphStats,
|
||||
getCreditThresholds,
|
||||
tableTitles,
|
||||
getCreditProgressTableTitles,
|
||||
isNonMajorCredit,
|
||||
mapCodesToIds,
|
||||
getId,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { indexOf } = require('lodash')
|
||||
const moment = require('moment')
|
||||
|
||||
const {
|
||||
@@ -8,10 +7,7 @@ const {
|
||||
getPercentage,
|
||||
getYearsObject,
|
||||
getCorrectStudentnumbers,
|
||||
getCreditThresholds,
|
||||
getCreditGraphStats,
|
||||
tableTitles,
|
||||
getCreditProgressTableTitles,
|
||||
getGoal,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
@@ -28,13 +24,14 @@ const { getAcademicYearDates } = require('../util/semester')
|
||||
const { countTimeCategories, getStatutoryAbsences } = require('./graduationHelpers')
|
||||
|
||||
const getUnique = studentnumbers => [...new Set(studentnumbers)]
|
||||
const getStudentData = (startDate, students, thresholdKeys, thresholdAmounts) => {
|
||||
const getStudentData = (startDate, students) => {
|
||||
let data = { female: 0, male: 0, otherUnkown: 0, finnish: 0, otherCountries: 0, otherCountriesCounts: {} }
|
||||
thresholdKeys.forEach(t => (data[t] = 0))
|
||||
const creditCounts = []
|
||||
students.forEach(({ genderCode, homeCountryEn, credits }) => {
|
||||
const creditcount = credits
|
||||
.filter(credit => moment(credit.attainment_date).isAfter(startDate))
|
||||
.reduce((prev, curr) => (prev += curr.credits), 0)
|
||||
creditCounts.push(creditcount)
|
||||
|
||||
data.male += genderCode === '1' ? 1 : 0
|
||||
data.female += genderCode === '2' ? 1 : 0
|
||||
@@ -47,27 +44,8 @@ const getStudentData = (startDate, students, thresholdKeys, thresholdAmounts) =>
|
||||
}
|
||||
data.otherCountriesCounts[homeCountryEn] += 1
|
||||
}
|
||||
data[thresholdKeys[0]] += creditcount < thresholdAmounts[0] ? 1 : 0
|
||||
data[thresholdKeys[1]] += creditcount >= thresholdAmounts[0] && creditcount < thresholdAmounts[1] ? 1 : 0
|
||||
data[thresholdKeys[2]] += creditcount >= thresholdAmounts[1] && creditcount < thresholdAmounts[2] ? 1 : 0
|
||||
data[thresholdKeys[3]] += creditcount >= thresholdAmounts[2] && creditcount < thresholdAmounts[3] ? 1 : 0
|
||||
if (thresholdKeys.length > 7) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] && creditcount < thresholdAmounts[6] ? 1 : 0
|
||||
data[thresholdKeys[7]] += creditcount >= thresholdAmounts[6] ? 1 : 0
|
||||
} else if (thresholdKeys.length > 6) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] && creditcount < thresholdAmounts[5] ? 1 : 0
|
||||
data[thresholdKeys[6]] += creditcount >= thresholdAmounts[5] ? 1 : 0
|
||||
} else if (thresholdKeys.length > 5) {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[4] ? 1 : 0
|
||||
} else {
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] ? 1 : 0
|
||||
}
|
||||
})
|
||||
return data
|
||||
return { data, creditCounts }
|
||||
}
|
||||
// type: either Bc + Ms combo or Bc/Ms/T/anything else
|
||||
const addGraduation = async ({ studentNumber, startdate, enddate, graduationAmounts, times, track, type, year }) => {
|
||||
@@ -193,8 +171,6 @@ const getStudytrackDataForTheYear = async ({
|
||||
year,
|
||||
years,
|
||||
data,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
doCombo,
|
||||
}) => {
|
||||
const { specialGroups: includeAllSpecials, graduated: includeGraduated } = settings
|
||||
@@ -203,8 +179,6 @@ const getStudytrackDataForTheYear = async ({
|
||||
const {
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
creditGraphStats,
|
||||
creditTableStats,
|
||||
graduationAmounts,
|
||||
graduationTimes,
|
||||
graduationTimesSecondProg,
|
||||
@@ -213,6 +187,7 @@ const getStudytrackDataForTheYear = async ({
|
||||
emptyTracks,
|
||||
totals,
|
||||
otherCountriesCount,
|
||||
creditCounts,
|
||||
} = data
|
||||
|
||||
await Promise.all(
|
||||
@@ -239,6 +214,7 @@ const getStudytrackDataForTheYear = async ({
|
||||
|
||||
let all = []
|
||||
let studentData = {}
|
||||
let creditCountData = []
|
||||
let started = []
|
||||
let enrolled = []
|
||||
let absent = []
|
||||
@@ -250,13 +226,14 @@ const getStudytrackDataForTheYear = async ({
|
||||
// Get all the studyrights and students for the calculations
|
||||
if (year !== 'Total') {
|
||||
all = await allStudyrights(track, studentnumbers)
|
||||
studentData = getStudentData(startDate, students, creditThresholdKeys, creditThresholdAmounts)
|
||||
;({ data: studentData, creditCounts: creditCountData } = getStudentData(startDate, students))
|
||||
otherCountriesCount[track][year] = studentData.otherCountriesCounts
|
||||
started = await startedStudyrights(track, startDate, startedStudentnumbers)
|
||||
enrolled = await enrolledStudents(track, studentnumbers)
|
||||
absent = await absentStudents(track, studentnumbers)
|
||||
inactive = await inactiveStudyrights(track, studentnumbers)
|
||||
graduated = await graduatedStudyRights(track, startDate, studentnumbers)
|
||||
creditCounts[year] = creditCountData
|
||||
|
||||
const enrolledSecondProgramme = combinedProgramme
|
||||
? await enrolledStudents(combinedProgramme, studentnumbers)
|
||||
@@ -322,25 +299,6 @@ const getStudytrackDataForTheYear = async ({
|
||||
return
|
||||
}
|
||||
|
||||
if (year !== 'Total') {
|
||||
// Count stats for creditgraph for the year per track
|
||||
creditThresholdKeys.forEach(threshold => {
|
||||
creditGraphStats[track][threshold].data[indexOf(years, year)] += studentData[threshold]
|
||||
creditGraphStats[track][threshold].data[indexOf(years, 'Total')] += studentData[threshold]
|
||||
})
|
||||
|
||||
// Count stats for the credit progress table for the year per track
|
||||
creditTableStats[track] = [
|
||||
[year, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
|
||||
...creditTableStats[track],
|
||||
]
|
||||
} else {
|
||||
const totalCredits = creditTableStats[track].reduce(
|
||||
(a, b) => a.map((colVal, idx) => colVal + (b[idx] || 0)),
|
||||
Array(creditThresholdKeys.length + 2).fill(0)
|
||||
)
|
||||
creditTableStats[track] = [[year, ...totalCredits.slice(1)], ...creditTableStats[track]]
|
||||
}
|
||||
// Count stats for the main studytrack table grouped by tracks
|
||||
mainStatsByTrack[track] = [
|
||||
[
|
||||
@@ -441,8 +399,6 @@ const getStudytrackOptions = (studyprogramme, studytrackNames, studytracks, empt
|
||||
const getEmptyStatsObjects = (years, studytracks, studyprogramme, combinedProgramme) => {
|
||||
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
|
||||
const mainStatsByTrack = {}
|
||||
const creditGraphStats = {}
|
||||
const creditTableStats = {}
|
||||
const graduationAmounts = {}
|
||||
const goal = getGoal(studyprogramme)
|
||||
const goalSecondProg = getGoal(combinedProgramme)
|
||||
@@ -463,8 +419,6 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme, combinedProgra
|
||||
studytracks.forEach(async track => {
|
||||
otherCountriesCount[track] = {}
|
||||
mainStatsByTrack[track] = []
|
||||
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years, combinedProgramme, true)
|
||||
creditTableStats[track] = []
|
||||
graduationAmounts[track] = { basic: getYearsObject({ years }), combo: getYearsObject({ years }) }
|
||||
totalAmounts[track] = getYearsObject({ years })
|
||||
totals[track] = {
|
||||
@@ -482,8 +436,6 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme, combinedProgra
|
||||
return {
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
creditGraphStats,
|
||||
creditTableStats,
|
||||
graduationAmounts,
|
||||
graduationTimes,
|
||||
graduationAmountsSecondProg,
|
||||
@@ -508,14 +460,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, combinedPro
|
||||
: [studyprogramme]
|
||||
|
||||
const studytrackNames = associations.studyTracks
|
||||
const data = getEmptyStatsObjects(years, studytracks, studyprogramme, combinedProgramme)
|
||||
const onlyMasterStudyrights = true
|
||||
|
||||
const { creditThresholdKeys, creditThresholdAmounts } = getCreditThresholds(
|
||||
studyprogramme,
|
||||
combinedProgramme,
|
||||
onlyMasterStudyrights
|
||||
)
|
||||
const data = { ...getEmptyStatsObjects(years, studytracks, studyprogramme, combinedProgramme), creditCounts: {} }
|
||||
const doCombo = studyprogramme.startsWith('MH') && !['MH30_001', 'MH30_003'].includes(studyprogramme)
|
||||
const yearsReversed = [...years].reverse()
|
||||
for (const year of yearsReversed) {
|
||||
@@ -529,8 +474,6 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, combinedPro
|
||||
year,
|
||||
years,
|
||||
data,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
doCombo,
|
||||
})
|
||||
}
|
||||
@@ -549,10 +492,6 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, combinedPro
|
||||
studytrackOptions,
|
||||
includeGraduated: settings.graduated,
|
||||
populationTitles: [...tableTitles.studytracksStart, ...graduatedTitles, ...tableTitles.studytracksEnd],
|
||||
creditTableTitles:
|
||||
studyprogramme === 'MH90_001'
|
||||
? tableTitles['creditProgress']['bachelor']
|
||||
: getCreditProgressTableTitles(studyprogramme, combinedProgramme, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,57 +3,39 @@ const os = require('os')
|
||||
const winston = require('winston')
|
||||
const { WinstonGelfTransporter } = require('winston-gelf-transporter')
|
||||
|
||||
const { isDev, isProduction } = require('../conf-backend')
|
||||
const { isProduction } = require('../conf-backend')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
let transports = []
|
||||
|
||||
if (isDev) {
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
const formatDate = timestamp => new Date(timestamp).toLocaleString('fi-FI').replace(' klo', '')
|
||||
|
||||
const consoleFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) =>
|
||||
`${formatDate(timestamp)} ${level}: ${message} ${Object.keys(rest).length === 0 ? '' : JSON.stringify(rest)}`
|
||||
)
|
||||
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
level: 'debug',
|
||||
format: combine(splat(), timestamp(), consoleFormat),
|
||||
})
|
||||
)
|
||||
|
||||
if (isProduction) {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
level: 'debug',
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
new WinstonGelfTransporter({
|
||||
handleExceptions: true,
|
||||
host: 'svm-116.cs.helsinki.fi',
|
||||
port: 9503,
|
||||
protocol: 'udp',
|
||||
hostName: os.hostname(),
|
||||
additional: {
|
||||
app: 'oodikone',
|
||||
environment: 'production',
|
||||
},
|
||||
})
|
||||
)
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
verbose: 4,
|
||||
debug: 5,
|
||||
silly: 6,
|
||||
}
|
||||
|
||||
const prodFormat = winston.format.printf(({ level, ...rest }) =>
|
||||
JSON.stringify({
|
||||
level: levels[level],
|
||||
...rest,
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
|
||||
if (isProduction) {
|
||||
transports.push(
|
||||
new WinstonGelfTransporter({
|
||||
handleExceptions: true,
|
||||
host: 'svm-116.cs.helsinki.fi',
|
||||
port: 9503,
|
||||
protocol: 'udp',
|
||||
hostName: os.hostname(),
|
||||
additional: {
|
||||
app: 'oodikone',
|
||||
environment: 'production',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
@@ -7,7 +7,7 @@ const initializeSentry = app => {
|
||||
if (!sentryRelease || !sentryEnvironment || runningInCI || isStaging) return
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://020b79f0cbb14aad94cc9d69a1ea9d52@sentry.cs.helsinki.fi/2',
|
||||
dsn: 'https://b6d3f10ac9a2c333461c74312bfb71d1@toska.cs.helsinki.fi/14',
|
||||
environment: sentryEnvironment,
|
||||
release: sentryRelease,
|
||||
integrations: [new Sentry.Integrations.Http({ tracing: true }), new Tracing.Integrations.Express({ app })],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const mapToProviders = elementDetails => {
|
||||
return elementDetails.map(r => {
|
||||
const mapToProviders = programmeCodes => {
|
||||
return programmeCodes.map(r => {
|
||||
const isNumber = str => !Number.isNaN(Number(str))
|
||||
if (r.includes('_')) {
|
||||
const [left, right] = r.split('_')
|
||||
@@ -10,7 +10,16 @@ const mapToProviders = elementDetails => {
|
||||
}
|
||||
if (/^(T)[0-9]{6}$/.test(r)) {
|
||||
const numbers = r.substring(1)
|
||||
return `7${numbers}`
|
||||
const courseProvider = `7${numbers}`
|
||||
const asNum = Number(courseProvider)
|
||||
// God-awful hack to fix a bunch of doctoral degrees
|
||||
// that got the wrong provider
|
||||
if (asNum < 7920111 && asNum > 7920102) {
|
||||
return `${asNum + 1}`
|
||||
} else if (asNum === 7920111) {
|
||||
return `7920103`
|
||||
}
|
||||
return `${asNum}`
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"react/jsx-no-constructed-context-values": "off",
|
||||
"react/jsx-no-useless-fragment": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"react/jsx-no-bind": "error",
|
||||
"prefer-object-spread/prefer-object-spread": "error",
|
||||
"babel/no-invalid-this": "error",
|
||||
|
||||
@@ -107,7 +107,7 @@ export const handleRequest = store => next => async action => {
|
||||
// All tags used for invalidating cache must be defined here
|
||||
export const RTKApi = createApi({
|
||||
reducerPath: 'api',
|
||||
tagTypes: ['StudyGuidanceGroups', 'Semester'],
|
||||
tagTypes: ['StudyGuidanceGroups', 'Semester', 'CustomPopulationSearches'],
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: apiBasePath,
|
||||
prepareHeaders: headers => {
|
||||
|
||||
@@ -101,7 +101,7 @@ export default {
|
||||
- **Inactive** Sisältää kaikki opinto-oikeudet, jotka ovat alkaneet kyseisenä vuonna, mutta jotka ovat sittemmin vanhentuneet ilman, että opiskelija on valmistunut ohjelmasta. Sisältää myös opinto-oikeudet,
|
||||
joissa opiskelija on laiminlyönyt ilmoittautumisen tälle lukukaudelle.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, ohjelmaan ja siitä pois siirtyneet opiskelijat sekä erillisopinto-oikeudella opiskelevat.
|
||||
Mukana ovat oletusarvoisesti myös ohjelmaan ja siitä pois siirtyneet opiskelijat.
|
||||
Taulukko näyttää myös näistä opiskelijoista miesten, naisten ja suomalaisten osuudet sekä valmistuneiden määrän. `,
|
||||
StudentProgress: `
|
||||
Kuvaa tiedekuntaan kuuluvien eri ohjemien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
|
||||
@@ -114,6 +114,6 @@ export default {
|
||||
|
||||
HUOM! Mukana ovat myös muuhun kuin valittuun koulutusohjelmaan tai opintosuuntaan tehdyt opintopisteet. Opintopisteet on laskettu opinto-oikeuden alkamisesta, eikä tällä hetkellä ota huomioon mahdollisia hyväksilukuja.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, erillisopinto-oikeudella opiskelevat ja ohjelmaan siirtyneet.
|
||||
Mukana ovat oletusarvoisesti myös ohjelmaan siirtyneet.
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export default {
|
||||
|
||||
**Non-degree students**: Opiskelijat, joilla ei ole tutkinto-oikeutta Helsingin yliopistossa.
|
||||
|
||||
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
|
||||
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet. **Luku ei sisälly total-sarakkeeseen**.
|
||||
|
||||
**Type**: Kertoo onko kyseessä kurssi vai opintokokonaisuus.
|
||||
`,
|
||||
StudentsOfProgrammeCourses: `
|
||||
Sisältää kurssien opiskelijat.
|
||||
@@ -45,7 +47,9 @@ export default {
|
||||
**Non-major students**: Sisältää kurssin opiskelijat, joilla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan. Sisältää
|
||||
esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat.
|
||||
|
||||
**Transferred students**: Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet
|
||||
**Transferred students**: Sisältää opiskelijat, jotka ovat suorittaneet kyseisenä vuonna hyväksiluettuja opintopisteitä tähän koulutusohjelmaan. Mukana ovat kaikenlaisilla opinto-oikeuksilla hyväksiluetut opintopisteet. Lukua **ei lasketa mukaan Total-sarakkeeseen**.
|
||||
|
||||
**Type**: Kertoo onko kyseessä kurssi vai opintokokonaisuus.
|
||||
`,
|
||||
GraduatedAndThesisWritersOfTheProgramme: `
|
||||
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman tai pro gradu-tutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
|
||||
@@ -108,7 +112,7 @@ export default {
|
||||
|
||||
Kenttien **Currently enrolled, Absent, Inactive** ja **Graduated** arvot tuottavat yhteenlaskettuna kentän **All** arvon.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, ohjelmaan ja siitä pois siirtyneet opiskelijat sekä erillisopinto-oikeudella opiskelevat.
|
||||
Mukana ovat oletusarvoisesti myös ohjelmaan ja siitä pois siirtyneet opiskelijat.
|
||||
Taulukko näyttää myös näistä opiskelijoista miesten, naisten ja suomalaisten osuudet sekä valmistuneiden määrän.
|
||||
|
||||
Yläosan valikosta on mahdollista valita tarkasteluun yhden opintosuunnan opiskelijat. Sekä opiskelijoiden yleistiedot että edistyminen opintopisteittäin
|
||||
@@ -126,7 +130,7 @@ export default {
|
||||
- Kenttien **Currently enrolled, Absent, Inactive** ja **Graduated licentiate** (tai **Graduated master**) arvot tuottavat yhteenlaskettuna kentän **All** arvon.
|
||||
- Lisäksi kenttien **Currently enrolled, Absent, Inactive** arvot sisältävät opiskelijat, joilla on kyseinen status riippumatta siitä, onko opiskelija kandi- vai maisterivaiheen opiskelija.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, ohjelmaan ja siitä pois siirtyneet opiskelijat sekä erillisopinto-oikeudella opiskelevat.
|
||||
Mukana ovat oletusarvoisesti myös ohjelmaan ja siitä pois siirtyneet opiskelijat.
|
||||
Taulukko näyttää myös näistä opiskelijoista miesten, naisten ja suomalaisten osuudet sekä valmistuneiden määrän.
|
||||
|
||||
Yläosan valikosta on mahdollista valita tarkasteluun yhden opintosuunnan opiskelijat. Sekä opiskelijoiden yleistiedot että edistyminen opintopisteittäin
|
||||
@@ -137,7 +141,7 @@ export default {
|
||||
|
||||
HUOM! Mukana ovat myös muuhun kuin valittuun koulutusohjelmaan tai opintosuuntaan tehdyt opintopisteet.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, erillisopinto-oikeudella opiskelevat ja ohjelmaan siirtyneet.
|
||||
Mukana ovat oletusarvoisesti myös ohjelmaan siirtyneet opiskelijat.
|
||||
|
||||
Mikäli yläosan valikosta valittuna on jokin tietty opintosuunta, myös nämä graafit ja taulukot kuvaavat kyseisen opintosuunnan opiskelijoita.
|
||||
`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import moment from 'moment'
|
||||
import Datetime from 'react-datetime'
|
||||
import { filter, maxBy, sortBy, intersection } from 'lodash'
|
||||
import { range, filter, maxBy, sortBy, intersection } from 'lodash'
|
||||
import pathToRegexp from 'path-to-regexp'
|
||||
import { API_DATE_FORMAT, DISPLAY_DATE_FORMAT } from '../constants'
|
||||
import toskaLogo from '../assets/toska.png'
|
||||
@@ -316,7 +316,7 @@ const getEarliestStudyRightElement = studyright => {
|
||||
}
|
||||
|
||||
export const getTargetCreditsForProgramme = code => {
|
||||
if (code === 'MH30_001') return 360
|
||||
if (code === 'MH30_001' || code === 'KH90_001-MH90_001') return 360
|
||||
if (code === 'MH30_003') return 330
|
||||
if (code === 'MH30_004') return 150
|
||||
if (code === 'MH90_001') return 180
|
||||
@@ -326,7 +326,7 @@ export const getTargetCreditsForProgramme = code => {
|
||||
// Those codes begin with 'LIS' is it 40 credits or something else?
|
||||
}
|
||||
|
||||
const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
|
||||
export const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
|
||||
|
||||
/**
|
||||
* Get start and end dates for study right element. For bachelor a 3 year target is used
|
||||
@@ -379,6 +379,39 @@ export const getStudyRightElementTargetDates = (studyRight, absences = []) => {
|
||||
]
|
||||
}
|
||||
|
||||
export const TimeDivision = {
|
||||
ACADEMIC_YEAR: 'academic-year',
|
||||
CALENDAR_YEAR: 'calendar-year',
|
||||
SEMESTER: 'semester',
|
||||
}
|
||||
|
||||
/* Returns an array of credit categories depending on parameters, shows the high limit
|
||||
of the category, for example [20, 40, 60, 80, 100, 120] where the first category is 0 - 20 */
|
||||
export const getCreditCategories = (
|
||||
cumulative,
|
||||
timeDivision,
|
||||
programmeCredits,
|
||||
timeSlots,
|
||||
creditCategoryAmount,
|
||||
minCredits = 0
|
||||
) => {
|
||||
// In calendar-year mode, minus 30 from target credits because programmes (usually) start in autumn,
|
||||
// also if current date is before august, minus 30
|
||||
const isCalendar = timeDivision === TimeDivision.CALENDAR_YEAR
|
||||
const isPastAugust = new Date().getMonth() > 6
|
||||
const calendarModifier = 30 + (isPastAugust ? 0 : 30)
|
||||
const creditsByTimeslots =
|
||||
timeSlots.length * (timeDivision === TimeDivision.SEMESTER ? 30 : 60) - (isCalendar ? calendarModifier : 0)
|
||||
const maxCredits = creditsByTimeslots > programmeCredits ? programmeCredits : creditsByTimeslots
|
||||
const creditCategoryArray = []
|
||||
for (let i = 1; i <= creditCategoryAmount; i++) creditCategoryArray.push(i)
|
||||
|
||||
const limitBreaks = cumulative
|
||||
? creditCategoryArray.map(num => Math.round(minCredits + (num * (maxCredits - minCredits)) / creditCategoryAmount))
|
||||
: [15, 30, 45, 60].map(limit => limit * (timeDivision === TimeDivision.SEMESTER ? 0.5 : 1))
|
||||
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
|
||||
}
|
||||
|
||||
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
|
||||
|
||||
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
+64
-106
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, number } from 'prop-types'
|
||||
import { Table, Tab } from 'semantic-ui-react'
|
||||
import { maxBy } from 'lodash'
|
||||
import { useGetFacultiesQuery } from 'redux/facultyStats'
|
||||
import moment from 'moment'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import { getNewestProgramme } from '../../../common'
|
||||
@@ -22,10 +22,19 @@ const CreditGainTableRow = ({ statistics, code }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const CreditGainTable = ({ data, selectedStudents, totalCredits, headerText }) => {
|
||||
const CreditGainTable = ({ data, totalCredits, headerText }) => {
|
||||
const tableRows = Object.keys(data)
|
||||
.sort()
|
||||
.map(code => <CreditGainTableRow key={code} statistics={data[code]} code={code} />)
|
||||
.map(code => {
|
||||
if (data[code].credits === 0) return null
|
||||
return <CreditGainTableRow key={code} statistics={data[code]} code={code} />
|
||||
})
|
||||
|
||||
const totalStudents = Object.values(data).reduce((acc, code) => {
|
||||
if (code.credits === 0) return acc
|
||||
return acc + code.students.length
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header style={{ backgroundColor: 'whitesmoke' }}>
|
||||
@@ -39,7 +48,7 @@ const CreditGainTable = ({ data, selectedStudents, totalCredits, headerText }) =
|
||||
{tableRows}
|
||||
<Table.Row style={{ backgroundColor: 'ghostwhite' }}>
|
||||
<Table.Cell style={{ fontWeight: '700' }}>Total</Table.Cell>
|
||||
<Table.Cell style={{ fontWeight: '700' }}>{selectedStudents.length}</Table.Cell>
|
||||
<Table.Cell style={{ fontWeight: '700' }}>{totalStudents}</Table.Cell>
|
||||
<Table.Cell style={{ fontWeight: '700' }}>{totalCredits}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
@@ -47,89 +56,79 @@ const CreditGainTable = ({ data, selectedStudents, totalCredits, headerText }) =
|
||||
)
|
||||
}
|
||||
|
||||
const CoursePopulationCreditGainTable = ({
|
||||
samples,
|
||||
selectedStudents,
|
||||
export const CoursePopulationCreditGainTable = ({
|
||||
students,
|
||||
codes,
|
||||
from,
|
||||
to,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics,
|
||||
faculties,
|
||||
}) => {
|
||||
const { data: faculties } = useGetFacultiesQuery()
|
||||
const [programmeCreditsStatistics, setStatistics] = useState({})
|
||||
const [facultyCreditsStatistics, setFacStatistics] = useState({})
|
||||
const [totalCredits, setTotalCredits] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (samples && selectedStudents && Object.keys(populationStatistics).length > 0) {
|
||||
const programmeCredits = {}
|
||||
const facultyCredits = {}
|
||||
if (!faculties || !students || Object.keys(populationStatistics ?? {}).length === 0) return
|
||||
const programmeCredits = {}
|
||||
const facultyCredits = {}
|
||||
|
||||
let tempTotal = 0
|
||||
const filteredStudents = samples.filter(student => selectedStudents.includes(student.studentNumber))
|
||||
filteredStudents.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics.elementdetails.data
|
||||
)
|
||||
let tempTotal = 0
|
||||
students.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics.elementdetails.data
|
||||
)
|
||||
|
||||
if (!programmeCredits[programme.code]) {
|
||||
programmeCredits[programme.code] = { name: programme.name, students: [], credits: 0 }
|
||||
}
|
||||
if (!programmeCredits[programme.code]) {
|
||||
programmeCredits[programme.code] = { name: programme.name, students: [], credits: 0 }
|
||||
}
|
||||
|
||||
const faculty = faculties.find(fac => fac.code === programme.facultyCode) || {
|
||||
// in case there isn't a faculty associated with studyright
|
||||
code: '0000',
|
||||
name: { fi: 'No associated faculty' },
|
||||
}
|
||||
const faculty = faculties?.find(fac => fac.code === programme.facultyCode) || {
|
||||
// in case there isn't a faculty associated with studyright
|
||||
code: '0000',
|
||||
name: { fi: 'No associated faculty' },
|
||||
}
|
||||
|
||||
if (!facultyCredits[faculty.code]) {
|
||||
facultyCredits[faculty.code] = { name: faculty.name, students: [], credits: 0 }
|
||||
}
|
||||
if (!facultyCredits[faculty.code]) {
|
||||
facultyCredits[faculty.code] = { name: faculty.name, students: [], credits: 0 }
|
||||
}
|
||||
|
||||
programmeCredits[programme.code].students.push(student.studentNumber)
|
||||
facultyCredits[faculty.code].students.push(student.studentNumber)
|
||||
const coursesBetween = []
|
||||
courses.forEach(course => {
|
||||
if (
|
||||
new Date(from).getTime() <= new Date(course.date).getTime() &&
|
||||
new Date(course.date).getTime() <= new Date(to).getTime() &&
|
||||
course.passed
|
||||
) {
|
||||
if (course.grade === 'Hyv.') {
|
||||
coursesBetween.push({ grade: course.grade, value: 1, credits: course.credits })
|
||||
} else {
|
||||
coursesBetween.push({ grade: course.grade, value: Number(course.grade), credits: course.credits })
|
||||
}
|
||||
const coursesBetween = []
|
||||
courses.forEach(course => {
|
||||
if (moment(course.date).isBetween(moment(from), moment(to)) && course.passed) {
|
||||
if (course.grade === 'Hyv.') {
|
||||
coursesBetween.push({ grade: course.grade, value: 1, credits: course.credits })
|
||||
} else {
|
||||
coursesBetween.push({ grade: course.grade, value: Number(course.grade), credits: course.credits })
|
||||
}
|
||||
})
|
||||
if (maxBy(coursesBetween, course => course.value)) {
|
||||
const maxCredits = maxBy(coursesBetween, course => course.value).credits
|
||||
programmeCredits[programme.code].credits += maxCredits
|
||||
facultyCredits[faculty.code].credits += maxCredits
|
||||
tempTotal += maxBy(coursesBetween, course => course.value).credits
|
||||
}
|
||||
})
|
||||
setTotalCredits(tempTotal)
|
||||
setStatistics(programmeCredits)
|
||||
setFacStatistics(facultyCredits)
|
||||
}
|
||||
}, [selectedStudents])
|
||||
if (maxBy(coursesBetween, course => course.value)) {
|
||||
programmeCredits[programme.code].students.push(student.studentNumber)
|
||||
facultyCredits[faculty.code].students.push(student.studentNumber)
|
||||
|
||||
const maxCredits = maxBy(coursesBetween, course => course.value).credits
|
||||
programmeCredits[programme.code].credits += maxCredits
|
||||
facultyCredits[faculty.code].credits += maxCredits
|
||||
tempTotal += maxBy(coursesBetween, course => course.value).credits
|
||||
}
|
||||
})
|
||||
setTotalCredits(tempTotal)
|
||||
setStatistics(programmeCredits)
|
||||
setFacStatistics(facultyCredits)
|
||||
}, [students, faculties])
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'Faculty',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<CreditGainTable
|
||||
data={facultyCreditsStatistics}
|
||||
selectedStudents={selectedStudents}
|
||||
totalCredits={totalCredits}
|
||||
headerText="Faculty"
|
||||
/>
|
||||
<CreditGainTable data={facultyCreditsStatistics} totalCredits={totalCredits} headerText="Faculty" />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
@@ -137,12 +136,7 @@ const CoursePopulationCreditGainTable = ({
|
||||
menuItem: 'Programme',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<CreditGainTable
|
||||
data={programmeCreditsStatistics}
|
||||
selectedStudents={selectedStudents}
|
||||
totalCredits={totalCredits}
|
||||
headerText="Programme"
|
||||
/>
|
||||
<CreditGainTable data={programmeCreditsStatistics} totalCredits={totalCredits} headerText="Programme" />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
@@ -155,39 +149,3 @@ const CoursePopulationCreditGainTable = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
CoursePopulationCreditGainTable.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
}
|
||||
|
||||
CreditGainTableRow.propTypes = {
|
||||
statistics: shape({}).isRequired,
|
||||
code: string.isRequired,
|
||||
}
|
||||
|
||||
CreditGainTable.propTypes = {
|
||||
data: shape({}).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
totalCredits: number.isRequired,
|
||||
headerText: string.isRequired,
|
||||
}
|
||||
|
||||
CoursePopulationCreditGainTable.propTypes = {
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
samples: arrayOf(shape({})).isRequired,
|
||||
codes: arrayOf(string).isRequired,
|
||||
from: number.isRequired,
|
||||
to: number.isRequired,
|
||||
studentToTargetCourseDateMap: shape({}),
|
||||
populationStatistics: shape({}).isRequired,
|
||||
faculties: arrayOf(shape({})).isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations, faculties }) => {
|
||||
return {
|
||||
faculties: faculties.data,
|
||||
populationStatistics: populations.data,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(CoursePopulationCreditGainTable)
|
||||
|
||||
+42
-73
@@ -1,59 +1,46 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import React from 'react'
|
||||
import { Progress, Table } from 'semantic-ui-react'
|
||||
import { intersection, orderBy } from 'lodash'
|
||||
import { shape, bool, arrayOf, string, number } from 'prop-types'
|
||||
import moment from 'moment'
|
||||
import { getHighestGradeOfCourseBetweenRange } from '../../../common'
|
||||
import ExternalGradeFilterToggle from './ExternalGradeFilterToggle'
|
||||
|
||||
const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStudents, samples, codes, from, to }) => {
|
||||
const [courseGrades, setCourseGrades] = useState([])
|
||||
export const CoursePopulationGradeDist = ({ singleCourseStats, students, courseCodes, from, to }) => {
|
||||
const courseGrades = []
|
||||
|
||||
useEffect(() => {
|
||||
if (samples && singleCourseStats?.unifyStats?.alternatives) {
|
||||
const filteredGradeArray = []
|
||||
const grades = {}
|
||||
if (students && singleCourseStats?.unifyStats?.alternatives) {
|
||||
const grades = {}
|
||||
|
||||
samples.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const hasEnrollment =
|
||||
student.enrollments?.some(
|
||||
e => codes.includes(e.course_code) && moment(e.enrollment_date_time).isBetween(moment(from), moment(to))
|
||||
) ?? false
|
||||
const highestGrade = getHighestGradeOfCourseBetweenRange(courses, from, to)
|
||||
if (!highestGrade && hasEnrollment) {
|
||||
if (!grades['No grade']) grades['No grade'] = []
|
||||
grades['No grade'].push(student.studentNumber)
|
||||
}
|
||||
|
||||
if (highestGrade) {
|
||||
if (!grades[highestGrade.grade]) {
|
||||
grades[highestGrade.grade] = []
|
||||
}
|
||||
grades[highestGrade.grade].push(student.studentNumber)
|
||||
}
|
||||
})
|
||||
|
||||
Object.keys(grades).forEach(grade => {
|
||||
const filteredGrades = intersection(selectedStudents, grades[grade])
|
||||
filteredGradeArray.push({ grade, amount: filteredGrades.length })
|
||||
})
|
||||
|
||||
setCourseGrades(filteredGradeArray)
|
||||
}
|
||||
}, [pending, selectedStudents])
|
||||
|
||||
const sortedCourseGrades = orderBy(
|
||||
courseGrades,
|
||||
e => {
|
||||
if (Number(e.grade)) {
|
||||
return `_${e.grade}`
|
||||
students.forEach(student => {
|
||||
const courses = student.courses.filter(c => courseCodes.includes(c.course_code))
|
||||
const hasEnrollment =
|
||||
student.enrollments?.some(
|
||||
e => courseCodes.includes(e.course_code) && moment(e.enrollment_date_time).isBetween(moment(from), moment(to))
|
||||
) ?? false
|
||||
const highestGrade = getHighestGradeOfCourseBetweenRange(courses, from, to)
|
||||
if (!highestGrade && hasEnrollment) {
|
||||
if (!grades['No grade']) grades['No grade'] = []
|
||||
grades['No grade'].push(student.studentNumber)
|
||||
}
|
||||
return e.grade
|
||||
},
|
||||
['desc']
|
||||
)
|
||||
|
||||
if (highestGrade) {
|
||||
if (!grades[highestGrade.grade]) {
|
||||
grades[highestGrade.grade] = []
|
||||
}
|
||||
grades[highestGrade.grade].push(student.studentNumber)
|
||||
}
|
||||
})
|
||||
|
||||
Object.keys(grades).forEach(grade => {
|
||||
const filteredGrades = intersection(
|
||||
students.map(s => s.studentNumber),
|
||||
grades[grade]
|
||||
)
|
||||
courseGrades.push({ grade, amount: filteredGrades.length })
|
||||
})
|
||||
}
|
||||
|
||||
const sortedCourseGrades = orderBy(courseGrades, e => (Number(e.grade) ? `_${e.grade}` : e.grade), ['desc'])
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
@@ -63,8 +50,7 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
<Table.HeaderCell>Grades</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={selectedStudents.length})</span>
|
||||
<div style={{ fontWeight: 100 }}>(n={students.length})</div>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
@@ -79,13 +65,13 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
<Table.Cell>{grade.grade}</Table.Cell>
|
||||
<Table.Cell>{grade.amount}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{selectedStudents.length && (
|
||||
<Progress
|
||||
percent={Math.round((grade.amount / selectedStudents.length) * 100)}
|
||||
progress
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Progress
|
||||
value={grade.amount}
|
||||
total={students.length}
|
||||
progress="percent"
|
||||
precision={0}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
@@ -93,20 +79,3 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
CoursePopulationCreditDist.propTypes = {
|
||||
singleCourseStats: shape({}).isRequired,
|
||||
pending: bool.isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
samples: arrayOf(shape({})).isRequired,
|
||||
codes: arrayOf(string).isRequired,
|
||||
from: number.isRequired,
|
||||
to: number.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ singleCourseStats }) => ({
|
||||
singleCourseStats: singleCourseStats.stats,
|
||||
pending: singleCourseStats.pending,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(CoursePopulationCreditDist)
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, Progress } from 'semantic-ui-react'
|
||||
import { languageAbbreviations } from '../../../common'
|
||||
|
||||
const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
|
||||
const [languages, setLanguages] = useState()
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (samples) {
|
||||
const lang = {}
|
||||
let students = 0
|
||||
|
||||
samples.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const filteredCourses = courses
|
||||
.filter(
|
||||
course =>
|
||||
new Date(from).getTime() <= new Date(course.date).getTime() &&
|
||||
new Date(course.date).getTime() <= new Date(to).getTime()
|
||||
)
|
||||
// .filter(course => course.passed === true)
|
||||
.sort((a, b) => (new Date(a.date) <= new Date(b.date) ? -1 : 1))
|
||||
|
||||
if (filteredCourses[0] && filteredCourses[0].language !== null) {
|
||||
if (!lang[filteredCourses[0].language]) {
|
||||
lang[filteredCourses[0].language] = 0
|
||||
}
|
||||
lang[filteredCourses[0].language] += 1
|
||||
students++
|
||||
}
|
||||
})
|
||||
setLanguages(lang)
|
||||
setTotal(students)
|
||||
}
|
||||
}, [samples])
|
||||
|
||||
const getLanguage = lang => (lang in languageAbbreviations ? languageAbbreviations[lang] : lang)
|
||||
|
||||
if (total === 0) return <p>No data available!</p>
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Languages</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Language Distribution
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={total})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{total !== 0 &&
|
||||
Object.entries(languages).map(language => (
|
||||
<Table.Row key={`language-table-row-${language[0]}`}>
|
||||
<Table.Cell>{getLanguage(language[0])}</Table.Cell>
|
||||
<Table.Cell>{language[1]}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{total !== 0 && (
|
||||
<Progress percent={Math.round((language[1] / total) * 100)} progress style={{ margin: 0 }} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoursePopulationLanguageDist
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { Table, Progress } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { languageAbbreviations } from '../../../common'
|
||||
|
||||
export const CoursePopulationLanguageDist = ({ samples, codes, from, to }) => {
|
||||
let total = 0
|
||||
const languages = {}
|
||||
|
||||
if (samples) {
|
||||
samples.forEach(student => {
|
||||
const courses = student.courses.filter(c => codes.includes(c.course_code))
|
||||
const filteredCourses = courses
|
||||
.filter(course => moment(course.date).isBetween(moment(from), moment(to)))
|
||||
.sort((a, b) => (new Date(a.date) <= new Date(b.date) ? -1 : 1))
|
||||
|
||||
if (filteredCourses[0] && filteredCourses[0].language !== null) {
|
||||
if (!languages[filteredCourses[0].language]) {
|
||||
languages[filteredCourses[0].language] = 0
|
||||
}
|
||||
languages[filteredCourses[0].language] += 1
|
||||
total++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getLanguage = lang => (lang in languageAbbreviations ? languageAbbreviations[lang] : lang)
|
||||
|
||||
if (total === 0) return <p>No data available!</p>
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Languages</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Language Distribution
|
||||
<div style={{ fontWeight: 100 }}>(n={total})</div>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{Object.entries(languages).map(language => (
|
||||
<Table.Row key={`language-table-row-${language[0]}`}>
|
||||
<Table.Cell>{getLanguage(language[0])}</Table.Cell>
|
||||
<Table.Cell>{language[1]}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Progress value={language[1]} total={total} progress="percent" precision={0} style={{ margin: 0 }} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import moment from 'moment'
|
||||
import _ from 'lodash'
|
||||
import { Segment, Header, Accordion } from 'semantic-ui-react'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { Segment, Header, Form, Input } from 'semantic-ui-react'
|
||||
import InfoBox from 'components/Info/InfoBox'
|
||||
import { getCoursePopulation } from '../../redux/populations'
|
||||
import { getSingleCourseStats } from '../../redux/singleCourseStats'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
|
||||
import { getFaculties } from '../../redux/faculties'
|
||||
import { useGetSemestersQuery } from '../../redux/semesters'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import PanelView from 'components/common/PanelView'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from 'redux/populations'
|
||||
import { useGetSingleCourseStatsQuery } from 'redux/singleCourseStats'
|
||||
import { useGetStudentListCourseStatisticsQuery } from 'redux/populationCourses'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CoursePopulationGradeDist from './CoursePopulationGradeDist'
|
||||
import CoursePopulationLanguageDist from './CoursePopulationLanguageDist'
|
||||
import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
|
||||
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
|
||||
import { CoursePopulationCreditGainTable } from './CoursePopulationCreditGainTable'
|
||||
import { CustomPopulationProgrammeDist } from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import { getStudentToTargetCourseDateMap, getUnifyTextIn } from '../../common'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
@@ -43,55 +39,48 @@ const NO_PROGRAMME = {
|
||||
startdate: '',
|
||||
}
|
||||
|
||||
const CoursePopulation = ({
|
||||
getCoursePopulationDispatch,
|
||||
getSingleCourseStatsDispatch,
|
||||
getElementDetails,
|
||||
studentData,
|
||||
pending,
|
||||
history,
|
||||
courseData,
|
||||
getFacultiesDispatch,
|
||||
unifyCourses,
|
||||
}) => {
|
||||
const CoursePopulation = ({ history }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [codes, setCodes] = useState([])
|
||||
const [headerYears, setYears] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState(null)
|
||||
const [dateTo, setDateTo] = useState(null)
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
const gradeDistRef = useRef()
|
||||
const languageDistRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const creditGainRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [gradeDistRef, programmeRef, creditGainRef, studentRef]
|
||||
useTitle('Course population')
|
||||
|
||||
const { onProgress, progress } = useProgress(pending && !studentData.students)
|
||||
const studentToTargetCourseDateMap = useMemo(
|
||||
() => getStudentToTargetCourseDateMap(studentData.students ? studentData.students : [], codes),
|
||||
[studentData.students, codes]
|
||||
const { coursecodes, from, to, separate, unifyCourses, years, years2, combineSubstitutions } = queryParamsFromUrl(
|
||||
history.location
|
||||
)
|
||||
|
||||
const { data: courseStatistics } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: studentData.students ? studentData.students.map(student => student.studentNumber) : [],
|
||||
const { data: populationStatistics, isFetching } = useGetPopulationStatisticsByCourseQuery({
|
||||
coursecodes,
|
||||
from,
|
||||
to,
|
||||
separate,
|
||||
unifyCourses,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const parsedCourseCodes = JSON.parse(coursecodes)
|
||||
setCodes(parsedCourseCodes)
|
||||
}, [coursecodes])
|
||||
|
||||
const { progress } = useProgress(isFetching)
|
||||
const studentToTargetCourseDateMap = useMemo(
|
||||
() => getStudentToTargetCourseDateMap(populationStatistics?.students ?? [], codes),
|
||||
[populationStatistics?.students, codes]
|
||||
)
|
||||
|
||||
const { data: courseStatistics } = useGetStudentListCourseStatisticsQuery(
|
||||
{ studentNumbers: populationStatistics ? populationStatistics.students.map(student => student.studentNumber) : [] },
|
||||
{ skip: !populationStatistics }
|
||||
)
|
||||
|
||||
const { data: semesters = {} } = useGetSemestersQuery()
|
||||
|
||||
useEffect(() => {
|
||||
getElementDetails()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex && refs[newestIndex]) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
const { data: [courseData = undefined] = [] } = useGetSingleCourseStatsQuery(
|
||||
{ courseCodes: codes, separate, combineSubstitutions },
|
||||
{ skip: codes.length === 0 }
|
||||
)
|
||||
|
||||
const getFromToDates = (from, to, separate) => {
|
||||
if (!semesters.years || !semesters.semesters) return {}
|
||||
const targetProp = separate ? 'semestercode' : 'yearcode'
|
||||
const data = separate ? semesters.semesters : semesters.years
|
||||
const dataValues = Object.values(data)
|
||||
@@ -103,40 +92,15 @@ const CoursePopulation = ({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (semesters.years && semesters.semesters) {
|
||||
const { coursecodes, from, to, years, years2, separate, unifyCourses } = queryParamsFromUrl(history.location)
|
||||
const parsedCourseCodes = JSON.parse(coursecodes)
|
||||
getCoursePopulationDispatch({ coursecodes, from, to, onProgress, separate, unifyCourses })
|
||||
getSingleCourseStatsDispatch({
|
||||
fromYear: from,
|
||||
toYear: to,
|
||||
courseCodes: parsedCourseCodes,
|
||||
separate,
|
||||
})
|
||||
setCodes(parsedCourseCodes)
|
||||
if (years) {
|
||||
setYears(years)
|
||||
} else {
|
||||
setYears(years2)
|
||||
}
|
||||
getFromToDates(from, to, separate)
|
||||
getFacultiesDispatch()
|
||||
const { dateFrom, dateTo } = getFromToDates(from, to, separate ? JSON.parse(separate) : false)
|
||||
|
||||
const { dateFrom, dateTo } = getFromToDates(from, to, separate ? JSON.parse(separate) : false)
|
||||
setDateFrom(dateFrom)
|
||||
setDateTo(dateTo)
|
||||
}
|
||||
}, [semesters])
|
||||
|
||||
const avoin = getUnifyTextIn(unifyCourses)
|
||||
const header = courseData ? `${getTextIn(courseData.name)} ${headerYears} ${avoin}` : null
|
||||
const header = courseData
|
||||
? `${getTextIn(courseData[unifyCourses].name)} ${years || years2} ${getUnifyTextIn(unifyCourses)}`
|
||||
: null
|
||||
|
||||
const subHeader = codes.join(', ')
|
||||
|
||||
if (!dateFrom || !dateTo) return null
|
||||
|
||||
if (!studentData.students) {
|
||||
if (!populationStatistics || !semesters) {
|
||||
return (
|
||||
<Segment className="contentSegment">
|
||||
<ProgressBar progress={progress} />
|
||||
@@ -144,166 +108,81 @@ const CoursePopulation = ({
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
const panels = filtered => [
|
||||
const createPanels = filtered => [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Grade distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={gradeDistRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
|
||||
<CoursePopulationGradeDist
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
samples={filtered}
|
||||
codes={codes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Grade distribution',
|
||||
content: (
|
||||
<div>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
|
||||
<CoursePopulationGradeDist
|
||||
singleCourseStats={courseData}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
students={filtered}
|
||||
courseCodes={codes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Language distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={languageDistRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.LanguageDistributionCoursePopulation} />
|
||||
<CoursePopulationLanguageDist from={dateFrom} to={dateTo} samples={studentData.students} codes={codes} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Language distribution',
|
||||
content: (
|
||||
<div>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.LanguageDistributionCoursePopulation} />
|
||||
<CoursePopulationLanguageDist from={dateFrom} to={dateTo} samples={filtered} codes={codes} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={filtered}
|
||||
coursecode={codes}
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Programme distribution',
|
||||
content: (
|
||||
<div>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
students={filtered}
|
||||
coursecode={codes}
|
||||
studentData={populationStatistics}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<CustomPopulationCoursesWrapper filteredStudents={filtered} showFilter />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Courses of population',
|
||||
content: <CustomPopulationCoursesWrapper courseStatistics={courseStatistics} filteredStudents={filtered} />,
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit gains
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(4),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
selectedStudents={filtered.map(s => s.studentNumber)}
|
||||
samples={filtered}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Credit gains',
|
||||
content: (
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
students={filtered}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
populationStatistics={populationStatistics}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 5,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({filtered.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(5),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="coursePopulation"
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={filtered}
|
||||
coursecode={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: `Students (${filtered.length})`,
|
||||
content: (
|
||||
<PopulationStudents
|
||||
variant="coursePopulation"
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={filtered}
|
||||
coursecode={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const courses = JSON.parse(queryParamsFromUrl(history.location).coursecodes)
|
||||
|
||||
const studyRightPredicate = (student, sre) => {
|
||||
const date = _.chain(student)
|
||||
.get('courses')
|
||||
.filter(c => courses.includes(c.course_code))
|
||||
.filter(c => codes.includes(c.course_code))
|
||||
.map('date')
|
||||
.max()
|
||||
.value()
|
||||
@@ -332,7 +211,7 @@ const CoursePopulation = ({
|
||||
],
|
||||
}),
|
||||
gradeFilter({
|
||||
courseCodes: courses,
|
||||
courseCodes: codes,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
}),
|
||||
@@ -343,7 +222,7 @@ const CoursePopulation = ({
|
||||
initialOptions={{
|
||||
[programmeFilter.key]: { mode: 'attainment', selectedProgrammes: [] },
|
||||
}}
|
||||
students={studentData.students ?? []}
|
||||
students={populationStatistics.students ?? []}
|
||||
>
|
||||
{filtered => (
|
||||
<div className="segmentContainer">
|
||||
@@ -354,7 +233,7 @@ const CoursePopulation = ({
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
{subHeader}
|
||||
</Header>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels(filtered)} />
|
||||
<PanelView panels={createPanels(filtered)} viewTitle="coursepopulation" />
|
||||
</Segment>
|
||||
</div>
|
||||
)}
|
||||
@@ -362,36 +241,43 @@ const CoursePopulation = ({
|
||||
)
|
||||
}
|
||||
|
||||
const CustomPopulationCoursesWrapper = props => {
|
||||
const CustomPopulationCoursesWrapper = ({ filteredStudents }) => {
|
||||
const { data: courseStatistics, isLoading } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: props.filteredStudents.map(student => student.studentNumber),
|
||||
studentNumbers: filteredStudents.map(student => student.studentNumber),
|
||||
})
|
||||
|
||||
const [studentAmountLimit, setStudentAmountLimit] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setStudentAmountLimit(Math.round(filteredStudents.length ? filteredStudents.length * 0.3 : 0))
|
||||
}, [filteredStudents.length])
|
||||
|
||||
const onStudentAmountLimitChange = value => {
|
||||
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
|
||||
}
|
||||
|
||||
if (isLoading) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
|
||||
<CustomPopulationCourses {...props} courses={courseStatistics} />
|
||||
<Form style={{ padding: '4px 4px 4px 8px' }}>
|
||||
<Form.Field inline>
|
||||
<label>Limit to courses where student number is at least</label>
|
||||
<Input
|
||||
value={studentAmountLimit}
|
||||
onChange={e => onStudentAmountLimitChange(e.target.value)}
|
||||
style={{ width: '70px' }}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<PopulationCourseStatsFlat
|
||||
filteredStudents={filteredStudents}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
courses={courseStatistics}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ singleCourseStats, populations, courseSearch }) => {
|
||||
return {
|
||||
studentData: populations.data,
|
||||
pending: populations.pending,
|
||||
courseData: singleCourseStats.stats?.[courseSearch.openOrReqular],
|
||||
unifyCourses: courseSearch.openOrReqular,
|
||||
elementDetails: populations?.data?.elementdetails?.data,
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
connect(mapStateToProps, {
|
||||
getCoursePopulationDispatch: getCoursePopulation,
|
||||
getSingleCourseStatsDispatch: getSingleCourseStats,
|
||||
getFacultiesDispatch: getFaculties,
|
||||
getElementDetails,
|
||||
})(CoursePopulation)
|
||||
)
|
||||
export default CoursePopulation
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, bool } from 'prop-types'
|
||||
import { Form } from 'semantic-ui-react'
|
||||
import { clearCourses, findCoursesV2 } from '../../redux/coursesearch'
|
||||
import AutoSubmitSearchInput from '../AutoSubmitSearchInput'
|
||||
|
||||
const CourseSearchForm = props => {
|
||||
const [courseName, setCourseName] = useState('')
|
||||
const [courseCode, setCourseCode] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
props.clearCourses()
|
||||
}, [])
|
||||
|
||||
const fetchCourses = () => {
|
||||
const validateParam = (param, minLength) => param && param.length >= minLength
|
||||
const isValidName = validateParam(courseName, 5)
|
||||
const isValidCode = validateParam(courseCode, 2)
|
||||
if (isValidName || isValidCode) {
|
||||
return props.findCoursesV2({ name: courseName, code: courseCode })
|
||||
}
|
||||
if (courseName.length === 0 && courseCode.length === 0) {
|
||||
props.clearCourses()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const { pending } = props
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Form.Group widths="equal">
|
||||
<Form.Field>
|
||||
<label>Name:</label>
|
||||
<AutoSubmitSearchInput
|
||||
doSearch={fetchCourses}
|
||||
placeholder="Search by entering a course name"
|
||||
value={courseName}
|
||||
onChange={cn => setCourseName(cn)}
|
||||
loading={pending}
|
||||
minSearchLength={0}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Code:</label>
|
||||
<AutoSubmitSearchInput
|
||||
doSearch={fetchCourses}
|
||||
placeholder="Search by entering a course code"
|
||||
value={courseCode}
|
||||
onChange={cc => setCourseCode(cc)}
|
||||
loading={pending}
|
||||
minSearchLength={0}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
CourseSearchForm.propTypes = {
|
||||
findCoursesV2: func.isRequired,
|
||||
clearCourses: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
pending: state.courseSearch.pending,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
findCoursesV2,
|
||||
clearCourses,
|
||||
})(CourseSearchForm)
|
||||
@@ -27,7 +27,7 @@ const CourseTable = ({ courses, onSelectCourse, hidden, title, emptyListText, ma
|
||||
<div>{getTextIn(course.name)}</div>
|
||||
<div>{getActiveYears(course)}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={!course.alternatives ? course.code : course.alternatives.map(a => a.code).join(', ')} />
|
||||
<Table.Cell content={[course.code, ...course.substitutions].join(', ')} />
|
||||
{controlIcon ? <Table.Cell icon={controlIcon} /> : null}
|
||||
</Table.Row>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Segment, Header, Form } from 'semantic-ui-react'
|
||||
import { Segment, Header, Form, Radio, Popup } from 'semantic-ui-react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
@@ -8,7 +8,7 @@ import { func, shape } from 'prop-types'
|
||||
import { clearCourses, findCoursesV2 } from '../../../redux/coursesearch'
|
||||
import { getCourseStats, clearCourseStats } from '../../../redux/coursestats'
|
||||
import { getCourseSearchResults } from '../../../selectors/courses'
|
||||
import { useSearchHistory } from '../../../common/hooks'
|
||||
import { useSearchHistory, useToggle } from '../../../common/hooks'
|
||||
import { validateInputLength } from '../../../common'
|
||||
import AutoSubmitSearchInput from '../../AutoSubmitSearchInput'
|
||||
import CourseTable from '../CourseTable'
|
||||
@@ -29,29 +29,25 @@ const searchBoxStyle = {
|
||||
boxShadow: '0 2px 4px 0 rgb(34 36 38 / 12%), 0 2px 10px 0 rgb(34 36 38 / 15%)',
|
||||
}
|
||||
|
||||
const SearchForm = props => {
|
||||
const SearchForm = ({ location, history, onProgress }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
const isLoading = useSelector(state => state.courseStats.pending)
|
||||
const matchingCourses = useSelector(getCourseSearchResults)
|
||||
const [state, setState] = useState({
|
||||
...INITIAL,
|
||||
})
|
||||
const [combineSubstitutions, toggleCombineSubstitutions] = useToggle(true)
|
||||
const matchingCourses = useSelector(state => getCourseSearchResults(state, combineSubstitutions))
|
||||
const [state, setState] = useState({ ...INITIAL })
|
||||
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('courseSearch', 6)
|
||||
|
||||
const { courseName, courseCode, selectedCourses, separate } = state
|
||||
|
||||
// const { coursesLoading, isLoading, matchingCourses /* unifyOpenUniCourses */ } = props
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { location } = props
|
||||
const { courseCodes, separate, unifyOpenUniCourses, ...rest } = qs.parse(location.search)
|
||||
const query = {
|
||||
...INITIAL,
|
||||
...rest,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate: JSON.parse(separate),
|
||||
// unifyOpenUniCourses: JSON.parse(unifyOpenUniCourses || false),
|
||||
combineSubstitutions: JSON.parse(combineSubstitutions),
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -59,11 +55,10 @@ const SearchForm = props => {
|
||||
const fetchStatisticsFromUrlParams = () => {
|
||||
const query = parseQueryFromUrl()
|
||||
setState({ ...state, ...query })
|
||||
dispatch(getCourseStats(query, props.onProgress))
|
||||
dispatch(getCourseStats(query, onProgress))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const { location } = props
|
||||
if (!location.search) {
|
||||
dispatch(clearCourses())
|
||||
dispatch(clearCourseStats())
|
||||
@@ -71,11 +66,10 @@ const SearchForm = props => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const { location } = props
|
||||
if (location.search) {
|
||||
fetchStatisticsFromUrlParams()
|
||||
}
|
||||
}, [props.location.search])
|
||||
}, [location.search])
|
||||
|
||||
const onSelectCourse = course => {
|
||||
course.selected = !course.selected
|
||||
@@ -99,9 +93,12 @@ const SearchForm = props => {
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const { history } = props
|
||||
const { courseCodes, ...rest } = query
|
||||
const queryObject = { ...rest, courseCodes: JSON.stringify(courseCodes) }
|
||||
const queryObject = {
|
||||
...rest,
|
||||
courseCodes: JSON.stringify(courseCodes),
|
||||
combineSubstitutions: JSON.stringify(combineSubstitutions),
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push({ search: searchString })
|
||||
}
|
||||
@@ -115,6 +112,7 @@ const SearchForm = props => {
|
||||
const params = {
|
||||
courseCodes: codes,
|
||||
separate,
|
||||
combineSubstitutions,
|
||||
}
|
||||
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name)} ${code}`)
|
||||
addItemToSearchHistory({
|
||||
@@ -129,7 +127,7 @@ const SearchForm = props => {
|
||||
const isValidCode = validateInputLength(courseCode, 2)
|
||||
|
||||
if (isValidName || isValidCode) {
|
||||
return dispatch(findCoursesV2({ name: courseName, code: courseCode }))
|
||||
return dispatch(findCoursesV2({ name: courseName, code: courseCode, combineSubstitutions }))
|
||||
}
|
||||
if (courseName.length < 5 && courseCode.length < 2) {
|
||||
dispatch(clearCourses())
|
||||
@@ -137,6 +135,13 @@ const SearchForm = props => {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await fetchCourses()
|
||||
}
|
||||
fetchData()
|
||||
}, [combineSubstitutions])
|
||||
|
||||
const courses = matchingCourses.filter(c => !selectedCourses[c.code])
|
||||
|
||||
const disabled = isLoading || Object.keys(selectedCourses).length === 0
|
||||
@@ -176,7 +181,7 @@ const SearchForm = props => {
|
||||
placeholder="Search by entering a course name"
|
||||
value={courseName}
|
||||
onChange={cn => setState({ ...state, courseName: cn })}
|
||||
loading={props.coursesLoading}
|
||||
loading={isLoading}
|
||||
minSearchLength={0}
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -187,11 +192,32 @@ const SearchForm = props => {
|
||||
placeholder="Search by a course code"
|
||||
value={courseCode}
|
||||
onChange={cc => setState({ ...state, courseCode: cc })}
|
||||
loading={props.coursesLoading}
|
||||
loading={isLoading}
|
||||
minSearchLength={0}
|
||||
data-cy="course-code-input"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Popup
|
||||
on="hover"
|
||||
content={getTextIn({
|
||||
fi: 'Jos "Combine substitutions" on valittuna (oletuksena), niin kurssi ja leikkaavat kurssit yhdistetään tilastoissa.',
|
||||
en: 'If "Combine substitutions" is on (default behavior), then course and its substitutions are combined in the statistics.',
|
||||
})}
|
||||
size="tiny"
|
||||
position="top center"
|
||||
trigger={
|
||||
<Radio
|
||||
toggle
|
||||
label="Combine substitutions"
|
||||
checked={combineSubstitutions}
|
||||
onChange={toggleCombineSubstitutions}
|
||||
style={{ margin: '32px 0 0 5px', height: '100%' }}
|
||||
data-cy="combine-substitutions-toggle"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
<div style={!noSelectedCourses ? searchBoxStyle : null}>
|
||||
<CourseTable
|
||||
|
||||
@@ -41,6 +41,7 @@ const SingleCourseStats = ({
|
||||
maxYearsToCreatePopulationFrom,
|
||||
getMaxYearsToCreatePopulationFrom,
|
||||
userHasAccessToAllStats,
|
||||
unifyCourses,
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [primary, setPrimary] = useState([ALL.value])
|
||||
@@ -396,7 +397,7 @@ const SingleCourseStats = ({
|
||||
years.find(s => s.value === to).text.split('-')[1]
|
||||
}`
|
||||
const { alternatives } = stats
|
||||
const queryObject = { from, to, coursecodes: JSON.stringify(alternatives), years2, separate: false }
|
||||
const queryObject = { from, to, coursecodes: JSON.stringify(alternatives), years2, separate: false, unifyCourses }
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push(`/coursepopulation?${searchString}`)
|
||||
}
|
||||
@@ -534,6 +535,7 @@ const mapStateToProps = state => {
|
||||
return {
|
||||
programmes: selectors.getAllStudyProgrammes(state),
|
||||
maxYearsToCreatePopulationFrom: selectors.getMaxYearsToCreatePopulationFrom(state) || 0,
|
||||
unifyCourses: state.courseSearch.openOrReqular,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
|
||||
const CustomPopulationCourses = ({ courses, pending, filteredStudents, showFilter }) => {
|
||||
return (
|
||||
<PopulationCourseStatsFlat
|
||||
courses={courses}
|
||||
pending={pending}
|
||||
filteredStudents={filteredStudents}
|
||||
showFilter={showFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomPopulationCourses
|
||||
+47
-74
@@ -1,7 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Progress } from 'semantic-ui-react'
|
||||
import { arrayOf, string, shape } from 'prop-types'
|
||||
import SearchResultTable from '../../SearchResultTable'
|
||||
import { getNewestProgramme } from '../../../common'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
@@ -9,71 +7,65 @@ import useFilters from '../../FilterView/useFilters'
|
||||
import { isProgrammeSelected, toggleProgrammeSelection } from '../../FilterView/filters/programmes'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
|
||||
const CustomPopulationProgrammeDist = ({
|
||||
samples,
|
||||
selectedStudents,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics,
|
||||
coursecode,
|
||||
}) => {
|
||||
export const CustomPopulationProgrammeDist = ({ students, studentToTargetCourseDateMap, coursecode, studentData }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const [tableRows, setRows] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(populationStatistics).length > 0) {
|
||||
const allProgrammes = {}
|
||||
const filteredSamples = samples.filter(student => selectedStudents.includes(student.studentNumber))
|
||||
if (Object.keys(studentData ?? {}).length === 0) return
|
||||
const allProgrammes = {}
|
||||
|
||||
filteredSamples.forEach(student => {
|
||||
let programme = getNewestProgramme(
|
||||
students.forEach(student => {
|
||||
let programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
studentData.elementdetails?.data
|
||||
)
|
||||
if (programme && programme.code === '00000' && coursecode) {
|
||||
const filteredEnrollments = (student.enrollments || [])
|
||||
// eslint-disable-next-line camelcase
|
||||
.filter(({ course_code }) => coursecode.includes(course_code))
|
||||
.sort((a, b) => new Date(b.enrollment_date_time) - new Date(a.enrollment_date_time))
|
||||
programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
studentToTargetCourseDateMap,
|
||||
populationStatistics.elementdetails.data
|
||||
{ [student.studentNumber]: (filteredEnrollments[0] || {}).enrollment_date_time },
|
||||
studentData.elementdetails?.data
|
||||
)
|
||||
if (programme && programme.code === '00000' && coursecode) {
|
||||
const filteredEnrollments = (student.enrollments || [])
|
||||
// eslint-disable-next-line camelcase
|
||||
.filter(({ course_code }) => coursecode.includes(course_code))
|
||||
.sort((a, b) => new Date(b.enrollment_date_time) - new Date(a.enrollment_date_time))
|
||||
programme = getNewestProgramme(
|
||||
student.studyrights,
|
||||
student.studentNumber,
|
||||
{ [student.studentNumber]: (filteredEnrollments[0] || {}).enrollment_date_time },
|
||||
populationStatistics.elementdetails.data
|
||||
)
|
||||
}
|
||||
if (programme) {
|
||||
if (allProgrammes[programme.code]) {
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
} else {
|
||||
allProgrammes[programme.code] = { programme, students: [] }
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
}
|
||||
}
|
||||
if (programme) {
|
||||
if (allProgrammes[programme.code]) {
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
} else {
|
||||
if (!allProgrammes['00000']) {
|
||||
allProgrammes['00000'] = { programme: { name: { en: 'No programme' } }, students: [] }
|
||||
}
|
||||
allProgrammes['00000'].students.push({ studentnumber: student.studentnumber })
|
||||
allProgrammes[programme.code] = { programme, students: [] }
|
||||
allProgrammes[programme.code].students.push({ studentnumber: student.studentNumber })
|
||||
}
|
||||
})
|
||||
const rows = Object.entries(allProgrammes).map(([code, { programme, students }]) => [
|
||||
getTextIn(programme.name),
|
||||
code,
|
||||
students.length,
|
||||
<Progress
|
||||
style={{ margin: '0px' }}
|
||||
percent={Math.round((students.length / selectedStudents.length) * 100)}
|
||||
progress
|
||||
/>,
|
||||
])
|
||||
const sortedRows = rows.sort((a, b) => b[2] - a[2])
|
||||
setRows(sortedRows)
|
||||
}
|
||||
}, [selectedStudents])
|
||||
} else {
|
||||
if (!allProgrammes['00000']) {
|
||||
allProgrammes['00000'] = { programme: { name: { en: 'No programme' } }, students: [] }
|
||||
}
|
||||
allProgrammes['00000'].students.push({ studentnumber: student.studentnumber })
|
||||
}
|
||||
})
|
||||
const rows = Object.entries(allProgrammes).map(([code, { programme, students: programmeStudents }]) => [
|
||||
getTextIn(programme.name),
|
||||
code,
|
||||
programmeStudents.length,
|
||||
<Progress
|
||||
value={programmeStudents.length}
|
||||
total={students.length}
|
||||
progress="percent"
|
||||
precision={0}
|
||||
style={{ margin: 0 }}
|
||||
/>,
|
||||
])
|
||||
const sortedRows = rows.sort((a, b) => b[2] - a[2])
|
||||
setRows(sortedRows)
|
||||
}, [students])
|
||||
|
||||
const headers = ['Programme', 'Code', `Students (all=${selectedStudents.length})`, 'Percentage of population']
|
||||
const headers = ['Programme', 'Code', `Students (all=${students.length})`, 'Percentage of population']
|
||||
|
||||
return (
|
||||
<SearchResultTable
|
||||
@@ -97,22 +89,3 @@ const ProgrammeFilterToggleCell = ({ programme }) => {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationProgrammeDist.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
}
|
||||
|
||||
CustomPopulationProgrammeDist.propTypes = {
|
||||
samples: arrayOf(shape({})).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
studentToTargetCourseDateMap: shape({}),
|
||||
populationStatistics: shape({}).isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => {
|
||||
return {
|
||||
populationStatistics: populations.data,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(CustomPopulationProgrammeDist)
|
||||
|
||||
@@ -1,46 +1,30 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import React, { useState } from 'react'
|
||||
import { Button, Modal, Form, TextArea } from 'semantic-ui-react'
|
||||
import { shape, func, arrayOf, bool } from 'prop-types'
|
||||
import { textAndDescriptionSearch } from 'common'
|
||||
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import { getCustomPopulation } from '../../redux/populations'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import {
|
||||
getCustomPopulationSearches,
|
||||
saveCustomPopulationSearch,
|
||||
updateCustomPopulationSearch,
|
||||
selectCustomPopulationSearch,
|
||||
deleteCustomPopulationSearch,
|
||||
useGetCustomPopulationSearchesQuery,
|
||||
useCreateCustomPopulationSearchMutation,
|
||||
useUpdateCustomPopulationSearchMutation,
|
||||
useDeleteCustomPopulationSearchMutation,
|
||||
} from '../../redux/customPopulationSearch'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import SearchHistory from '../SearchHistory'
|
||||
|
||||
const CustomPopulationSearch = ({
|
||||
getCustomPopulationDispatch,
|
||||
getCustomPopulationCoursesByStudentnumbers,
|
||||
getCustomPopulationSearchesDispatch,
|
||||
saveCustomPopulationSearchDispatch,
|
||||
updateCustomPopulationSearchDispatch,
|
||||
selectCustomPopulationSearchDispatch,
|
||||
deleteCustomPopulationSearchDispatch,
|
||||
loading,
|
||||
customPopulationSearches,
|
||||
customPopulationSearchSaving,
|
||||
}) => {
|
||||
const CustomPopulationSearch = ({ setCustomPopulationState }) => {
|
||||
const [modal, setModal] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [programme, setProgramme] = useState('')
|
||||
const [selectedSearchId, setSelectedSearchId] = useState('')
|
||||
const [associatedProgramme, setAssociatedProgramme] = useState('')
|
||||
const [selectedSearch, setSelectedSearch] = useState(null)
|
||||
const programmes = useFilteredAndFormattedElementDetails()
|
||||
const { onProgress } = useProgress(loading)
|
||||
|
||||
useTitle('Custom population')
|
||||
|
||||
useEffect(() => {
|
||||
getCustomPopulationSearchesDispatch()
|
||||
}, [])
|
||||
const { data: searches, isFetching } = useGetCustomPopulationSearchesQuery()
|
||||
const [createSearch] = useCreateCustomPopulationSearchMutation()
|
||||
const [updateSearch] = useUpdateCustomPopulationSearchMutation()
|
||||
const [deleteSearch] = useDeleteCustomPopulationSearchMutation()
|
||||
|
||||
const handleNameChange = e => {
|
||||
setName(e.target.value)
|
||||
@@ -49,7 +33,7 @@ const CustomPopulationSearch = ({
|
||||
const clearForm = () => {
|
||||
setName('')
|
||||
setInput('')
|
||||
setSelectedSearchId('')
|
||||
setSelectedSearch(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -65,45 +49,43 @@ const CustomPopulationSearch = ({
|
||||
.map(s => (s.length === 8 ? `0${s}` : s))
|
||||
|
||||
const onSave = () => {
|
||||
const studentnumberlist = parseInput(input)
|
||||
if (selectedSearchId) {
|
||||
updateCustomPopulationSearchDispatch({ id: selectedSearchId, studentnumberlist })
|
||||
const students = parseInput(input)
|
||||
if (selectedSearch) {
|
||||
updateSearch({ id: selectedSearch.id, students })
|
||||
} else {
|
||||
saveCustomPopulationSearchDispatch({ name, studentnumberlist })
|
||||
createSearch({ name, students })
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (selectedSearchId) {
|
||||
deleteCustomPopulationSearchDispatch({ id: selectedSearchId })
|
||||
if (selectedSearch) {
|
||||
deleteSearch({ id: selectedSearch.id })
|
||||
clearForm()
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectSearch = selected => {
|
||||
if (!selected) {
|
||||
const onSelectSearch = selectedId => {
|
||||
if (!selectedId) {
|
||||
clearForm()
|
||||
return
|
||||
}
|
||||
|
||||
const { id: selectedSearchId } = selected
|
||||
const selectedSearch = customPopulationSearches.find(({ id }) => id === selectedSearchId)
|
||||
const selectedSearch = searches.find(({ id }) => id === selectedId)
|
||||
if (selectedSearch) {
|
||||
setInput(selectedSearch.students.join('\n'))
|
||||
setName(selectedSearch.name)
|
||||
setSelectedSearchId(selectedSearch.id)
|
||||
setSelectedSearch(selectedSearch)
|
||||
}
|
||||
}
|
||||
|
||||
const onClicker = e => {
|
||||
e.preventDefault()
|
||||
const studentnumbers = parseInput(input)
|
||||
getCustomPopulationDispatch({ studentnumberlist: studentnumbers, onProgress, associatedProgramme: programme })
|
||||
getCustomPopulationCoursesByStudentnumbers({ studentnumberlist: studentnumbers })
|
||||
selectCustomPopulationSearchDispatch(selectedSearchId || null)
|
||||
const studentNumbers = parseInput(input)
|
||||
setCustomPopulationState({ selectedSearch, studentNumbers, associatedProgramme })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
if (!searches) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
trigger={
|
||||
@@ -117,13 +99,13 @@ const CustomPopulationSearch = ({
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<h2> Custom population new</h2>
|
||||
<h2>New custom population</h2>
|
||||
<Form.Field>
|
||||
<em> Insert name for this custom population if you wish to save it </em>
|
||||
<Form.Input disabled={!!selectedSearchId} value={name} placeholder="name" onChange={handleNameChange} />
|
||||
<em>Insert name for this custom population if you wish to save it</em>
|
||||
<Form.Input disabled={!!selectedSearch} value={name} placeholder="name" onChange={handleNameChange} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<em> Insert studentnumbers you wish to use for population here </em>
|
||||
<em>Insert student numbers you wish to use for population here</em>
|
||||
<TextArea
|
||||
value={input}
|
||||
placeholder="011111111"
|
||||
@@ -135,8 +117,8 @@ const CustomPopulationSearch = ({
|
||||
name="Associated programme"
|
||||
search={textAndDescriptionSearch}
|
||||
options={programmes}
|
||||
onChange={(_, value) => setProgramme(value?.value)}
|
||||
value={programme}
|
||||
onChange={(_, value) => setAssociatedProgramme(value?.value)}
|
||||
value={associatedProgramme}
|
||||
closeOnChange
|
||||
clearable
|
||||
placeholder="Select associated study programme for the population"
|
||||
@@ -144,26 +126,26 @@ const CustomPopulationSearch = ({
|
||||
</Form>
|
||||
<SearchHistory
|
||||
header="Saved populations"
|
||||
items={customPopulationSearches.map(s => ({
|
||||
items={searches.map(s => ({
|
||||
...s,
|
||||
text: s.name,
|
||||
timestamp: new Date(s.updatedAt),
|
||||
params: { id: s.id },
|
||||
}))}
|
||||
updateItem={() => null}
|
||||
handleSearch={onSelectSearch}
|
||||
handleSearch={({ id }) => onSelectSearch(id)}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
disabled={!name || customPopulationSearchSaving}
|
||||
loading={customPopulationSearchSaving}
|
||||
disabled={!name || isFetching}
|
||||
loading={isFetching}
|
||||
floated="left"
|
||||
icon="save"
|
||||
onClick={onSave}
|
||||
content="Save"
|
||||
/>
|
||||
<Button disabled={!selectedSearchId} negative floated="left" icon="trash" onClick={onDelete} content="Delete" />
|
||||
<Button disabled={!selectedSearch} negative floated="left" icon="trash" onClick={onDelete} content="Delete" />
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button positive onClick={e => onClicker(e)} data-cy="search-button">
|
||||
Search population
|
||||
@@ -173,33 +155,4 @@ const CustomPopulationSearch = ({
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationSearch.propTypes = {
|
||||
getCustomPopulationDispatch: func.isRequired,
|
||||
getCustomPopulationCoursesByStudentnumbers: func.isRequired,
|
||||
loading: bool.isRequired,
|
||||
customPopulationSearches: arrayOf(shape({})).isRequired,
|
||||
customPopulationSearchSaving: bool.isRequired,
|
||||
saveCustomPopulationSearchDispatch: func.isRequired,
|
||||
getCustomPopulationSearchesDispatch: func.isRequired,
|
||||
updateCustomPopulationSearchDispatch: func.isRequired,
|
||||
selectCustomPopulationSearchDispatch: func.isRequired,
|
||||
deleteCustomPopulationSearchDispatch: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations, populationCourses, customPopulationSearch }) => ({
|
||||
loading: populations.pending,
|
||||
custompop: populations.data.students || [],
|
||||
courses: populationCourses.data,
|
||||
customPopulationSearches: customPopulationSearch.customPopulationSearches,
|
||||
customPopulationSearchSaving: customPopulationSearch.saving,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
getCustomPopulationDispatch: getCustomPopulation,
|
||||
getCustomPopulationCoursesByStudentnumbers,
|
||||
saveCustomPopulationSearchDispatch: saveCustomPopulationSearch,
|
||||
getCustomPopulationSearchesDispatch: getCustomPopulationSearches,
|
||||
updateCustomPopulationSearchDispatch: updateCustomPopulationSearch,
|
||||
selectCustomPopulationSearchDispatch: selectCustomPopulationSearch,
|
||||
deleteCustomPopulationSearchDispatch: deleteCustomPopulationSearch,
|
||||
})(CustomPopulationSearch)
|
||||
export default CustomPopulationSearch
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Segment, Header, Accordion, Message, Label } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import semestersApi from 'redux/semesters'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Segment, Header, Message, Label, Form, Input } from 'semantic-ui-react'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
|
||||
|
||||
import RightsNotification from 'components/RightsNotification'
|
||||
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { useGetCustomPopulationQuery } from 'redux/populations'
|
||||
import { useGetStudentListCourseStatisticsQuery } from 'redux/populationCourses'
|
||||
import PanelView from 'components/common/PanelView'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import { getCustomPopulationSearches } from '../../redux/customPopulationSearch'
|
||||
import { useGetStudentListCourseStatisticsQuery } from '../../redux/populationCourses'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from './CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
|
||||
import { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterView from '../FilterView'
|
||||
@@ -36,36 +33,34 @@ import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import CustomPopulationSearch from './CustomPopulationSearch'
|
||||
import UnihowDataExport from './UnihowDataExport'
|
||||
|
||||
const selectCustomPopulationData = createSelector(
|
||||
semestersApi.endpoints.getSemesters.select(),
|
||||
state => state.populations.data,
|
||||
(semesters, populations) => ({
|
||||
allSemesters: semesters?.data?.semesters ?? [],
|
||||
custompop: populations?.students ?? [],
|
||||
studyProgramme: populations?.studyProgramme,
|
||||
discardedStudentNumbers: populations?.discardedStudentNumbers,
|
||||
})
|
||||
)
|
||||
|
||||
const CustomPopulation = () => {
|
||||
const { language } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const {
|
||||
allSemesters,
|
||||
custompop,
|
||||
studyProgramme: associatedProgramme,
|
||||
discardedStudentNumbers,
|
||||
} = useSelector(selectCustomPopulationData)
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: custompop.map(s => s.studentNumber),
|
||||
const [customPopulationState, setCustomPopulationState] = useState({
|
||||
selectedSearch: null,
|
||||
studentNumbers: [],
|
||||
associatedProgramme: '',
|
||||
})
|
||||
|
||||
const { data } = useGetSemestersQuery()
|
||||
const allSemesters = data?.semesters ?? []
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery(
|
||||
{ studentNumbers: customPopulationState.studentNumbers },
|
||||
{ skip: !customPopulationState.studentNumbers.length }
|
||||
)
|
||||
|
||||
const { data: studentData, isFetching } = useGetCustomPopulationQuery(
|
||||
{
|
||||
studentNumbers: customPopulationState.studentNumbers,
|
||||
tags: { studyProgramme: customPopulationState.associatedProgramme },
|
||||
},
|
||||
{ skip: !customPopulationState.studentNumbers.length }
|
||||
)
|
||||
|
||||
useTitle('Custom population')
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getCustomPopulationSearches())
|
||||
}, [])
|
||||
const custompop = studentData?.students ?? []
|
||||
const associatedProgramme = studentData?.studyProgramme
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const filtersList = [
|
||||
@@ -79,7 +74,7 @@ const CustomPopulation = () => {
|
||||
programmeFilter,
|
||||
creditDateFilter,
|
||||
enrollmentStatusFilter({
|
||||
allSemesters: allSemesters ?? [],
|
||||
allSemesters,
|
||||
language,
|
||||
}),
|
||||
]
|
||||
@@ -91,165 +86,98 @@ const CustomPopulation = () => {
|
||||
|
||||
return (
|
||||
<FilterView name="CustomPopulation" filters={filters} students={custompop} displayTray={custompop.length > 0}>
|
||||
{students => (
|
||||
{filteredStudents => (
|
||||
<CustomPopulationContent
|
||||
discardedStudentNumbers={discardedStudentNumbers}
|
||||
students={students}
|
||||
custompop={custompop}
|
||||
filteredStudents={filteredStudents}
|
||||
studentData={studentData}
|
||||
customPopulationState={customPopulationState}
|
||||
setCustomPopulationState={setCustomPopulationState}
|
||||
isFetchingPopulation={isFetching}
|
||||
/>
|
||||
)}
|
||||
</FilterView>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomPopulationContent = ({ students, custompop, discardedStudentNumbers }) => {
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
const CustomPopulationContent = ({
|
||||
filteredStudents,
|
||||
customPopulationState,
|
||||
studentData,
|
||||
setCustomPopulationState,
|
||||
isFetchingPopulation,
|
||||
}) => {
|
||||
const studyProgrammes = useFilteredAndFormattedElementDetails()
|
||||
const creditGainRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const coursesRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
|
||||
const { customPopulationSearches, searchedCustomPopulationSearchId } = useSelector(
|
||||
state => state.customPopulationSearch
|
||||
)
|
||||
const [studentAmountLimit, setStudentAmountLimit] = useState(0)
|
||||
|
||||
const populations = useSelector(state => state.populations)
|
||||
const { customPopulationFlag } = populations
|
||||
|
||||
const { progress } = useProgress(populations.pending)
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery({
|
||||
studentNumbers: students.map(s => s.studentNumber),
|
||||
})
|
||||
const discardedStudentNumbers = studentData?.discardedStudentNumber
|
||||
const allStudents = studentData?.students ?? []
|
||||
const associatedProgramme = studentData?.studyProgramme
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
setStudentAmountLimit(Math.round(filteredStudents.length ? filteredStudents.length * 0.3 : 0))
|
||||
}, [filteredStudents.length])
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
const onStudentAmountLimitChange = value => {
|
||||
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
|
||||
}
|
||||
const createPanels = () => [
|
||||
|
||||
const { data: courseStats } = useGetStudentListCourseStatisticsQuery(
|
||||
{ studentNumbers: filteredStudents.map(s => s.studentNumber) },
|
||||
{ skip: !filteredStudents.map(s => s.studentNumber).length }
|
||||
)
|
||||
|
||||
const { progress } = useProgress(isFetchingPopulation)
|
||||
|
||||
const panels = [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit accumulation (for {students.length} students)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditAccumulationGraphHighCharts students={students} customPopulation />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
content: <CreditAccumulationGraphHighCharts students={filteredStudents} customPopulation />,
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist samples={students} selectedStudents={_.map(students, 'studentNumber')} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Programme distribution',
|
||||
content: (
|
||||
<div>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist students={filteredStudents} studentData={studentData} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={coursesRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
|
||||
<CustomPopulationCourses filteredStudents={students} courses={courseStats} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: 'Courses of population',
|
||||
content: (
|
||||
<>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
|
||||
<Form style={{ padding: '4px 4px 4px 8px' }}>
|
||||
<Form.Field inline>
|
||||
<label>Limit to courses where student number is at least</label>
|
||||
<Input
|
||||
value={studentAmountLimit}
|
||||
onChange={e => onStudentAmountLimitChange(e.target.value)}
|
||||
style={{ width: '70px' }}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<PopulationCourseStatsFlat
|
||||
filteredStudents={filteredStudents}
|
||||
courses={courseStats}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({students.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="customPopulation"
|
||||
filteredStudents={students}
|
||||
dataExport={<UnihowDataExport students={students} />}
|
||||
customPopulationProgramme={populations.data.studyProgramme || ''}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
title: `Students (${filteredStudents.length})`,
|
||||
content: (
|
||||
<PopulationStudents
|
||||
variant="customPopulation"
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={<UnihowDataExport students={filteredStudents} />}
|
||||
customPopulationProgramme={associatedProgramme || ''}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const renderCustomPopulation = () => (
|
||||
<div>
|
||||
{custompop && (
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Custom population
|
||||
{searchedCustomPopulationSearchId
|
||||
? ` "${customPopulationSearches.find(({ id }) => id === searchedCustomPopulationSearchId).name}"`
|
||||
: ''}
|
||||
{populations.data.studyProgramme && (
|
||||
<Label
|
||||
style={{ marginLeft: '2em' }}
|
||||
tag
|
||||
color="blue"
|
||||
content={studyProgrammes.find(p => p.key === populations.data.studyProgramme).text}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
)}
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels()} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message style={{ maxWidth: '800px', fontSize: '16px' }}>
|
||||
@@ -262,15 +190,33 @@ const CustomPopulationContent = ({ students, custompop, discardedStudentNumbers
|
||||
studentnumbers you have access rights to i.e. you have rights to the programme they are in.
|
||||
</p>
|
||||
</Message>
|
||||
{discardedStudentNumbers?.length > 0 && !populations.pending && (
|
||||
{discardedStudentNumbers?.length > 0 && !isFetchingPopulation && (
|
||||
<RightsNotification discardedStudentNumbers={discardedStudentNumbers} />
|
||||
)}
|
||||
{discardedStudentNumbers?.length === 0 && students?.length === 0 && (
|
||||
{discardedStudentNumbers?.length === 0 && filteredStudents?.length === 0 && (
|
||||
<Message>No students found. Please re-check the student number list</Message>
|
||||
)}
|
||||
<CustomPopulationSearch />
|
||||
{custompop.length > 0 && customPopulationFlag ? (
|
||||
<Segment className="contentSegment">{renderCustomPopulation()}</Segment>
|
||||
<CustomPopulationSearch setCustomPopulationState={setCustomPopulationState} />
|
||||
{!isFetchingPopulation && allStudents.length ? (
|
||||
<Segment className="contentSegment">
|
||||
<div>
|
||||
<div style={{ margin: 'auto' }}>
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Custom population
|
||||
{customPopulationState.selectedSearch && ` "${customPopulationState.selectedSearch.name}"`}
|
||||
{associatedProgramme && (
|
||||
<Label
|
||||
style={{ marginLeft: '2em' }}
|
||||
tag
|
||||
color="blue"
|
||||
content={studyProgrammes.find(p => p.key === associatedProgramme).text}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
<PanelView panels={panels} viewTitle="custompopulation" />
|
||||
</div>
|
||||
</Segment>
|
||||
) : (
|
||||
<Segment className="contentSegment">
|
||||
<ProgressBar progress={progress} />
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import React from 'react'
|
||||
import { Message } from 'semantic-ui-react'
|
||||
import { calculateStats } from 'components/FacultyStatistics/FacultyProgrammeOverview'
|
||||
import FacultyProgressTable from '../FacultyStatistics/FacultyProgrammeOverview/FacultyProgressTable'
|
||||
import FacultyBarChart from '../FacultyStatistics/FacultyProgrammeOverview/FacultyBarChart'
|
||||
import sortProgrammeKeys from '../FacultyStatistics/facultyHelpers'
|
||||
|
||||
const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
const bachelorStats = calculateStats(progressStats?.data?.creditCounts?.bachelor, 180)
|
||||
const bachelorMasterStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.bachelorMaster,
|
||||
faculty === 'H90' ? 360 : 300,
|
||||
180,
|
||||
7
|
||||
)
|
||||
const masterStats = calculateStats(progressStats?.data?.creditCounts?.master, 120)
|
||||
const licentiateStats = calculateStats(progressStats?.data?.creditCounts?.licentiate, 360)
|
||||
const doctorStats = calculateStats(progressStats?.data?.creditCounts?.doctor, 40, 0, 5)
|
||||
|
||||
return (
|
||||
<>
|
||||
{getDivider('Bachelor', 'BachelorStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
@@ -14,16 +26,16 @@ const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
cypress="FacultyBachelorsProgress"
|
||||
data={{
|
||||
id: faculty,
|
||||
stats: progressStats?.data.bachelorsGraphStats,
|
||||
stats: bachelorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.bachelorsTableStats}
|
||||
data={bachelorStats.tableStats}
|
||||
programmeStats={progressStats?.data.bachelorsProgStats}
|
||||
titles={progressStats?.data.bachelorTitles}
|
||||
titles={bachelorStats.tableTitles}
|
||||
sortedKeys={sortProgrammeKeys(
|
||||
Object.keys(progressStats?.data.bachelorsProgStats).map(obj => [
|
||||
obj,
|
||||
@@ -50,16 +62,16 @@ const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
cypress="FacultyBachelorMastersProgress"
|
||||
data={{
|
||||
id: faculty,
|
||||
stats: progressStats?.data.bcMsGraphStats,
|
||||
stats: bachelorMasterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.bcMsTableStats}
|
||||
data={bachelorMasterStats.tableStats}
|
||||
programmeStats={progressStats?.data.bcMsProgStats}
|
||||
titles={progressStats?.data.bcMsTitles}
|
||||
titles={bachelorMasterStats.tableTitles}
|
||||
sortedKeys={sortProgrammeKeys(
|
||||
Object.keys(progressStats?.data.bcMsProgStats).map(obj => [
|
||||
obj,
|
||||
@@ -84,16 +96,16 @@ const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
cypress="FacultyMastersProgress"
|
||||
data={{
|
||||
id: faculty,
|
||||
stats: progressStats?.data.mastersGraphStats,
|
||||
stats: masterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.mastersTableStats}
|
||||
data={masterStats.tableStats}
|
||||
programmeStats={progressStats?.data.mastersProgStats}
|
||||
titles={progressStats?.data.mastersTitles}
|
||||
titles={masterStats.tableTitles}
|
||||
sortedKeys={sortProgrammeKeys(
|
||||
Object.keys(progressStats?.data.mastersProgStats).map(obj => [
|
||||
obj,
|
||||
@@ -111,6 +123,42 @@ const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{faculty === 'H30' && (
|
||||
<>
|
||||
{getDivider('Licentiate', 'LicentiateStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyLicentiateProgress"
|
||||
data={{
|
||||
id: faculty,
|
||||
stats: licentiateStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={licentiateStats.tableStats}
|
||||
programmeStats={progressStats?.data.licentiateProgStats}
|
||||
titles={licentiateStats.tableTitles}
|
||||
sortedKeys={sortProgrammeKeys(
|
||||
Object.keys(progressStats?.data.licentiateProgStats).map(obj => [
|
||||
obj,
|
||||
progressStats?.data?.programmeNames[obj].code,
|
||||
]),
|
||||
faculty
|
||||
).map(listObj => listObj[0])}
|
||||
progressYearsVisible={Array(progressStats?.data.years.slice(1).length).fill(false)}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
cypress="FacultyLicentiateProgressTable"
|
||||
progressTitles={progressStats?.data.yearlyLicentiateTitles}
|
||||
needsExtra="EXTRA HEIGHT"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{getDivider('Doctor', 'DoctoralStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
@@ -118,16 +166,16 @@ const FacultyProgress = ({ faculty, progressStats, getDivider }) => {
|
||||
cypress="FacultyDoctoralProgress"
|
||||
data={{
|
||||
id: faculty,
|
||||
stats: progressStats?.data.doctoralGraphStats,
|
||||
stats: doctorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.doctoralTableStats}
|
||||
data={doctorStats.tableStats}
|
||||
programmeStats={progressStats?.data.doctoralProgStats}
|
||||
titles={progressStats?.data.doctoralTitles}
|
||||
titles={doctorStats.tableTitles}
|
||||
sortedKeys={sortProgrammeKeys(
|
||||
Object.keys(progressStats?.data.doctoralProgStats).map(obj => [
|
||||
obj,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Header, Loader, Message, Segment } from 'semantic-ui-react'
|
||||
import { useGetEvaluationStatsQuery } from 'redux/studyProgramme'
|
||||
import { calculateStats } from 'components/FacultyStatistics/FacultyProgrammeOverview'
|
||||
import { getTargetCreditsForProgramme } from 'common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import Toggle from '../StudyProgramme/Toggle'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
@@ -30,10 +32,19 @@ const ProgrammeView = ({ studyprogramme }) => {
|
||||
graduated: grad,
|
||||
})
|
||||
|
||||
const {
|
||||
tableStats,
|
||||
chartStats,
|
||||
tableTitles: creditTableTitles,
|
||||
} = calculateStats(statistics?.data?.creditCounts, getTargetCreditsForProgramme(studyprogramme))
|
||||
const creditTableStats = {}
|
||||
creditTableStats[studyprogramme] = tableStats
|
||||
const creditChartData = { creditGraphStats: {}, years: statistics?.data?.years }
|
||||
creditChartData.creditGraphStats[studyprogramme] = chartStats
|
||||
|
||||
const programmeName = statistics?.data?.programmeName && getTextIn(statistics?.data?.programmeName)
|
||||
|
||||
const graduationData = statistics?.data?.graduations
|
||||
const progressData = statistics?.data?.progress
|
||||
|
||||
const doCombo = graduationData?.doCombo
|
||||
const timesData = graduationData?.graduationTimes
|
||||
@@ -94,8 +105,9 @@ const ProgrammeView = ({ studyprogramme }) => {
|
||||
<Segment className="contentSegment">
|
||||
<Message info>
|
||||
<Message.Header>This view is an abridged version of Oodikone's Studyprogramme Overview</Message.Header>
|
||||
<p>
|
||||
In these statistics, all special studyrights have been excluded, eg. exchange students and non-degree
|
||||
<p style={{ marginTop: '12px' }}>
|
||||
In these statistics, the following students are excluded: Students whose studyrights{' '}
|
||||
<b>have been transferred away from or into the program</b> as well as exchange students and non-degree
|
||||
students.
|
||||
</p>
|
||||
<p>
|
||||
@@ -128,39 +140,39 @@ const ProgrammeView = ({ studyprogramme }) => {
|
||||
setValue={setGraduated}
|
||||
/>
|
||||
<div className="section-container">
|
||||
<BarChart cypress="StudytrackProgress" data={progressData?.graphData} track={studyprogramme} />
|
||||
<BarChart cypress="StudytrackProgress" data={creditChartData} track={studyprogramme} />
|
||||
<BasicDataTable
|
||||
cypress="StudytrackProgress"
|
||||
data={progressData?.creditTableStats}
|
||||
data={creditTableStats}
|
||||
track={studyprogramme}
|
||||
titles={progressData?.creditTableTitles}
|
||||
titles={creditTableTitles}
|
||||
/>
|
||||
</div>
|
||||
{graduationData?.programmesBeforeOrAfterGraphStats && (
|
||||
{getDivider('Graduation times', 'AverageGraduationTimes')}
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="GraduationTimeToggle"
|
||||
firstLabel="Breakdown"
|
||||
secondLabel="Median time"
|
||||
value={showMedian}
|
||||
setValue={setShowMedian}
|
||||
/>
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="YearToggle"
|
||||
toolTips={toolTips.YearToggle}
|
||||
firstLabel="Calendar year"
|
||||
secondLabel="Academic year"
|
||||
value={academicYear}
|
||||
setValue={setAcademicYear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`section-container${doCombo ? '' : '-centered'}`}>
|
||||
{showMedian ? displayMedian() : displayBreakdown()}
|
||||
</div>
|
||||
{graduationData?.programmesBeforeOrAfterGraphStats?.length !== 0 && (
|
||||
<>
|
||||
{getDivider('Graduation times', 'AverageGraduationTimes')}
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="GraduationTimeToggle"
|
||||
firstLabel="Breakdown"
|
||||
secondLabel="Median time"
|
||||
value={showMedian}
|
||||
setValue={setShowMedian}
|
||||
/>
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="YearToggle"
|
||||
toolTips={toolTips.YearToggle}
|
||||
firstLabel="Calendar year"
|
||||
secondLabel="Academic year"
|
||||
value={academicYear}
|
||||
setValue={setAcademicYear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`section-container${doCombo ? '' : '-centered'}`}>
|
||||
{showMedian ? displayMedian() : displayBreakdown()}
|
||||
</div>
|
||||
{getDivider(
|
||||
studyprogramme.includes('KH')
|
||||
? 'Primary master programme studies after this programme'
|
||||
|
||||
+2
-2
@@ -8,10 +8,10 @@ const getColors = len => {
|
||||
}
|
||||
|
||||
const FacultyBarChart = ({ cypress, data }) => {
|
||||
if (!data) return null
|
||||
if (!data.stats) return null
|
||||
|
||||
const colors = getColors(Object.keys(data.stats).length)
|
||||
const dataWithColors = Object.values(data?.stats).map((series, index) => ({ ...series, color: colors[index] }))
|
||||
const dataWithColors = Object.values(data.stats).map((series, index) => ({ ...series, color: colors[index] }))
|
||||
const getFileName = () => {
|
||||
return `oodikone_progress_of_students_in_${data?.id}_by_study_start_year`
|
||||
}
|
||||
|
||||
+129
-13
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { Button, Divider, Loader, Message, Popup } from 'semantic-ui-react'
|
||||
import { useGetFacultyProgressStatsQuery, useGetFacultyStudentStatsQuery } from 'redux/facultyStats'
|
||||
import useLanguage from 'components/LanguagePicker/useLanguage'
|
||||
import { getCreditCategories } from 'common'
|
||||
import FacultyProgressTable from './FacultyProgressTable'
|
||||
import FacultyBarChart from './FacultyBarChart'
|
||||
import Toggle from '../../StudyProgramme/Toggle'
|
||||
@@ -22,9 +23,70 @@ const getDivider = (title, toolTipText, content, cypress) => (
|
||||
{content === 'no-infobox' ? null : <InfoBox content={content} cypress={cypress} />}
|
||||
</>
|
||||
)
|
||||
|
||||
const getKey = (programmeKeys, idx) => {
|
||||
return programmeKeys[idx][1].startsWith('T') ? 'T' : programmeKeys[idx][1].slice(0, 2)
|
||||
}
|
||||
|
||||
const isBetween = (number, lowerLimit, upperLimit) => {
|
||||
return (lowerLimit === undefined || number >= lowerLimit) && (upperLimit === undefined || number < upperLimit)
|
||||
}
|
||||
|
||||
export const calculateStats = (
|
||||
creditCounts,
|
||||
maximumAmountOfCredits,
|
||||
minimumAmountOfCredits = 0,
|
||||
numberOfCreditCategories = 7
|
||||
) => {
|
||||
const tableStats = []
|
||||
if (creditCounts === undefined) return tableStats
|
||||
|
||||
const limits = getCreditCategories(
|
||||
true,
|
||||
'academic-year',
|
||||
maximumAmountOfCredits,
|
||||
Object.keys(creditCounts),
|
||||
numberOfCreditCategories - 1,
|
||||
minimumAmountOfCredits
|
||||
)
|
||||
const tableTitles = ['', 'All']
|
||||
for (let i = 0; i < limits.length; i++) {
|
||||
if (limits[i][0] === undefined) tableTitles.push(`< ${limits[i][1]} credits`)
|
||||
else if (limits[i][1] === undefined) tableTitles.push(`≥ ${limits[i][0]} credits`)
|
||||
else tableTitles.push(`${limits[i][0]}–${limits[i][1]} credits`)
|
||||
}
|
||||
|
||||
Object.keys(creditCounts).forEach(year => {
|
||||
const yearCreditCount = creditCounts[year]
|
||||
const yearCounts = [year, yearCreditCount.length]
|
||||
tableStats.push(yearCounts)
|
||||
for (let i = 0; i < limits.length; i++) {
|
||||
yearCounts.push(yearCreditCount.filter(credits => isBetween(credits, limits[i][0], limits[i][1])).length)
|
||||
}
|
||||
})
|
||||
|
||||
const totalCounts = ['Total']
|
||||
for (let i = 1; i < tableStats[0].length; i++) {
|
||||
let columnSum = 0
|
||||
for (let j = 0; j < tableStats.length; j++) {
|
||||
columnSum += tableStats[j][i]
|
||||
}
|
||||
totalCounts.push(columnSum)
|
||||
}
|
||||
tableStats.push(totalCounts)
|
||||
|
||||
// Calculate statistics for the bar chart (i.e., transpose the tableStats as rows are now columns and vice versa)
|
||||
const chartStats = []
|
||||
for (let i = 2; i < tableStats[0].length; i++) {
|
||||
const column = []
|
||||
for (let j = tableStats.length - 1; j >= 0; j--) {
|
||||
column.push(tableStats[j][i])
|
||||
}
|
||||
chartStats.push({ name: tableTitles[i].replace('<', 'Less than').replace('≥', 'At least'), data: column })
|
||||
}
|
||||
return { tableStats, chartStats, tableTitles }
|
||||
}
|
||||
|
||||
const FacultyProgrammeOverview = ({
|
||||
faculty,
|
||||
graduatedGroup,
|
||||
@@ -88,6 +150,17 @@ const FacultyProgrammeOverview = ({
|
||||
)
|
||||
}
|
||||
|
||||
const bachelorStats = calculateStats(progressStats?.data?.creditCounts?.bachelor, 180)
|
||||
const bachelorMasterStats = calculateStats(
|
||||
progressStats?.data?.creditCounts?.bachelorMaster,
|
||||
faculty.code === 'H90' ? 360 : 300,
|
||||
180,
|
||||
7
|
||||
)
|
||||
const masterStats = calculateStats(progressStats?.data?.creditCounts?.master, 120)
|
||||
const licentiateStats = calculateStats(progressStats?.data?.creditCounts?.licentiate, 360)
|
||||
const doctorStats = calculateStats(progressStats?.data?.creditCounts?.doctor, 40, 0, 5)
|
||||
|
||||
return (
|
||||
<div className="faculty-overview">
|
||||
<div className="toggle-container">
|
||||
@@ -171,7 +244,18 @@ const FacultyProgrammeOverview = ({
|
||||
icon="download"
|
||||
floated="right"
|
||||
onClick={() =>
|
||||
downloadProgressTableCsv(progressStats, progressStats?.data?.programmeNames, faculty, getTextIn)
|
||||
downloadProgressTableCsv(
|
||||
{
|
||||
...progressStats,
|
||||
bachelorStats,
|
||||
bachelorMasterStats,
|
||||
masterStats,
|
||||
doctorStats,
|
||||
},
|
||||
progressStats?.data?.programmeNames,
|
||||
faculty,
|
||||
getTextIn
|
||||
)
|
||||
}
|
||||
style={{ backgroundColor: 'white', borderRadius: 0 }}
|
||||
/>
|
||||
@@ -184,16 +268,16 @@ const FacultyProgrammeOverview = ({
|
||||
cypress="FacultyBachelorsProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: progressStats?.data.bachelorsGraphStats,
|
||||
stats: bachelorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.bachelorsTableStats}
|
||||
data={bachelorStats.tableStats}
|
||||
programmeStats={progressStats?.data.bachelorsProgStats}
|
||||
titles={progressStats?.data.bachelorTitles}
|
||||
titles={bachelorStats.tableTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.bachelorsProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
@@ -216,16 +300,16 @@ const FacultyProgrammeOverview = ({
|
||||
cypress="FacultyBachelorMastersProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: progressStats?.data.bcMsGraphStats,
|
||||
stats: bachelorMasterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.bcMsTableStats}
|
||||
data={bachelorMasterStats.tableStats}
|
||||
programmeStats={progressStats?.data.bcMsProgStats}
|
||||
titles={progressStats?.data.bcMsTitles}
|
||||
titles={bachelorMasterStats.tableTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.bcMsProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
@@ -246,16 +330,16 @@ const FacultyProgrammeOverview = ({
|
||||
cypress="FacultyMastersProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: progressStats?.data.mastersGraphStats,
|
||||
stats: masterStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.mastersTableStats}
|
||||
data={masterStats.tableStats}
|
||||
programmeStats={progressStats?.data.mastersProgStats}
|
||||
titles={progressStats?.data.mastersTitles}
|
||||
titles={masterStats.tableTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.mastersProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
@@ -269,6 +353,38 @@ const FacultyProgrammeOverview = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{faculty.code === 'H30' && (
|
||||
<>
|
||||
{getDivider('Licentiate', 'LicentiateStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
<FacultyBarChart
|
||||
cypress="FacultyLicentiateProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: licentiateStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={licentiateStats.tableStats}
|
||||
programmeStats={progressStats?.data.licentiateProgStats}
|
||||
titles={licentiateStats.tableTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.licentiateProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
progressYearsVisible={Array(progressStats?.data.years.slice(1).length).fill(false)}
|
||||
programmeNames={progressStats?.data.programmeNames}
|
||||
cypress="FacultyLicentiateProgressTable"
|
||||
progressTitles={progressStats?.data.yearlyLicentiateTitles}
|
||||
needsExtra="EXTRA HEIGHT"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{getDivider('Doctor', 'DoctoralStudentsOfTheFacultyByStartingYear', 'no-infobox')}
|
||||
<div className="section-container">
|
||||
<div className="graph-container">
|
||||
@@ -276,16 +392,16 @@ const FacultyProgrammeOverview = ({
|
||||
cypress="FacultyDoctoralProgress"
|
||||
data={{
|
||||
id: faculty.code,
|
||||
stats: progressStats?.data.doctoralGraphStats,
|
||||
stats: doctorStats.chartStats,
|
||||
years: progressStats?.data.years,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<FacultyProgressTable
|
||||
data={progressStats?.data.doctoralTableStats}
|
||||
data={doctorStats.tableStats}
|
||||
programmeStats={progressStats?.data.doctoralProgStats}
|
||||
titles={progressStats?.data.doctoralTitles}
|
||||
titles={doctorStats.tableTitles}
|
||||
sortedKeys={getSortedProgrammeKeysProgress(progressStats?.data.doctoralProgStats).map(
|
||||
listObj => listObj[0]
|
||||
)}
|
||||
|
||||
@@ -95,10 +95,10 @@ const BarChart = ({
|
||||
fontSize: '25px',
|
||||
// outside: true,
|
||||
// eslint-disable-next-line
|
||||
formatter: function() {
|
||||
formatter: function () {
|
||||
return getTooltipText(
|
||||
this.point.name,
|
||||
this.point.code,
|
||||
this.point.code ? this.point.code : this.point.name,
|
||||
this.point.amount,
|
||||
this.y,
|
||||
this.point.statistics,
|
||||
|
||||
@@ -109,17 +109,24 @@ export const downloadStudentTableCsv = (studentStats, programmeNames, faculty, s
|
||||
}
|
||||
|
||||
export const downloadProgressTableCsv = (progressStats, programmeNames, faculty, getTextIn) => {
|
||||
const bachelorStats = progressStats?.bachelorStats
|
||||
const bachelorMasterStats = progressStats?.bachelorMasterStats
|
||||
const masterStats = progressStats?.masterStats
|
||||
const doctorStats = progressStats?.doctorStats
|
||||
|
||||
const book = xlsx.utils.book_new()
|
||||
const progressStatsBachelor = progressStats?.data?.bachelorsTableStats || []
|
||||
const progressStatsBachelorMaster = progressStats?.data?.bcMsTableStats || []
|
||||
const progressStatsMaster = progressStats?.data?.mastersTableStats || []
|
||||
const progressStatsDoctor = progressStats?.data?.doctoralTableStats || []
|
||||
|
||||
const progressStatsBachelor = bachelorStats.tableStats || []
|
||||
const progressStatsBachelorMaster = bachelorMasterStats.tableStats || []
|
||||
const progressStatsMaster = masterStats.tableStats || []
|
||||
const progressStatsDoctor = doctorStats.tableStats || []
|
||||
|
||||
const tableData = [progressStatsBachelor, progressStatsMaster, progressStatsBachelorMaster, progressStatsDoctor]
|
||||
const tableHeadersData = [
|
||||
progressStats?.data?.bachelorTitles,
|
||||
progressStats?.data?.mastersTitles,
|
||||
progressStats?.data?.bcMsTitles,
|
||||
progressStats?.data?.doctoralTitles,
|
||||
bachelorStats.tableTitles,
|
||||
masterStats.tableTitles,
|
||||
bachelorMasterStats.tableTitles,
|
||||
doctorStats.tableTitles,
|
||||
]
|
||||
|
||||
const sheetNames = ['Bachelor', 'Master', 'Bachelor-Master', 'Doctor']
|
||||
@@ -152,7 +159,7 @@ export const downloadProgressTableCsv = (progressStats, programmeNames, faculty,
|
||||
progressStats?.data?.yearlyBachelorTitles,
|
||||
progressStats?.data?.yearlyMasterTitles,
|
||||
progressStats?.data?.yearlyBcMsTitles,
|
||||
progressStats?.data?.doctoralTitles,
|
||||
doctorStats.tableTitles,
|
||||
]
|
||||
counter = -1
|
||||
const years = progressStatsBachelor.map(row => row[0])
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { Form, TextArea, Button, Modal } from 'semantic-ui-react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Form, TextArea, Button, Modal, Message, Icon, Header } from 'semantic-ui-react'
|
||||
|
||||
import { sendFeedbackAction } from '../../redux/feedback'
|
||||
import { useSendFeedbackMutation } from 'redux/feedback'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
|
||||
const Feedback = ({ sendFeedback, success, pending, error }) => {
|
||||
const Feedback = () => {
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [showError, setError] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
const [showError, setShowError] = useState(false)
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
useTitle('Feedback')
|
||||
|
||||
const [sendFeedback, { isError, isLoading, isSuccess }] = useSendFeedbackMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
setFeedback('')
|
||||
setShow(true)
|
||||
setTimeout(() => {
|
||||
setShow(false)
|
||||
}, 10000)
|
||||
}
|
||||
if (error) {
|
||||
setError(true)
|
||||
setTimeout(() => {
|
||||
setError(false)
|
||||
}, 10000)
|
||||
}
|
||||
}, [pending])
|
||||
if (!isSuccess) return undefined
|
||||
setFeedback('')
|
||||
setShowSuccess(true)
|
||||
const timer = setTimeout(() => setShowSuccess(false), 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isError) return undefined
|
||||
setShowError(true)
|
||||
const timer = setTimeout(() => setShowError(false), 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isError])
|
||||
|
||||
const handleTyping = ({ target }) => {
|
||||
setFeedback(target.value)
|
||||
@@ -35,36 +33,46 @@ const Feedback = ({ sendFeedback, success, pending, error }) => {
|
||||
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault()
|
||||
sendFeedback(feedback)
|
||||
sendFeedback({ content: feedback })
|
||||
}
|
||||
|
||||
return (
|
||||
<div align="center">
|
||||
{show === true ? (
|
||||
<div className="ui positive message" align="center" style={{ maxWidth: 1000 }}>
|
||||
<div className="header">Your question/feedback was sent</div>
|
||||
<p>Thank you for contacting us. We will contact you soon.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showError === true ? (
|
||||
<div className="ui negative message" align="center" style={{ maxWidth: 1000 }}>
|
||||
<div className="header">Your question/feedback was not sent</div>
|
||||
<p>An error occured while trying to send your message. Please try again.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showSuccess && (
|
||||
<Message icon positive size="large" style={{ marginTop: '40px', maxWidth: 700 }}>
|
||||
<Icon name="envelope" />
|
||||
<Message.Content style={{ textAlign: 'left' }}>
|
||||
<Message.Header>Your message was sent</Message.Header>
|
||||
<p>Thank you for contacting us. We’ll get back to you soon.</p>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)}
|
||||
{showError && (
|
||||
<Message icon negative size="large" style={{ marginTop: '40px', maxWidth: 700 }}>
|
||||
<Icon name="warning sign" />
|
||||
<Message.Content style={{ textAlign: 'left' }}>
|
||||
<Message.Header>Your message was not sent</Message.Header>
|
||||
<p>An error occured while trying to send your message. Please try again.</p>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)}
|
||||
<Header as="h1" textAlign="center" style={{ margin: '40px 0' }}>
|
||||
Give feedback
|
||||
</Header>
|
||||
<Form>
|
||||
Questions/feedback:
|
||||
<div>
|
||||
<TextArea
|
||||
placeholder="Tell us more"
|
||||
style={{ minHeight: 400, maxWidth: 1000 }}
|
||||
onChange={handleTyping}
|
||||
value={feedback}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
placeholder="Tell us more"
|
||||
style={{ minHeight: 400, maxWidth: 1000 }}
|
||||
onChange={handleTyping}
|
||||
value={feedback}
|
||||
/>
|
||||
<div>
|
||||
<Modal
|
||||
trigger={<Button disabled={feedback.length < 1 || pending}> submit</Button>}
|
||||
trigger={
|
||||
<Button primary style={{ marginTop: '30px' }} disabled={!feedback.trim().length || isLoading}>
|
||||
Submit
|
||||
</Button>
|
||||
}
|
||||
header="Sending mail to Toska"
|
||||
actions={[
|
||||
'Cancel',
|
||||
@@ -72,7 +80,7 @@ const Feedback = ({ sendFeedback, success, pending, error }) => {
|
||||
key: 'send',
|
||||
content: 'Send',
|
||||
positive: true,
|
||||
disabled: pending,
|
||||
disabled: isLoading,
|
||||
onClick: event => handleSubmit(event),
|
||||
},
|
||||
]}
|
||||
@@ -83,17 +91,4 @@ const Feedback = ({ sendFeedback, success, pending, error }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ feedback }) => ({
|
||||
success: feedback.success,
|
||||
pending: feedback.pending,
|
||||
error: feedback.error,
|
||||
})
|
||||
|
||||
Feedback.propTypes = {
|
||||
sendFeedback: PropTypes.func.isRequired,
|
||||
success: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
error: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default withRouter(connect(mapStateToProps, { sendFeedback: sendFeedbackAction })(Feedback))
|
||||
export default Feedback
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import RangeSelector from 'components/RangeSelector'
|
||||
import { RangeSelector } from 'components/common/RangeSelector'
|
||||
import { useDebounce } from 'common/hooks'
|
||||
import _ from 'lodash'
|
||||
import createFilter from './createFilter'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const FilterType = {
|
||||
ALL: 'ALL',
|
||||
PASSED: 'PASSED',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import RangeSelector from 'components/RangeSelector'
|
||||
import { RangeSelector } from 'components/common/RangeSelector'
|
||||
import React, { useMemo } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { useDebounce } from 'common/hooks'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable react/no-children-prop */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import changelogApi from 'redux/changelog'
|
||||
import { Divider, Header, Loader } from 'semantic-ui-react'
|
||||
import { builtAt } from '../../conf'
|
||||
|
||||
const { useGetChangelogQuery } = changelogApi
|
||||
|
||||
export const Changelog = ({ showFullChangelog }) => {
|
||||
const [itemsToShow, setItemsToShow] = useState([])
|
||||
const { data, isLoading } = useGetChangelogQuery()
|
||||
|
||||
const filterInternalReleases = release => !release.title.startsWith('Internal:')
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setItemsToShow(
|
||||
showFullChangelog
|
||||
? [...data.filter(filterInternalReleases).slice(0, 20)]
|
||||
: [...data.filter(filterInternalReleases).slice(0, 2)]
|
||||
)
|
||||
}, [data, showFullChangelog])
|
||||
|
||||
const formatDate = dateString => {
|
||||
const date = new Date(dateString)
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' })
|
||||
return dateFormatter.format(date)
|
||||
}
|
||||
|
||||
const getReleaseString = release => {
|
||||
const date = formatDate(release.time)
|
||||
const { title } = release
|
||||
const description = release.description.split('### Internal')[0]
|
||||
const releaseString = showFullChangelog ? `## ${title}\n${date}\n\n${description}` : `#### ${title}\n${description}`
|
||||
return releaseString
|
||||
}
|
||||
|
||||
if (isLoading || itemsToShow.length === 0) return <Loader />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!showFullChangelog && (
|
||||
<>
|
||||
<Header as="h3">Updates</Header>
|
||||
<p>Last update on: {builtAt ? formatDate(builtAt) : formatDate(itemsToShow[0].time)}</p>
|
||||
</>
|
||||
)}
|
||||
{itemsToShow.map(release => (
|
||||
<div key={release.time}>
|
||||
<Divider section />
|
||||
<ReactMarkdown children={getReleaseString(release)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import oodiTXT from '../../static/oodi.txt'
|
||||
import './oodiToOodikone.css'
|
||||
|
||||
const OodiToOodikone = () => {
|
||||
const [oodis, setOodis] = useState([])
|
||||
useEffect(() => {
|
||||
const getOodis = async () => {
|
||||
try {
|
||||
const data = await fetch(oodiTXT)
|
||||
setOodis(
|
||||
(await data.text())
|
||||
.split('**')
|
||||
.map(o => o.trim())
|
||||
.filter(oodi => !!oodi)
|
||||
)
|
||||
} catch (error) {
|
||||
setOodis([])
|
||||
}
|
||||
}
|
||||
getOodis()
|
||||
}, [])
|
||||
|
||||
if (!oodis || oodis.length === 0) {
|
||||
return null
|
||||
}
|
||||
const oodi = oodis[Math.floor(Math.random() * oodis.length)].split('\n')
|
||||
return (
|
||||
<blockquote cite="http://www.helsinki.fi/discovery">
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{oodi.map((l, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<p key={index} style={{ marginBottom: '0.6rem' }}>
|
||||
{l}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ marginTop: '1.3em', textAlign: 'center' }}>
|
||||
- <a href="http://www.helsinki.fi/discovery">R. U. Nokone</a>
|
||||
</p>
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
|
||||
export default OodiToOodikone
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import React, { useState } from 'react'
|
||||
import { Container, Header, Image, Divider, List, Grid, Button, Icon } from 'semantic-ui-react'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { images, checkUserAccess } from '../../common'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { builtAt } from '../../conf'
|
||||
import OodiToOodikone from './OoodiToOodikone'
|
||||
import { Changelog } from './Changelog'
|
||||
|
||||
const FrontPage = () => {
|
||||
const { rights, roles } = useGetAuthorizedUserQuery()
|
||||
const [showFullChangelog, setShowFullChangelog] = useState(false)
|
||||
|
||||
useTitle()
|
||||
|
||||
const showItems = {
|
||||
@@ -23,59 +23,87 @@ const FrontPage = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container text style={{ paddingTop: 50 }} textAlign="justified">
|
||||
<Header as="h1" textAlign="center">
|
||||
Oodikone
|
||||
</Header>
|
||||
<Header as="h3" style={{ textAlign: 'center' }}>
|
||||
Exploratory Research on Study Data
|
||||
</Header>
|
||||
<OodiToOodikone />
|
||||
|
||||
{showItems.populations && (
|
||||
<Container style={{ paddingTop: 50, marginBottom: '20px' }} textAlign="justified">
|
||||
{showFullChangelog ? (
|
||||
<>
|
||||
<Divider section />
|
||||
<Header as="h4">Study Programme</Header>
|
||||
<List bulleted>
|
||||
<List.Item>
|
||||
<i>Search by Class:</i> Query a student population specified by a starting year and a study right.
|
||||
Oodikone will show you interactive statistics and visualizations for the population to be explored.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<i>Overview:</i> View student progress and annual productivity for a given study programme.
|
||||
</List.Item>
|
||||
</List>
|
||||
<Header as="h1" style={{ textAlign: 'center' }}>
|
||||
Latest updates
|
||||
</Header>
|
||||
<Button fluid style={{ margin: '30px 0' }} onClick={() => setShowFullChangelog(false)}>
|
||||
<Icon name="arrow left" />
|
||||
Back to front page
|
||||
</Button>
|
||||
<Changelog showFullChangelog />
|
||||
<Button fluid style={{ margin: '30px 0' }} onClick={() => setShowFullChangelog(false)}>
|
||||
<Icon name="arrow left" />
|
||||
Back to front page
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header as="h1" textAlign="center">
|
||||
Oodikone
|
||||
</Header>
|
||||
<Header as="h3" style={{ textAlign: 'center' }}>
|
||||
Exploratory Research on Study Data
|
||||
</Header>
|
||||
<Grid columns="two" style={{ marginTop: '40px' }} divided>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<Header as="h3">Trends</Header>
|
||||
<p>View many kinds of visualizations about study progress and study programme status.</p>
|
||||
<Divider section />
|
||||
{showItems.populations && (
|
||||
<>
|
||||
<Header as="h3">Study programme</Header>
|
||||
<List bulleted>
|
||||
<List.Item>
|
||||
<i>Search by Class:</i> Query a student population specified by a starting year and a study
|
||||
right. Oodikone will show you interactive statistics and visualizations for the population to
|
||||
be explored.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<i>Overview:</i> View student progress and annual productivity for a given study programme.
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
{showItems.courseStatistics && (
|
||||
<>
|
||||
<Divider section />
|
||||
<Header as="h3">Course statistics</Header>
|
||||
<p>View statistics by course and year.</p>
|
||||
</>
|
||||
)}
|
||||
{showItems.students && (
|
||||
<>
|
||||
<Divider section />
|
||||
<Header as="h3">Student statistics</Header>
|
||||
<p>View detailed information for a given student.</p>
|
||||
</>
|
||||
)}
|
||||
<Divider section />
|
||||
<Header as="h3">Feedback</Header>
|
||||
<p>
|
||||
For questions and suggestions, please use the{' '}
|
||||
<a href="https://oodikone.helsinki.fi/feedback">feedback form</a> or shoot an email to{' '}
|
||||
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a>.
|
||||
</p>
|
||||
</Grid.Column>
|
||||
<Grid.Column style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Changelog showFullChangelog={false} />
|
||||
<Button style={{ marginTop: '40px' }} onClick={() => setShowFullChangelog(true)}>
|
||||
Show more
|
||||
</Button>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{showItems.students && (
|
||||
<>
|
||||
<Divider section />
|
||||
<Header as="h4">Student Statistics</Header>
|
||||
<p>View detailed information for a given student.</p>
|
||||
</>
|
||||
)}
|
||||
{showItems.courseStatistics && (
|
||||
<>
|
||||
<Divider section />
|
||||
<Header as="h4">Course Statistics</Header>
|
||||
<p>View statistics by course and year.</p>
|
||||
</>
|
||||
)}
|
||||
<Divider section />
|
||||
<Header as="h4">Trends</Header>
|
||||
<p>View many kinds visualizations of study progress and study programme status.</p>
|
||||
|
||||
<Divider section />
|
||||
<Header as="h4">Feedback</Header>
|
||||
<p>
|
||||
For questions and suggestions, use either the feedback form or shoot an e-mail to{' '}
|
||||
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a>.
|
||||
</p>
|
||||
|
||||
<Divider section />
|
||||
{builtAt ? <p>Oodikone was last updated on: {moment(builtAt).toDate().toLocaleString()}</p> : null}
|
||||
</Container>
|
||||
<Image src={images.toskaLogo} size="medium" centered style={{ bottom: 0 }} />
|
||||
<a href="https://toska.dev/">
|
||||
<Image src={images.toskaLogo} size="medium" centered style={{ bottom: 0 }} />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
blockquote {
|
||||
font-style: italic;
|
||||
line-height: 1.618;
|
||||
font-size: 1.2em;
|
||||
width: 30em;
|
||||
position: relative;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
blockquote::before {
|
||||
content: open-quote;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
blockquote::after {
|
||||
content: close-quote;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
blockquote::before,
|
||||
blockquote::after {
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
line-height: 1.618;
|
||||
font-size: 2em;
|
||||
border-radius: 100%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import useLanguage from 'components/LanguagePicker/useLanguage'
|
||||
import SortableTable, { row } from 'components/SortableTable'
|
||||
import React, { useMemo } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { calculateTotals, getColumns } from './logic'
|
||||
import { getRatio, useLanguageCenterContext } from '../common'
|
||||
import { CompletionPicker, SemesterRangeSelector } from '../selectorComponents'
|
||||
import '../index.css'
|
||||
|
||||
export const FacultiesTab = () => {
|
||||
const { numberMode, semesterFilter, setSemesterFilter, selectedSemesters, data, facultyMap } =
|
||||
useLanguageCenterContext()
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const totalRow = useMemo(() => {
|
||||
const totals = calculateTotals(data.tableData, data.faculties, selectedSemesters)
|
||||
return row(totals, { ignoreFilters: true, ignoreSorting: true })
|
||||
}, [data, facultyMap])
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
const tableData = [_.cloneDeep(totalRow), ..._.cloneDeep(data.tableData)]
|
||||
tableData.forEach(course => {
|
||||
const facultiesTotal = { completions: 0, enrollments: 0, ratio: null }
|
||||
selectedSemesters.forEach(semestercode => {
|
||||
data.faculties.forEach(faculty => {
|
||||
if (!course.bySemesters.cellStats[faculty])
|
||||
course.bySemesters.cellStats[faculty] = { completions: 0, enrollments: 0, ratio: null }
|
||||
const stats = course.bySemesters[semestercode]?.[faculty]
|
||||
if (!stats) return
|
||||
course.bySemesters.cellStats[faculty].completions += stats.completions
|
||||
course.bySemesters.cellStats[faculty].enrollments += stats.enrollments
|
||||
course.bySemesters.cellStats[faculty].ratio = getRatio(course.bySemesters.cellStats[faculty])
|
||||
facultiesTotal.completions += stats.completions
|
||||
facultiesTotal.enrollments += stats.enrollments
|
||||
})
|
||||
facultiesTotal.ratio = getRatio(facultiesTotal)
|
||||
})
|
||||
course.bySemesters.facultiesTotal = facultiesTotal
|
||||
course.bySemesters = { ...course.bySemesters, facultiesTotal }
|
||||
})
|
||||
return tableData
|
||||
}, [selectedSemesters, data])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="options-container">
|
||||
<SemesterRangeSelector setSemesterFilter={setSemesterFilter} semesterFilter={semesterFilter} />
|
||||
<CompletionPicker enableRatioOption />
|
||||
</div>
|
||||
<SortableTable
|
||||
columns={getColumns(getTextIn, [...data.faculties].sort(), numberMode, facultyMap)}
|
||||
data={numberMode === 'ratio' ? tableData.slice(1) : tableData}
|
||||
stretch
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { courseNameColumn, getRatio } from '../common'
|
||||
|
||||
export const getColumns = (getTextIn, faculties, numberMode, facultyMap) => {
|
||||
const getFacultyTitle = code => {
|
||||
if (!code) return 'No faculty' // Shouldn't happen probably
|
||||
if (code === 'OPEN') return 'Open\nuni'
|
||||
if (code === 'OTHER') return 'Other\nfaculty'
|
||||
return code
|
||||
}
|
||||
|
||||
const formatAsPercent = val => (val === null ? '-' : `${val} %`)
|
||||
|
||||
const getRatioTooltip = stats =>
|
||||
`Completions: ${stats.completions}\nEnrollments: ${stats.enrollments}\nRatio: ${stats.ratio} %`
|
||||
|
||||
const getRatioCellProps = stats => {
|
||||
if (stats.ratio === null) return null
|
||||
const hoverTooltip = {
|
||||
title: getRatioTooltip(stats),
|
||||
}
|
||||
if (numberMode !== 'ratio') return hoverTooltip
|
||||
if (stats.enrollments === 0) return hoverTooltip
|
||||
return { ...hoverTooltip, style: { backgroundColor: `rgba(200,0,0,${1 - stats.ratio / 100})` } }
|
||||
}
|
||||
|
||||
const totalColumn = {
|
||||
key: 'total-column',
|
||||
title: 'Total',
|
||||
getRowVal: row => row.bySemesters.facultiesTotal[numberMode],
|
||||
forceToolsMode: 'floating',
|
||||
filterType: 'range',
|
||||
}
|
||||
|
||||
const totalRatioColumn = {
|
||||
key: 'total-ratio',
|
||||
title: 'Total ratio',
|
||||
getRowVal: row => {
|
||||
const stats = row.bySemesters.facultiesTotal
|
||||
if (stats.enrollments === 0 || stats.ratio === null) return null
|
||||
return stats.ratio
|
||||
},
|
||||
formatValue: val => formatAsPercent(val),
|
||||
cellProps: row => getRatioCellProps(row.bySemesters.facultiesTotal),
|
||||
}
|
||||
|
||||
const columns = [
|
||||
courseNameColumn(getTextIn),
|
||||
...faculties.map(facultyCode => ({
|
||||
key: facultyCode ?? 'no-faculty',
|
||||
title: getFacultyTitle(facultyCode),
|
||||
headerProps: { title: getTextIn(facultyMap[facultyCode]) },
|
||||
cellProps: row => getRatioCellProps(row.bySemesters.cellStats[facultyCode]),
|
||||
getRowVal: row => {
|
||||
const stats = row.bySemesters.cellStats[facultyCode]
|
||||
if (numberMode === 'ratio') {
|
||||
if (stats.enrollments === 0) return null
|
||||
return stats.ratio
|
||||
}
|
||||
return stats[numberMode]
|
||||
},
|
||||
formatValue: numberMode !== 'ratio' ? null : val => formatAsPercent(val),
|
||||
filterType: 'range',
|
||||
forceToolsMode: 'floating',
|
||||
})),
|
||||
numberMode !== 'ratio' ? null : totalRatioColumn,
|
||||
numberMode === 'ratio' ? null : totalColumn,
|
||||
].filter(Boolean)
|
||||
return columns
|
||||
}
|
||||
|
||||
export const calculateTotals = (courses, faculties, semesters) => {
|
||||
const facultiesTotal = { completions: 0, enrollments: 0, ratio: null }
|
||||
const totalRow = { cellStats: {} }
|
||||
semesters.forEach(sem => {
|
||||
totalRow[sem] = { completions: 0, enrollments: 0, ratio: 1 }
|
||||
courses.forEach(course => {
|
||||
const stats = course.bySemesters[sem]
|
||||
if (!stats) return
|
||||
totalRow[sem].completions += stats.completions
|
||||
totalRow[sem].enrollments += stats.enrollments
|
||||
totalRow[sem].ratio = getRatio(totalRow[sem])
|
||||
facultiesTotal.completions += stats.completions
|
||||
facultiesTotal.enrollments += stats.enrollments
|
||||
facultiesTotal.ratio = getRatio(totalRow)
|
||||
})
|
||||
|
||||
faculties.forEach(fac => {
|
||||
totalRow[sem][fac] = { completions: 0, enrollments: 0, ratio: null }
|
||||
courses.forEach(course => {
|
||||
const stats = course.bySemesters[sem]?.[fac]
|
||||
if (!stats) return
|
||||
totalRow[sem][fac].completions += stats.completions
|
||||
totalRow[sem][fac].enrollments += stats.enrollments
|
||||
totalRow[sem][fac].ratio = getRatio(totalRow[sem][fac])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return { cellStats: {}, code: 'TOTAL', name: { en: 'All courses total' }, bySemesters: totalRow, facultiesTotal }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button, Icon, Message } from 'semantic-ui-react'
|
||||
|
||||
export const InfoBox = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Message style={{ maxWidth: '60em' }}>
|
||||
<p>This view displays amounts of enrollments and completions of courses organized by language center.</p>
|
||||
<p>You can view the numbers by faculties or by semesters.</p>
|
||||
{open && (
|
||||
<div>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Show number of:</b>
|
||||
</li>
|
||||
<ul>
|
||||
<li>Completions: Amount of passed completions of course</li>
|
||||
<li>Enrollments: Amount of accepted enrollments on course</li>
|
||||
<li>
|
||||
Ratio: Percentage of completions per enrollments. 0 % means there are zero completions and at least one
|
||||
enrollment. 100 % means there are at least as many completions as enrollments. A dash indicates there
|
||||
are no completions or enrollments. Hover mouse over a cell to view the amount of enrollments and
|
||||
completions.
|
||||
</li>
|
||||
</ul>
|
||||
<li>
|
||||
<b>Coloring mode: </b>Only available in "by semesters" -tab. Change this to compare a course's popularity
|
||||
to other courses, or to its own average in time.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Tips:</p>
|
||||
<ul>
|
||||
<li>Hover your mouse over the faculty column header to see the name of the faculty</li>
|
||||
<li>
|
||||
When in ratio mode, hover your mouse over the cells to see the numbers of completions and enrollments.
|
||||
</li>
|
||||
<li>
|
||||
Click the column headers to sort by the column, or click the filter icon{' '}
|
||||
<Icon name="filter" style={{ color: '#bbb' }} /> (appears when hovering mouse on column header) to set a
|
||||
filter on that column.
|
||||
</li>
|
||||
<li>
|
||||
<b>Example of viewing all courses of a language</b>: Click the filter-icon
|
||||
<Icon name="filter" style={{ color: '#bbb' }} />. Type "KK-ESP" and press enter. Now only the courses
|
||||
whose code contains "KK-ESP" will be shown. Notice that the total-row on top of the table still shows
|
||||
numbers from all courses.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
This is a new feature. Suggestions for improvement or questions are welcomed to grp-toska@helsinki.fi or via
|
||||
the <a href="https://oodikone.helsinki.fi/feedback">feedback form</a>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button style={{ marginTop: '20px' }} onClick={() => setOpen(!open)}>
|
||||
{open ? 'Hide' : 'Show more info'}
|
||||
</Button>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import SortableTable, { row } from 'components/SortableTable'
|
||||
import useLanguage from 'components/LanguagePicker/useLanguage'
|
||||
import { calculateTotals, getColumns } from './logic'
|
||||
import { useLanguageCenterContext } from '../common'
|
||||
import '../index.css'
|
||||
import { ColorModeSelector, CompletionPicker, SemesterRangeSelector } from '../selectorComponents'
|
||||
|
||||
export const SemestersTab = () => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const {
|
||||
setSemesterFilter,
|
||||
semesterFilter,
|
||||
semesters,
|
||||
numberMode,
|
||||
colorMode,
|
||||
selectedSemesters,
|
||||
setNumberMode,
|
||||
data,
|
||||
} = useLanguageCenterContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (numberMode === 'ratio') {
|
||||
setNumberMode('completions')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const totalRow = useMemo(() => {
|
||||
if (!data) return null
|
||||
const totals = calculateTotals(data.tableData, selectedSemesters)
|
||||
return row(totals, { ignoreSorting: true, ignoreFilters: true })
|
||||
}, [data, selectedSemesters])
|
||||
|
||||
if (numberMode === 'ratio') return null
|
||||
if (!semesters) return null
|
||||
const tableData = [totalRow, ...data.tableData]
|
||||
return (
|
||||
<div>
|
||||
<div className="options-container">
|
||||
<SemesterRangeSelector setSemesterFilter={setSemesterFilter} semesterFilter={semesterFilter} />
|
||||
<CompletionPicker enableRatioOption={false} />
|
||||
<ColorModeSelector />
|
||||
</div>
|
||||
<SortableTable
|
||||
columns={getColumns(
|
||||
getTextIn,
|
||||
semesters.filter(({ semestercode }) => selectedSemesters.includes(semestercode)),
|
||||
numberMode,
|
||||
colorMode,
|
||||
totalRow.bySemesters[numberMode]
|
||||
)}
|
||||
data={tableData}
|
||||
striped={colorMode === 'none'}
|
||||
stretch
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { courseNameColumn } from '../common'
|
||||
|
||||
export const getColumns = (getTextIn, semesters, numberMode, colorMode, allTotal) => {
|
||||
const getColor = (row, semester) => {
|
||||
if (colorMode === 'none' || allTotal === 0) return {}
|
||||
if (!semester && colorMode === 'course') return {}
|
||||
const stats = semester ? row.bySemesters[semester.semestercode] : row.bySemesters
|
||||
if (!stats) return {}
|
||||
const value = stats[numberMode]
|
||||
const totalValue = colorMode === 'course' ? row.bySemesters[numberMode] : allTotal
|
||||
if (value === 0) return {}
|
||||
|
||||
const relativeValue = (() => {
|
||||
if (totalValue === 0) return 0
|
||||
if (colorMode === 'course') return value / ((totalValue / semesters.length) * 2)
|
||||
return value / (totalValue / semesters.length / 8)
|
||||
})()
|
||||
const divisor = colorMode === 'course' ? 6 : 2
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: `rgba(0,170,0,${relativeValue / divisor})`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
courseNameColumn(getTextIn),
|
||||
...semesters.map(semester => ({
|
||||
key: `${semester.semestercode}`,
|
||||
title: `${getTextIn(semester.name).replace(' ', '\n')}`,
|
||||
cellProps: row => getColor(row, semester),
|
||||
getRowVal: row => row.bySemesters[semester.semestercode]?.[numberMode] ?? 0,
|
||||
filterable: false,
|
||||
})),
|
||||
{
|
||||
key: 'all',
|
||||
title: 'Total',
|
||||
getRowVal: row => row.bySemesters[numberMode] ?? 0,
|
||||
cellProps: row => getColor(row),
|
||||
},
|
||||
]
|
||||
return columns
|
||||
}
|
||||
|
||||
export const calculateTotals = (courses, semesters) => {
|
||||
const totalRow = { completions: 0, enrollments: 0, total: 0 }
|
||||
|
||||
semesters.forEach(sem => {
|
||||
totalRow[sem] = { completions: 0, enrollments: 0, total: 0 }
|
||||
courses.forEach(course => {
|
||||
const stats = course.bySemesters[sem]
|
||||
if (!stats) return
|
||||
totalRow[sem].completions += stats.completions
|
||||
totalRow[sem].enrollments += stats.enrollments
|
||||
totalRow[sem].total += stats.total
|
||||
totalRow.completions += stats.completions
|
||||
totalRow.enrollments += stats.enrollments
|
||||
totalRow.total += stats.total
|
||||
})
|
||||
})
|
||||
|
||||
return { code: 'TOTAL', name: { en: 'All courses total' }, bySemesters: totalRow }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import moment from 'moment'
|
||||
import React, { createContext, useContext } from 'react'
|
||||
|
||||
export const LanguageCenterContext = createContext(null)
|
||||
|
||||
export const useLanguageCenterContext = () => useContext(LanguageCenterContext)
|
||||
|
||||
export const shortenCourseName = (text, maxLength) =>
|
||||
text.length > maxLength ? `${text.substring(0, maxLength)} ... ` : text
|
||||
|
||||
export const courseNameColumn = getTextIn => ({
|
||||
key: 'course-name',
|
||||
title: 'Course',
|
||||
getRowVal: row => row.code,
|
||||
getRowContent: row => (
|
||||
<div title={getTextIn(row.name)} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<b>{row.code}</b>
|
||||
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name && shortenCourseName(getTextIn(row.name), 46)}</i>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
export const filterAttemptsByDates = (date, { startDate, endDate }) => {
|
||||
const start = startDate.startdate ?? moment(new Date('1900-1-1'))
|
||||
const end = endDate.enddate ?? moment(new Date('2100-01-01'))
|
||||
return moment(new Date(date)).isBetween(start, end)
|
||||
}
|
||||
|
||||
export const getRatio = stats => {
|
||||
if (stats.enrollments < stats.completions) return 100
|
||||
if (stats.completions === 0) return 0
|
||||
if (stats.enrollments === 0) return null
|
||||
const value = stats.completions / stats.enrollments
|
||||
return Math.round(value * 100)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
justify-content: center;
|
||||
margin: 50px 25px;
|
||||
flex-direction: column;
|
||||
align-content: 'center';
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -15,56 +15,18 @@ input[type='radio'] {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
cursor: pointer;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toggle-label-checked {
|
||||
cursor: pointer;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
margin-top: 1.4em;
|
||||
font-size: large;
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.datepicker-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.datepicker-acual-container {
|
||||
.selector-container {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.calendar-icon-container {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-right: 0.6em;
|
||||
}
|
||||
|
||||
.completion-container {
|
||||
margin: 1.2em auto;
|
||||
}
|
||||
|
||||
.completion-acual-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin: 0.4em auto;
|
||||
max-width: 12em;
|
||||
}
|
||||
|
||||
@@ -1,111 +1,27 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Dropdown, Icon, Loader, Radio, Button } from 'semantic-ui-react'
|
||||
import SortableTable, { row } from 'components/SortableTable'
|
||||
import useLanguageCenterData from 'redux/languageCenterView'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
import './index.css'
|
||||
import useLanguage from 'components/LanguagePicker/useLanguage'
|
||||
import moment from 'moment'
|
||||
import { useGetSemestersQuery } from 'redux/semesters'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useTabs } from 'common/hooks'
|
||||
import { useGetLanguageCenterDataQuery } from 'redux/languageCenterView'
|
||||
import { useGetFacultiesQuery } from 'redux/facultyStats'
|
||||
|
||||
const shorten = (text, maxLength) => (text.length > maxLength ? `${text.substring(0, maxLength)} ... ` : text)
|
||||
|
||||
const getColumns = (getTextIn, faculties, mode, facultyMap) => {
|
||||
const columns = [
|
||||
{
|
||||
key: 'course-name',
|
||||
title: 'Course',
|
||||
getRowVal: row => row.code ?? 'No faculty found because enrollment date missing.',
|
||||
getRowContent: row => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<b>{row.code ?? 'No faculty'}</b>
|
||||
<i style={{ color: 'gray', fontWeight: 'normal' }}>{row.name && shorten(getTextIn(row.name), 60)}</i>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...faculties.map(facultyCode => ({
|
||||
key: facultyCode ?? 'no-faculty',
|
||||
title: facultyCode ?? 'No faculty',
|
||||
headerProps: { title: getTextIn(facultyMap[facultyCode]) },
|
||||
getRowVal: row => {
|
||||
const stats = row.facultyStats[facultyCode]
|
||||
if (!stats) return 0
|
||||
if (mode === 'total') {
|
||||
const result = (stats.completed ?? 0) + (stats.notCompleted ?? 0)
|
||||
return result
|
||||
}
|
||||
return stats[mode] ?? 0
|
||||
},
|
||||
filterable: false,
|
||||
})),
|
||||
{
|
||||
key: 'all',
|
||||
title: 'Total',
|
||||
getRowVal: row => {
|
||||
const objVals = Object.values(row.facultyStats)
|
||||
const arr = objVals.map(stat => {
|
||||
if (mode === 'total') {
|
||||
const notCompleted = stat.notCompleted ?? 0
|
||||
const completed = stat.completed ?? 0
|
||||
return notCompleted + completed
|
||||
}
|
||||
return stat[mode]
|
||||
})
|
||||
return arr.reduce((sum, cur) => {
|
||||
if (cur === undefined) return sum
|
||||
return cur + sum
|
||||
}, 0)
|
||||
},
|
||||
},
|
||||
]
|
||||
return columns
|
||||
}
|
||||
|
||||
const getCourseFaculties = attempts => {
|
||||
const map = attempts.reduce((obj, cur) => {
|
||||
if (!obj[cur.courseCode]) {
|
||||
obj[cur.courseCode] = {}
|
||||
}
|
||||
if (!obj[cur.courseCode][cur.faculty]) {
|
||||
obj[cur.courseCode][cur.faculty] = {}
|
||||
}
|
||||
const stats = obj[cur.courseCode][cur.faculty]
|
||||
const field = cur.completed ? 'completed' : 'notCompleted'
|
||||
if (!stats[field]) {
|
||||
stats[field] = 1
|
||||
} else {
|
||||
stats[field] += 1
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
return map
|
||||
}
|
||||
|
||||
const calculateTotals = coursesWithFaculties => {
|
||||
const facultyStats = {}
|
||||
coursesWithFaculties.forEach(course => {
|
||||
Object.entries(course.facultyStats).forEach(([faculty, stats]) => {
|
||||
if (!facultyStats[faculty]) {
|
||||
facultyStats[faculty] = { notCompleted: 0, completed: 0 }
|
||||
}
|
||||
const oldStats = facultyStats[faculty]
|
||||
if (!oldStats.notCompleted) oldStats.notCompleted = 0
|
||||
if (!oldStats.completed) oldStats.completed = 0
|
||||
oldStats.notCompleted += stats.notCompleted ?? 0
|
||||
oldStats.completed += stats.completed ?? 0
|
||||
})
|
||||
})
|
||||
return { code: 'TOTAL', name: { en: 'All courses total' }, facultyStats }
|
||||
}
|
||||
|
||||
const filterAttemptsByDates = (date, { startDate, endDate }) => {
|
||||
const start = startDate.startdate ?? moment(new Date('1900-1-1'))
|
||||
const end = endDate.enddate ?? moment(new Date('2100-01-01'))
|
||||
return moment(new Date(date)).isBetween(start, end)
|
||||
}
|
||||
import { FacultiesTab } from './FacultiesTab/index'
|
||||
import { SemestersTab } from './SemestersTab/index'
|
||||
import { LanguageCenterContext } from './common'
|
||||
import { InfoBox } from './InfoBox'
|
||||
|
||||
const LanguageCenterView = () => {
|
||||
const semestersQuery = useGetSemestersQuery()
|
||||
const semesters =
|
||||
semestersQuery.isSuccess &&
|
||||
semestersQuery.data?.semesters &&
|
||||
Object.values(semestersQuery.data.semesters).filter(
|
||||
// 135 = Fall 2017
|
||||
sem => sem.semestercode >= 135 && new Date(sem.startdate).getFullYear() <= new Date().getFullYear()
|
||||
)
|
||||
const facultyQuery = useGetFacultiesQuery()
|
||||
|
||||
const facultyMap = useMemo(
|
||||
() =>
|
||||
facultyQuery.data?.reduce((obj, cur) => {
|
||||
@@ -115,161 +31,77 @@ const LanguageCenterView = () => {
|
||||
[facultyQuery?.data]
|
||||
)
|
||||
|
||||
const { data: rawData, isFetchingOrLoading, isError } = useLanguageCenterData()
|
||||
const { getTextIn } = useLanguage()
|
||||
const [mode, setMode] = useState('total')
|
||||
const semesterQuery = useGetSemestersQuery()
|
||||
const semesters =
|
||||
semesterQuery.isSuccess &&
|
||||
semesterQuery.data?.semesters &&
|
||||
Object.values(semesterQuery.data.semesters).filter(
|
||||
sem => sem.semestercode >= 133 && new Date(sem.startdate).getFullYear() <= new Date().getFullYear()
|
||||
)
|
||||
const [dates, setDates] = useState(null)
|
||||
const [filters, setFilters] = useState({ mode, ...dates })
|
||||
const [faculties, setFaculties] = useState([])
|
||||
const { data, isFetchingOrLoading, isError } = useGetLanguageCenterDataQuery()
|
||||
|
||||
const [numberMode, setNumberMode] = useState('completions')
|
||||
const [colorMode, setColorMode] = useState('course')
|
||||
const [semesterFilter, setSemesterFilter] = useState(null)
|
||||
const [filterEmptyCourses, setFilterEmptyCourses] = useState(true)
|
||||
const history = useHistory()
|
||||
const [tab, setTab] = useTabs('languagecenter_tab', 0, history)
|
||||
|
||||
const selectedSemesters = useMemo(() => {
|
||||
const selectedSemestersArray = []
|
||||
if (semesterFilter?.start && semesterFilter?.end) {
|
||||
for (let i = parseInt(semesterFilter.start, 10); i <= parseInt(semesterFilter.end, 10); i++) {
|
||||
selectedSemestersArray.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(selectedSemestersArray)]
|
||||
}, [semesterFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dates && semesters)
|
||||
setDates({
|
||||
startDate: semesters[0],
|
||||
endDate: semesters[semesters.length - 1],
|
||||
if (!semesters?.length) return
|
||||
if (!semesterFilter)
|
||||
setSemesterFilter({
|
||||
start: semesters[0].semestercode,
|
||||
end: semesters[semesters.length - 1].semestercode,
|
||||
})
|
||||
if (!filters.startDate || !filters.endDate) {
|
||||
setFilters({ ...filters, startDate: semesters[0], endDate: semesters[semesters.length - 1] })
|
||||
}
|
||||
}, [semesters])
|
||||
|
||||
const filterFaculties = data => {
|
||||
return {
|
||||
...data,
|
||||
attempts: data.attempts.filter(attempt => !attempt.faculty || attempt.faculty?.substring(0, 3).match(`^H\\d`)),
|
||||
}
|
||||
const getPanes = () => {
|
||||
return [
|
||||
{
|
||||
menuItem: 'By faculties',
|
||||
render: () => <FacultiesTab />,
|
||||
},
|
||||
{
|
||||
menuItem: 'By semesters',
|
||||
render: () => <SemestersTab />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!rawData) return []
|
||||
const facultyFilteredData = filterFaculties(rawData)
|
||||
|
||||
const filteredAttempts =
|
||||
!filters.startDate || !filters.endDate
|
||||
? facultyFilteredData.attempts
|
||||
: facultyFilteredData.attempts.filter(attempt => filterAttemptsByDates(attempt.date, filters))
|
||||
|
||||
const data = { ...facultyFilteredData, attempts: filteredAttempts }
|
||||
const newFaculties = [...new Set(data.attempts.map(({ faculty }) => faculty))].sort()
|
||||
setFaculties(newFaculties)
|
||||
const courseFaculties = getCourseFaculties(data.attempts)
|
||||
|
||||
const coursesWithFaculties = data.courses
|
||||
.map(c => ({ ...c, facultyStats: courseFaculties[c.code] }))
|
||||
.filter(course => course.facultyStats)
|
||||
const totals = calculateTotals(coursesWithFaculties)
|
||||
const totalRow = row(totals, { ignoreFilters: true, ignoreSorting: true })
|
||||
return [totalRow, ...coursesWithFaculties]
|
||||
}, [rawData, filters])
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
if (isFetchingOrLoading || !rawData || !dates || !semesters) return <Loader active style={{ marginTop: '15em' }} />
|
||||
if (!data || isFetchingOrLoading || !semesterFilter || !semesters?.length || !facultyMap)
|
||||
return <Loader active style={{ marginTop: '15em' }} />
|
||||
|
||||
const settingsContext = {
|
||||
semesterFilter,
|
||||
setSemesterFilter,
|
||||
numberMode,
|
||||
setNumberMode,
|
||||
semesters,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
selectedSemesters,
|
||||
data,
|
||||
facultyMap,
|
||||
filterEmptyCourses,
|
||||
setFilterEmptyCourses,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="languagecenterview">
|
||||
<Divider horizontal>Language center statistics</Divider>
|
||||
<div className="options-container">
|
||||
<div className="datepicker-container">
|
||||
<div className="calendar-icon-container">
|
||||
<Icon size="big" name="calendar alternate outline" />
|
||||
</div>
|
||||
<div className="datepicker-acual-container">
|
||||
<b>From</b>
|
||||
<SemesterSelector
|
||||
setSemester={semester => {
|
||||
setDates({
|
||||
endDate: dates.endDate.semestercode < semester.semestercode ? semester : dates.endDate,
|
||||
startDate: semester,
|
||||
})
|
||||
}}
|
||||
semester={dates?.startDate}
|
||||
allSemesters={semesters}
|
||||
/>
|
||||
<b>Until</b>
|
||||
<SemesterSelector
|
||||
allSemesters={semesters?.filter(s => {
|
||||
return dates.startDate.semestercode <= s.semestercode
|
||||
})}
|
||||
setSemester={semester => setDates({ ...dates, endDate: semester })}
|
||||
semester={dates?.endDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="completion-container">
|
||||
<div className="completion-icon-container" />
|
||||
<b className="options-header">Course completion</b>
|
||||
<div className="completion-acual-container" />
|
||||
<div>
|
||||
<Radio
|
||||
name="modeRadioGroup"
|
||||
value="notCompleted"
|
||||
label="Enrolled but not passed"
|
||||
onChange={() => setMode('notCompleted')}
|
||||
checked={mode === 'notCompleted'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Radio
|
||||
name="modeRadioGroup"
|
||||
value="completed"
|
||||
label="Passed"
|
||||
onChange={() => setMode('completed')}
|
||||
checked={mode === 'completed'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Radio
|
||||
name="modeRadioGroup"
|
||||
value="total"
|
||||
label="Both"
|
||||
onChange={() => setMode('total')}
|
||||
checked={mode === 'total'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-container">
|
||||
<Button
|
||||
disabled={
|
||||
filters.startDate === dates.startDate && filters.endDate === dates.endDate && filters.mode === mode
|
||||
}
|
||||
onClick={() => setFilters({ mode, ...dates })}
|
||||
color="green"
|
||||
>
|
||||
Apply filters
|
||||
</Button>
|
||||
<LanguageCenterContext.Provider value={settingsContext}>
|
||||
<div className="languagecenterview">
|
||||
<Divider horizontal>Language center statistics</Divider>
|
||||
<InfoBox />
|
||||
<div className="languagecenter-table">
|
||||
<Tab panes={getPanes()} activeIndex={tab} onTabChange={setTab} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="languagecenter-table">
|
||||
<SortableTable columns={getColumns(getTextIn, faculties, filters.mode, facultyMap)} data={tableData} stretch />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SemesterSelector = ({ allSemesters, semester, setSemester }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const currentValue =
|
||||
allSemesters.find(({ semestercode }) => semester.semestercode === semestercode) ?? allSemesters[0]
|
||||
const options = useMemo(
|
||||
() => allSemesters.map(s => ({ key: s.semestercode, text: getTextIn(s.name), value: s.semestercode })),
|
||||
[allSemesters]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
onChange={(_, { value }) => setSemester(allSemesters.find(({ semestercode }) => semestercode === value))}
|
||||
value={currentValue.semestercode}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</LanguageCenterContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import useLanguage from 'components/LanguagePicker/useLanguage'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Dropdown, Radio } from 'semantic-ui-react'
|
||||
import { useLanguageCenterContext } from './common'
|
||||
import './index.css'
|
||||
|
||||
export const CompletionPicker = ({ enableRatioOption }) => {
|
||||
const { numberMode, setNumberMode } = useLanguageCenterContext()
|
||||
|
||||
return (
|
||||
<div className="selector-container">
|
||||
<b>Show number of</b>
|
||||
<Radio
|
||||
data-cy="completions-button"
|
||||
name="modeRadioGroup"
|
||||
value="completions"
|
||||
label="Completions"
|
||||
onChange={() => setNumberMode('completions')}
|
||||
checked={numberMode === 'completions'}
|
||||
/>
|
||||
<Radio
|
||||
data-cy="enrollments-button"
|
||||
name="modeRadioGroup"
|
||||
value="enrollments"
|
||||
label="Enrollments"
|
||||
onChange={() => setNumberMode('enrollments')}
|
||||
checked={numberMode === 'enrollments'}
|
||||
/>
|
||||
{enableRatioOption && (
|
||||
<Radio
|
||||
name="modeRadioGroup"
|
||||
value="ratio"
|
||||
data-cy="ratio-button"
|
||||
label="Ratio of completions per enrollments"
|
||||
onChange={() => setNumberMode('ratio')}
|
||||
checked={numberMode === 'ratio'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ColorModeSelector = () => {
|
||||
const { colorMode, setColorMode } = useLanguageCenterContext()
|
||||
|
||||
return (
|
||||
<div className="selector-container">
|
||||
<b>Coloring mode</b>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="course"
|
||||
label="Compare to average of course"
|
||||
onChange={() => setColorMode('course')}
|
||||
checked={colorMode === 'course'}
|
||||
/>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="total"
|
||||
label="Compare to other courses"
|
||||
onChange={() => setColorMode('total')}
|
||||
checked={colorMode === 'total'}
|
||||
/>
|
||||
<Radio
|
||||
name="colorModeGroup"
|
||||
value="none"
|
||||
label="No colors"
|
||||
onChange={() => setColorMode('none')}
|
||||
checked={colorMode === 'none'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SemesterSelector = ({ allSemesters, semester, setSemester, dataCy }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const currentValue = allSemesters.find(({ semestercode }) => semester === semestercode) ?? allSemesters[0]
|
||||
const options = useMemo(
|
||||
() => allSemesters.map(s => ({ key: s.semestercode, text: getTextIn(s.name), value: s.semestercode })),
|
||||
[allSemesters]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="selector-container">
|
||||
<Dropdown
|
||||
data-cy={dataCy}
|
||||
onChange={(_, { value }) =>
|
||||
setSemester(allSemesters.find(({ semestercode }) => semestercode === value).semestercode)
|
||||
}
|
||||
value={currentValue.semestercode}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SemesterRangeSelector = () => {
|
||||
const { semesters, semesterFilter, setSemesterFilter } = useLanguageCenterContext()
|
||||
|
||||
return (
|
||||
<div className="selector-container">
|
||||
<div>
|
||||
<b>From</b>
|
||||
<SemesterSelector
|
||||
dataCy="semester-from"
|
||||
setSemester={semester => {
|
||||
setSemesterFilter({
|
||||
end: semesterFilter.end < semester ? semester : semesterFilter.end,
|
||||
start: semester,
|
||||
})
|
||||
}}
|
||||
semester={semesterFilter?.start}
|
||||
allSemesters={semesters}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>Until</b>
|
||||
{semesters && (
|
||||
<SemesterSelector
|
||||
dataCy="semester-to"
|
||||
allSemesters={semesters?.filter(s => {
|
||||
return semesterFilter.start <= s.semestercode
|
||||
})}
|
||||
setSemester={semester => {
|
||||
setSemesterFilter({ ...semesterFilter, end: semester })
|
||||
}}
|
||||
semester={semesterFilter?.end}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { createContext, useState, useCallback, useContext, useEffect } from 'react'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { LANGUAGE_CODES } from '../../constants'
|
||||
import { DEFAULT_LANG, LANGUAGE_CODES } from '../../constants'
|
||||
import { callApi } from '../../apiConnection'
|
||||
|
||||
const LanguageContext = createContext([[], () => {}])
|
||||
LanguageContext.displayName = 'Language'
|
||||
|
||||
export const LanguageProvider = ({ children }) => {
|
||||
let language = 'fi'
|
||||
let language = DEFAULT_LANG
|
||||
const [state, setState] = useState(language)
|
||||
const user = useGetAuthorizedUserQuery()
|
||||
if (user) {
|
||||
|
||||
@@ -63,7 +63,7 @@ const NavigationBar = () => {
|
||||
if (!checkUserAccess(['facultyStatistics', 'admin'], roles)) return
|
||||
}
|
||||
const { reqRights } = allNavigationItems[key]
|
||||
if (!reqRights || reqRights.every(r => roles.includes(r))) {
|
||||
if (!reqRights || reqRights.every(r => roles.includes(r) || (key === 'teachers' && isAdmin))) {
|
||||
visibleNavigationItems[key] = allNavigationItems[key]
|
||||
}
|
||||
})
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import PassingSemesterRow from './PassingSemesterRow'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import CumulativeCheckbox from './CumulativeCheckbox'
|
||||
import PassingSemestersHeader from './PassingSemestersHeader'
|
||||
|
||||
const PassingSemesters = () => {
|
||||
const { courseStatistics } = UsePopulationCourseContext()
|
||||
const [cumulativeStats, setCumulativeStats] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CumulativeCheckbox cumulativeStats={cumulativeStats} setCumulativeStats={setCumulativeStats} />
|
||||
<Table celled className="fixed-header">
|
||||
<PassingSemestersHeader />
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<PassingSemesterRow key={stats.course.code} statistics={stats} cumulative={cumulativeStats} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassingSemesters
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user