mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637f23a96f | ||
|
|
9b8db2096d | ||
|
|
6d174d0275 | ||
|
|
2e882ff8a0 | ||
|
|
f8a6f89199 | ||
|
|
b272f9e94a | ||
|
|
4cc73d5fd8 | ||
|
|
c19810fca8 | ||
|
|
298c9e9b4b | ||
|
|
58ed0ac417 | ||
|
|
cb40fd970c | ||
|
|
cdc7331667 | ||
|
|
2ffc222bcf | ||
|
|
2fcb5fd91d | ||
|
|
f4be37a3b8 | ||
|
|
67d7b05cc6 | ||
|
|
88ad8db7bb | ||
|
|
dda612f284 | ||
|
|
6a9cf26f4b | ||
|
|
2d7b679b04 | ||
|
|
5aa4c5d146 | ||
|
|
ff9aaa1119 | ||
|
|
f443f3b7db | ||
|
|
a6a47e5ec8 | ||
|
|
b414fd3501 | ||
|
|
88ce7bd2a5 | ||
|
|
eb35ea503d | ||
|
|
20de79a26f | ||
|
|
dab21ab390 | ||
|
|
6069dbc328 | ||
|
|
7cb8041ca0 | ||
|
|
729192cddc | ||
|
|
4b3b10d033 | ||
|
|
9c6812d7cf | ||
|
|
58ce626d97 | ||
|
|
0834b33778 | ||
|
|
cbce1d7dbd | ||
|
|
b8e214f8cd | ||
|
|
69d920015c | ||
|
|
cf64249d4f | ||
|
|
f372e4f362 | ||
|
|
dd619d375d | ||
|
|
0888eaf547 | ||
|
|
14275a3e6f | ||
|
|
956b9302d2 | ||
|
|
51f462ce7b | ||
|
|
a38bd1a3b0 | ||
|
|
316f3511c2 | ||
|
|
e36c7aa6ba | ||
|
|
ffda9af909 | ||
|
|
7b56978e7c | ||
|
|
b95d439836 | ||
|
|
1f7573a0ba | ||
|
|
0204b4890f | ||
|
|
5a19e48ff9 | ||
|
|
790386a394 | ||
|
|
e86045e8e6 | ||
|
|
b1fb0a7cc7 | ||
|
|
0086ae69d3 | ||
|
|
10d9731fab | ||
|
|
83ffd5389e | ||
|
|
fcb4328dba | ||
|
|
f196a0939d | ||
|
|
52a480d6fb | ||
|
|
0618a17830 | ||
|
|
d54b3c0782 | ||
|
|
28fa4e5310 | ||
|
|
df402c4823 | ||
|
|
9136d41bdd | ||
|
|
e1f63c2441 | ||
|
|
d797b766b1 | ||
|
|
03bed43077 | ||
|
|
6763905b2a | ||
|
|
994101732d | ||
|
|
b1c071a507 | ||
|
|
0e2bdc8d2d | ||
|
|
be0f14326a | ||
|
|
1cd6e2f435 | ||
|
|
0790003ae8 | ||
|
|
a77bf8b6a9 | ||
|
|
00cd57dc13 | ||
|
|
44a8f39cc1 | ||
|
|
4ecdb3237d | ||
|
|
6b544179ef | ||
|
|
9e29afedad | ||
|
|
8610bf1084 | ||
|
|
fe49a63408 | ||
|
|
a9100b78ec | ||
|
|
e3fb31ef8f | ||
|
|
f865796771 | ||
|
|
2532b5367e | ||
|
|
eeca5c52fc | ||
|
|
1e940fde73 | ||
|
|
38a2f0a9a8 | ||
|
|
991203e08e | ||
|
|
0c8698ab04 | ||
|
|
57e04434b0 | ||
|
|
f1fcf9661d | ||
|
|
61ab3d9849 | ||
|
|
52db993e1f | ||
|
|
66d4989fee | ||
|
|
0439efddbe | ||
|
|
453511fe5d | ||
|
|
bcaa0086e5 | ||
|
|
80a7072f73 | ||
|
|
33475304e9 | ||
|
|
8bc5c0c3b0 | ||
|
|
b852562bd2 | ||
|
|
6de3dc0bdb | ||
|
|
ef41c4e270 | ||
|
|
bd22b25cca | ||
|
|
0e7757ee3f | ||
|
|
89a8837e16 | ||
|
|
1dc305601f | ||
|
|
e4adda1cb3 | ||
|
|
9dfef1baff | ||
|
|
3599f3f96b | ||
|
|
1114732ac3 | ||
|
|
e2ed53a351 | ||
|
|
a9c354065c | ||
|
|
187ed14d30 | ||
|
|
191646bb99 | ||
|
|
d2e67be9c0 | ||
|
|
2ec2cebf3b | ||
|
|
69a8347a5a | ||
|
|
66b63decb7 | ||
|
|
4943f169f5 | ||
|
|
899421c88e | ||
|
|
a59ed4ce18 | ||
|
|
8e85bf898f | ||
|
|
2bd4cbeda4 | ||
|
|
4d4425147a | ||
|
|
b174cb852c | ||
|
|
2bdcaf7dff | ||
|
|
f27a755240 | ||
|
|
f1e8654f99 | ||
|
|
00a3bfbdac | ||
|
|
55419747d0 | ||
|
|
dfcf0788bd | ||
|
|
b9935a2000 | ||
|
|
a17af20323 | ||
|
|
c5fbc93a9d | ||
|
|
2e1bb6b179 | ||
|
|
7819d67d52 | ||
|
|
0b01e67c5e | ||
|
|
26e2060ad9 | ||
|
|
3751bfae3e | ||
|
|
e6d173674c | ||
|
|
19f7ba45ba | ||
|
|
0b69e7999c | ||
|
|
da678ed29b | ||
|
|
5250742acd | ||
|
|
2b2a8e7e59 | ||
|
|
ff3f170695 | ||
|
|
077a9b4807 | ||
|
|
90c60ffc27 | ||
|
|
38d9361d47 | ||
|
|
7961abc56c | ||
|
|
4c8df9f0c3 | ||
|
|
c35ab6dd5e | ||
|
|
e52a43856e | ||
|
|
1a5395e9d7 | ||
|
|
ff1322611e | ||
|
|
0bbdc9d6c0 | ||
|
|
cb732b60aa | ||
|
|
fb31093ce7 | ||
|
|
a554cf0257 | ||
|
|
2136b574c0 | ||
|
|
abb26bc34b | ||
|
|
ca1f86a4c2 | ||
|
|
ce972d4b0a | ||
|
|
02e4ee0892 | ||
|
|
719299f433 | ||
|
|
8cdefbf342 | ||
|
|
cf1c97b76d | ||
|
|
5af0486032 | ||
|
|
14e9f8d96d | ||
|
|
4a3fce3d1f | ||
|
|
91349936ad | ||
|
|
0ffabf30c3 | ||
|
|
cffad2b05e | ||
|
|
e0cf547246 | ||
|
|
c0ebd66b0d | ||
|
|
9eae226aa3 | ||
|
|
98281ef504 | ||
|
|
90b85bd343 | ||
|
|
bb0dee241f | ||
|
|
0d1f4967de | ||
|
|
1a9a0780d2 | ||
|
|
07b8a67d0e | ||
|
|
c6312c53f8 | ||
|
|
aa40257fc7 | ||
|
|
40e5cfe8af | ||
|
|
0303c741ce | ||
|
|
80bb7ce549 | ||
|
|
cbe21c5bfc |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -8,7 +8,7 @@ To run Oodikone locally, you will need the following:
|
||||
|
||||
1. Applications:
|
||||
- [Docker](https://docs.docker.com/install/) (version 20.10+),
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)(version 0.29+) and
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)(version 1.29+) and
|
||||
- [npm](https://docs.npmjs.com/cli/v7) (version 7+)
|
||||
2. Access to Toska Docker Hub,
|
||||
3. For real data setup, access to Toskas production servers.
|
||||
@@ -48,6 +48,10 @@ What different CLI options do:
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
### Architecture
|
||||
|
||||

|
||||
|
||||
### Basics
|
||||
|
||||
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for oodikone's two main components: **oodikone** and **updater**.
|
||||
|
||||
@@ -20,15 +20,13 @@ describe('Course Statistics tests', () => {
|
||||
|
||||
cy.contains('.tabular.menu a', 'Table').click()
|
||||
cy.contains('All')
|
||||
cy.contains('.modeSelectorRow a', 'Attempts').click()
|
||||
cy.contains('.modeSelectorRow a', 'Students').click()
|
||||
cy.cs('viewMode-Attempts').click()
|
||||
cy.cs('viewMode-Students').click()
|
||||
|
||||
cy.contains('.tabular.menu a', 'Pass rate chart').click()
|
||||
cy.get('div.modeSelectorContainer').click()
|
||||
cy.contains('svg', 'Pass rate chart')
|
||||
|
||||
cy.contains('.tabular.menu a', 'Grade distribution chart').click()
|
||||
cy.get('div.modeSelectorContainer').click()
|
||||
cy.contains('svg', 'Grades')
|
||||
|
||||
cy.contains('a', 'New query').click()
|
||||
@@ -98,7 +96,7 @@ describe('Course Statistics tests', () => {
|
||||
cy.get("input[placeholder='Search by entering a course name']").type('tietokantojen perusteet')
|
||||
|
||||
cy.contains('Tietokantojen perusteet')
|
||||
cy.contains('Avoin yo: Tietokantojen perusteet')
|
||||
cy.contains('Tietokantojen perusteet')
|
||||
cy.contains('A581328')
|
||||
cy.contains('AYTKT10004')
|
||||
cy.contains('TKT10004, 581328')
|
||||
@@ -107,46 +105,39 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT10004, 581328 Tietokantojen perusteet')
|
||||
cy.contains('TKT10004, 581328, AYTKT10004, A581328 Tietokantojen perusteet')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
})
|
||||
|
||||
it('Provider organization toggle works', () => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
cy.get("input[placeholder='Search by entering a course name']").type('tietokantojen perusteet')
|
||||
cy.contains('td', /^AYTKT10004/).click()
|
||||
|
||||
cy.contains('td', /^TKT10004/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('AYTKT10004 Avoin yo: Tietokantojen perusteet')
|
||||
|
||||
cy.contains('TKT10004, 581328, AYTKT10004, A581328 Tietokantojen perusteet')
|
||||
cy.get('[data-cy=unify_radio_reqular]').click()
|
||||
cy.contains('TKT10004, 581328 Tietokantojen perusteet')
|
||||
})
|
||||
|
||||
it('Searching course by name displays right courses, 10 credit courses', () => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
|
||||
|
||||
cy.contains('Tietorakenteet ja algoritmit')
|
||||
cy.contains('Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001')
|
||||
cy.contains('TKT20001, 58131')
|
||||
cy.contains('td', /^TKT20001/).click()
|
||||
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT20001, 58131 Tietorakenteet ja algoritmit')
|
||||
cy.contains('TKT20001, 58131, AYTKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
|
||||
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
|
||||
cy.contains('td', /^AYTKT20001/).click()
|
||||
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('AYTKT20001 Avoin yo: Tietorakenteet ja algoritmit')
|
||||
|
||||
cy.get('.right').click()
|
||||
cy.contains('No results')
|
||||
cy.get("input[name='unifyOpenUniCourses']").parent().click()
|
||||
cy.get("input[placeholder='Search by entering a course name']").type('tietorakenteet ja algoritmit')
|
||||
cy.contains('td', 'TKT20001, 58131, AYTKT20001').click()
|
||||
|
||||
@@ -161,12 +152,11 @@ describe('Course Statistics tests', () => {
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT20003')
|
||||
cy.contains('tr', 'TKT20003').click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
|
||||
cy.contains('Käyttöjärjestelmät')
|
||||
cy.contains('TKT20003')
|
||||
|
||||
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
|
||||
cy.contains('Population of course Unified with open uni: Käyttöjärjestelmät 2019-2020')
|
||||
cy.contains('TKT20003')
|
||||
|
||||
cy.contains('Students (127)').click()
|
||||
@@ -176,12 +166,12 @@ 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.contains(/^TKT20001$/).click()
|
||||
cy.contains(/^TKT20001, AYTKT20001$/).click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
cy.contains('TKT20001, 58131 Tietorakenteet ja algoritmit')
|
||||
cy.contains('TKT20001, 58131, AYTKT20001 Tietorakenteet ja algoritmit')
|
||||
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2018-2019')
|
||||
cy.contains('Students (197)').click()
|
||||
cy.contains('Population of course Unified with open uni: Tietorakenteet ja algoritmit 2018-2019')
|
||||
cy.contains('Students (204)').click()
|
||||
cy.contains('td', '010262566').siblings().contains('4')
|
||||
cy.contains('td', '010674989').siblings().contains('1')
|
||||
})
|
||||
@@ -191,6 +181,7 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains(/^TKT20003$/).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()
|
||||
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click()
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2019-2020')
|
||||
cy.contains('Language distribution').click()
|
||||
@@ -202,7 +193,6 @@ describe('Course Statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
cy.get("input[name='unifyOpenUniCourses']").parent().click()
|
||||
cy.get("input[placeholder='Search by a course code']").type('TKT10002')
|
||||
cy.contains('td', 'TKT10002, AYTKT10002').click()
|
||||
cy.contains('Fetch statistics').should('be.enabled').click()
|
||||
@@ -217,6 +207,16 @@ describe('Course Statistics tests', () => {
|
||||
['Total', 511, 486, 25],
|
||||
]
|
||||
|
||||
const attemptsTableContentsOpen = [
|
||||
// [time, passed, failed, passrate]
|
||||
['Total', 175, 175, 0],
|
||||
]
|
||||
|
||||
const attemptsTableContentsReqular = [
|
||||
// [time, passed, failed, passrate]
|
||||
['Total', 312, 298, 14],
|
||||
]
|
||||
|
||||
const gradesTableContents = [
|
||||
// [time, attempts, 0, 1, 2, 3, 4, 5, other passed]
|
||||
['Total', 511, 25, 11, 17, 21, 59, 365, 13],
|
||||
@@ -273,6 +273,42 @@ describe('Course Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Toggling course provider changes stats correctly', () => {
|
||||
cy.get('[data-cy=unify_radio_reqular]').click()
|
||||
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', 21)
|
||||
})
|
||||
cy.contains('#CourseStatPanes a.item', 'Table').click()
|
||||
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
|
||||
cy.get('#CourseStatPanes table>tbody').within(() => {
|
||||
attemptsTableContentsReqular.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=unify_radio_open]').click()
|
||||
cy.get('#CourseStatPanes table>tbody').within(() => {
|
||||
attemptsTableContentsOpen.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=unify_radio_unify]').click()
|
||||
})
|
||||
|
||||
it('after changing time range shows same stats', () => {
|
||||
const newYearRange = { from: '2016-2017', to: '2019-2020' }
|
||||
cy.get("div[name='fromYear']")
|
||||
@@ -304,7 +340,7 @@ describe('Course Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
it.only('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
|
||||
cy.init('/coursestatistics', 'onlycoursestatistics')
|
||||
cy.get('[data-cy=navbar-courseStatistics]').click()
|
||||
cy.get('[data-cy=course-code-input]').type('TKT20003')
|
||||
@@ -314,12 +350,14 @@ describe('Course Statistics tests', () => {
|
||||
cy.contains('Faculty statistics').should('not.exist')
|
||||
cy.contains('Show population').should('not.exist')
|
||||
|
||||
cy.cs('viewMode-Attempts').click()
|
||||
|
||||
const attemptsTableContents = [
|
||||
// [time, passed, failed, passrate]
|
||||
['Total*', 288, 213, 75, '73.96 %'],
|
||||
['Total', 288, 213, 75, '73.96 %'],
|
||||
// ['Total', 295, 216, 79, '73.22 %'],
|
||||
['2020-2021', '5 or less students', 'NA', 'NA', 'NA'],
|
||||
//['2020-2021', 5, 2, 3, '40.00 %'],
|
||||
// ['2020-2021', 5, 2, 3, '40.00 %'],
|
||||
['2019-2020', 164, 121, 43, '73.78 %'],
|
||||
['2018-2019', 85, 60, 25, '70.59 %'],
|
||||
['2017-2018', 39, 32, 7, '82.05 %'],
|
||||
|
||||
@@ -81,11 +81,11 @@ describe('Population Statistics tests', () => {
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(15000) // a bit hacky way, wait until ui is ready
|
||||
cy.get('[data-cy=expand-TKT1]').click()
|
||||
cy.get('[data-cy=coursestats-link-TKT10002]').click()
|
||||
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 Ohjelmoinnin perusteet')
|
||||
cy.contains('TKT10002, 581325, AYTKT10002, A581325 Ohjelmoinnin perusteet')
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Student Statistics tests', () => {
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
cy.url().should('include', '/students')
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Started: 01.08.2017')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma (01.08.2017 - 01.08.2025')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
@@ -54,7 +54,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.get('.remove').click()
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
@@ -62,7 +62,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can jump to course', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.contains('Introduction to Machine Learning (DATA11002)')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
@@ -82,7 +82,7 @@ describe('Student Statistics tests', () => {
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber).click()
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
cy.get('i.level.up.alternate.icon').eq(0).click()
|
||||
cy.contains('Population statistics')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
|
||||
|
||||
@@ -1,172 +1,354 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const deleteTag = name => {
|
||||
cy.contains(name).siblings().contains('Delete').click()
|
||||
cy.contains('td', name).siblings().contains('Delete').click()
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.contains('Confirm').click()
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.contains('td', name).should('not.exist')
|
||||
}
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const thisYear = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let year = thisYear; year > 2021; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
describe('Studyprogramme overview', () => {
|
||||
// check is used by couple of tests
|
||||
const checkProgressAndProductivity = (user = 'basic') => {
|
||||
// Column amount depends on user. Note that with dev user, you'll see even more
|
||||
// stuff than with admin or basic.
|
||||
const CSBachPopulationProgress2018ForAdmin = [
|
||||
'162',
|
||||
'44 (27%)',
|
||||
'118 (72%)',
|
||||
'161 (99%)',
|
||||
'161',
|
||||
'1',
|
||||
'11',
|
||||
'11',
|
||||
'152',
|
||||
'125',
|
||||
'78',
|
||||
'42',
|
||||
'16',
|
||||
]
|
||||
const CSBachPopulationProgress2018ForBasic = [
|
||||
'162',
|
||||
'44 (27%)',
|
||||
'118 (72%)',
|
||||
'161 (99%)',
|
||||
'161',
|
||||
'1',
|
||||
'11',
|
||||
'11',
|
||||
'152',
|
||||
'125',
|
||||
'78',
|
||||
'42',
|
||||
'16',
|
||||
] // cancelled and transferred from are missing
|
||||
|
||||
const population = user === 'basic' ? CSBachPopulationProgress2018ForBasic : CSBachPopulationProgress2018ForAdmin
|
||||
cy.contains('Population progress')
|
||||
cy.contains('Yearly productivity')
|
||||
|
||||
cy.contains('2018-2019')
|
||||
.siblings()
|
||||
.each((elem, index) => {
|
||||
cy.wrap(elem).contains(population[index])
|
||||
})
|
||||
const populationproductivity2019 = ['7388.0', '17', '7388.00', '0.00', '349.00']
|
||||
cy.get('table')
|
||||
.eq(1)
|
||||
.contains('2019')
|
||||
.siblings()
|
||||
.each((elem, index) => {
|
||||
cy.wrap(elem).contains(populationproductivity2019[index])
|
||||
})
|
||||
}
|
||||
|
||||
describe('when opening programme page with admin user', () => {
|
||||
/* Basic information overview -tests*/
|
||||
describe('Basic information -view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme/KH50_005', 'admin')
|
||||
cy.init('/study-programme')
|
||||
cy.viewport(1536, 960)
|
||||
})
|
||||
|
||||
it('progress should not be recalculating when opened for the first time', () => {
|
||||
cy.contains('Recalculating').should('not.exist')
|
||||
cy.contains('Last updated').should('exist')
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information -tab loads', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimes]')
|
||||
})
|
||||
|
||||
// Following vars are used to compare values before and after stats calculation
|
||||
// Taken from https://docs.cypress.io/api/commands/should#Compare-text-values-of-two-elements
|
||||
const normalizeText = s => s.replace(/\s/g, '').toLowerCase()
|
||||
let originalProgressCalculatedText
|
||||
let originalProductivityCalculatedText
|
||||
const doRecalculation = () => {
|
||||
// Wait for page to load before clicking to admin page, TODO: change to cy.intercept
|
||||
cy.wait(5000)
|
||||
cy.get('.attached').contains('Admin').click()
|
||||
cy.contains('recalculate productivity').click()
|
||||
cy.contains('recalculate throughput').click()
|
||||
cy.get('.attached').contains('Overview').click() // TODO: add data-cy tags for tabs
|
||||
// Wait to "recalculating" to disappear, TODO: change this to cy.intercept
|
||||
cy.wait(5000)
|
||||
cy.reload()
|
||||
}
|
||||
it('Basic information contains correct students', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started, Graduated, Cancelled, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2021, 0, 0, 0, 1, 0],
|
||||
[2020, 12, 36, 16, 3, 2],
|
||||
[2019, 87, 17, 14, 0, 1],
|
||||
[2018, 161, 1, 3, 0, 0],
|
||||
[2017, 171, 0, 0, 0, 0],
|
||||
]
|
||||
|
||||
it('renders progress and productivity tables with calculated status', () => {
|
||||
doRecalculation()
|
||||
|
||||
// check contains also study programme options for admin
|
||||
cy.contains('Study programme options')
|
||||
cy.get('table').should('have.length', 3)
|
||||
|
||||
checkProgressAndProductivity('admin')
|
||||
|
||||
// Grab update dates to be compared later
|
||||
cy.cs('throughputUpdateStatus')
|
||||
.invoke('text')
|
||||
.then(text => {
|
||||
originalProgressCalculatedText = normalizeText(text)
|
||||
expect(originalProgressCalculatedText).not.to.contain('recalculating')
|
||||
expect(originalProgressCalculatedText).not.to.contain('refresh')
|
||||
})
|
||||
|
||||
cy.cs('productivityUpdateStatus')
|
||||
.invoke('text')
|
||||
.then(text => {
|
||||
originalProductivityCalculatedText = normalizeText(text)
|
||||
expect(originalProductivityCalculatedText).not.to.contain('recalculating')
|
||||
expect(originalProgressCalculatedText).not.to.contain('refresh')
|
||||
})
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyprogramme')
|
||||
})
|
||||
|
||||
it('renders progress and productivity tables with calculated status after recalculating stats again', () => {
|
||||
doRecalculation()
|
||||
checkProgressAndProductivity('admin')
|
||||
it('Basic information contains correct credits', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Total, Major credits, Non-major credits, Transferred credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0]),
|
||||
[2021, 0, 0, 0, 0],
|
||||
[2020, 4928, 548, 4213, 167],
|
||||
[2019, 7737, 1644, 5744, 349],
|
||||
[2018, 6730, 1820, 4638, 272],
|
||||
[2017, 2900, 788, 1724, 388],
|
||||
]
|
||||
|
||||
// Check new calculation statuses are reported
|
||||
const newProgressCalculatedTextElement = cy.cs('throughputUpdateStatus').invoke('text')
|
||||
const newProductivityCalculatedTextElement = cy.cs('productivityUpdateStatus').invoke('text')
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
})
|
||||
|
||||
newProgressCalculatedTextElement.should(text => {
|
||||
const newProgressCalculatedText = normalizeText(text)
|
||||
expect(newProgressCalculatedText).not.to.contain('recalculating')
|
||||
expect(newProgressCalculatedText).not.to.contain('refresh')
|
||||
expect(newProgressCalculatedText).not.to.contain(originalProgressCalculatedText)
|
||||
})
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2021, 0, 0],
|
||||
[2020, 36, 35],
|
||||
[2019, 17, 25],
|
||||
[2018, 1, 5],
|
||||
[2017, 0, 0],
|
||||
]
|
||||
|
||||
newProductivityCalculatedTextElement.should(text => {
|
||||
const newProductivityCalculatedText = normalizeText(text)
|
||||
expect(newProductivityCalculatedText).not.to.contain('recalculating')
|
||||
expect(newProductivityCalculatedText).not.to.contain('refresh')
|
||||
expect(newProductivityCalculatedText).not.to.contain(originalProgressCalculatedText)
|
||||
})
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
})
|
||||
|
||||
it.skip('Special studyrights can be excluded and basic data changes accordingly', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started, Graduated]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2021, 0, 0],
|
||||
[2020, 22, 0],
|
||||
[2019, 12, 0],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
|
||||
const creditTableContents = [
|
||||
// [Year, Total, Major students credits, Transferred credits]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2021, 0, 0, 0],
|
||||
[2020, 117.5, 0, 7.5],
|
||||
[2019, 67, 0, 0],
|
||||
[2018, 0, 0, 0],
|
||||
[2017, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2021, 0, 0],
|
||||
[2020, 0, 0],
|
||||
[2019, 0, 0],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started, Graduated, Cancelled, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2021 - 2022', 0, 0, 0, 0, 0],
|
||||
['2020 - 2021', 5, 0, 1, 0, 0],
|
||||
['2019 - 2020', 27, 0, 1, 0, 0],
|
||||
['2018 - 2019', 4, 0, 0, 0, 0],
|
||||
['2017 - 2018', 0, 0, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
|
||||
const creditTableContents = [
|
||||
// [Year, Total, Major students credits, Non-major students credits, Transferred credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0]),
|
||||
['2021 - 2022', 0, 0, 0, 0],
|
||||
['2020 - 2021', 0, 0, 0, 0],
|
||||
['2019 - 2020', 338.5, 0, 331, 7.5],
|
||||
['2018 - 2019', 173, 0, 173, 0],
|
||||
['2017 - 2018', 42, 0, 42, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
|
||||
.should('contain', 'Started')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyprogramme')
|
||||
.should('contain', 'Major students credits')
|
||||
.should('contain', 'Non-major students credits')
|
||||
.should('contain', 'Transferred credits')
|
||||
.should('contain', 788)
|
||||
.should('contain', 1820)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 1)
|
||||
.should('contain', 5)
|
||||
.should('contain', 17)
|
||||
.should('contain', 25)
|
||||
.should('contain', 36)
|
||||
.should('contain', 35)
|
||||
|
||||
cy.get('[data-cy=Graph-2019-AverageGraduationTimes]')
|
||||
.should('contain', '2019')
|
||||
.should('contain', 'n = 17')
|
||||
.should('contain', '22 kk')
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 1)
|
||||
.should('contain', 4)
|
||||
.should('contain', 13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when opening programme page with basic user', () => {
|
||||
/* Studytrack overview -tests*/
|
||||
describe('Basic information -view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme/KH50_005')
|
||||
cy.init('/study-programme')
|
||||
})
|
||||
|
||||
it('renders progress and productivity tables with previosly calculated values', () => {
|
||||
// check doesn't contain study programme options for admin
|
||||
cy.contains('Study programme options').should('not.exist')
|
||||
cy.get('table').should('have.length', 2)
|
||||
checkProgressAndProductivity()
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Studytracks and student populations -tab loads', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
cy.get('[data-cy=Section-StudytrackOverview]')
|
||||
cy.get('[data-cy=Section-StudytrackProgress]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimes]')
|
||||
})
|
||||
|
||||
it('can open Thesis page', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('Thesis Courses').click()
|
||||
cy.contains('Add thesis course').click()
|
||||
cy.contains('No results')
|
||||
it('Students of the studyprogramme are shown correctly', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Currently enrolled, Absent, Cancelled, Graduated, Men, Women, Finnish]
|
||||
[
|
||||
'2020 - 2021',
|
||||
12,
|
||||
'100.0 %',
|
||||
10,
|
||||
'83.3 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
10,
|
||||
'83.3 %',
|
||||
2,
|
||||
'16.7 %',
|
||||
12,
|
||||
'100.0 %',
|
||||
],
|
||||
[
|
||||
'2019 - 2020',
|
||||
86,
|
||||
'100.0 %',
|
||||
86,
|
||||
'100.0 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
1,
|
||||
'1.2 %',
|
||||
1,
|
||||
'1.2 %',
|
||||
65,
|
||||
'75.6 %',
|
||||
21,
|
||||
'24.4 %',
|
||||
86,
|
||||
'100.0 %',
|
||||
],
|
||||
[
|
||||
'2018 - 2019',
|
||||
162,
|
||||
'100.0 %',
|
||||
161,
|
||||
'99.4 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
11,
|
||||
'6.8 %',
|
||||
11,
|
||||
'6.8 %',
|
||||
118,
|
||||
'72.8 %',
|
||||
44,
|
||||
'27.2 %',
|
||||
161,
|
||||
'99.4 %',
|
||||
],
|
||||
[
|
||||
'2017 - 2018',
|
||||
171,
|
||||
'100.0 %',
|
||||
171,
|
||||
'100.0 %',
|
||||
0,
|
||||
'0 %',
|
||||
0,
|
||||
'0 %',
|
||||
21,
|
||||
'12.3 %',
|
||||
42,
|
||||
'24.6 %',
|
||||
135,
|
||||
'78.9 %',
|
||||
36,
|
||||
'21.1 %',
|
||||
166,
|
||||
'97.1 %',
|
||||
],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackOverview')
|
||||
})
|
||||
|
||||
it('can move to Population statistics page by clickin', () => {
|
||||
cy.get('i.level.up.alternate.icon').eq(0).click()
|
||||
cy.contains('Students (10)')
|
||||
it('Student progress data is shown correctly', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
const tableContents = [
|
||||
['2020 - 2021', 12, 5, 2, 2, 0, 2, 1],
|
||||
['2019 - 2020', 86, 13, 15, 32, 11, 2, 13],
|
||||
['2018 - 2019', 162, 7, 16, 35, 33, 27, 44],
|
||||
['2017 - 2018', 171, 10, 22, 18, 22, 24, 75],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackProgress')
|
||||
})
|
||||
|
||||
it('Studytrack overview graphs render', () => {
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and student populations').click()
|
||||
|
||||
cy.get('[data-cy=Graph-StudytrackProgress]')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30-59 credits')
|
||||
.should('contain', 'More than 150 credits')
|
||||
.should('contain', '5.8%')
|
||||
.should('contain', '4.3%')
|
||||
.should('contain', '15.1%')
|
||||
|
||||
cy.get('[data-cy=Graph-2018-AverageGraduationTimes]')
|
||||
.should('contain', '2018 - 2019')
|
||||
.should('contain', 'n = 11 / 162')
|
||||
.should('contain', '22 kk')
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests*/
|
||||
describe('Basic information -view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -178,7 +360,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -194,7 +376,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
const student = '010113437'
|
||||
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2018')
|
||||
@@ -202,7 +384,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains(name)
|
||||
|
||||
cy.contains('Add tags to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
@@ -211,7 +393,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
cy.contains('Student statistics').click()
|
||||
cy.get('.prompt').type(student)
|
||||
cy.contains(student).click()
|
||||
cy.contains('a', student).click()
|
||||
cy.contains(name)
|
||||
|
||||
cy.go('back')
|
||||
@@ -221,7 +403,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
cy.contains('Student statistics').click()
|
||||
cy.get('.prompt').type(student)
|
||||
cy.contains(student).click()
|
||||
cy.contains('a', student).click()
|
||||
cy.contains(name).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Teachers page tests', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.cs('course-providers').children('.icon').click()
|
||||
cy.get('.form > .fluid').click()
|
||||
cy.contains('Name')
|
||||
cy.contains('Teacher')
|
||||
cy.contains(teacher1)
|
||||
cy.contains(teacher2)
|
||||
})
|
||||
@@ -32,21 +32,13 @@ describe('Teachers page tests', () => {
|
||||
cy.url().should('include', '/teachers')
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher1.split()[0])
|
||||
cy.contains(teacher1).click()
|
||||
cy.contains('td', teacher1).click()
|
||||
cy.contains('Aineopintojen harjoitustyö: Tietokantasovellus')
|
||||
})
|
||||
|
||||
it('Check leaderboad works', () => {
|
||||
cy.get('.borderless > :nth-child(2)').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('2019-2020').click({ force: true })
|
||||
cy.contains('Recalculate this year').click({ force: true })
|
||||
cy.wait(5000)
|
||||
cy.reload()
|
||||
cy.contains('2019-2020').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('Passed')
|
||||
cy.contains('Ahokas Jarmo Ilkka')
|
||||
cy.contains('2020-2021').click({ force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,9 +39,7 @@ describe('Users tests', () => {
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
cy.contains('mocking').should('not.exist')
|
||||
cy.contains('tr', 'basic').within(() => {
|
||||
cy.contains('.button', 'Edit').click()
|
||||
})
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.contains('mocking as basic')
|
||||
cy.wait(1000)
|
||||
|
||||
@@ -104,3 +104,18 @@ Cypress.Commands.add('selectStudyProgramme', name => {
|
||||
cy.cs('select-study-programme').click().children().contains(name).click()
|
||||
cy.contains('See population').click()
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get(`[data-cy=Table-${tableName}]>tbody`).within(() => {
|
||||
correctStats.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('tr').should('have.length', correctStats.length)
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+3
-3
@@ -1003,9 +1003,9 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
|
||||
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw=="
|
||||
"version": "1.14.8",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
|
||||
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
|
||||
@@ -16,7 +16,7 @@ class DbConnection extends EventEmitter {
|
||||
pool: {
|
||||
max: 25,
|
||||
min: 0,
|
||||
acquire: 10000,
|
||||
acquire: 30000,
|
||||
idle: 300000000,
|
||||
},
|
||||
logging: false,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.dropTable('thesis_courses')
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -10,10 +10,8 @@ const {
|
||||
} = require('./services/trends')
|
||||
const { refreshAssociationsInRedis } = require('./services/studyrights')
|
||||
const { getAllProgrammes } = require('./services/studyrights')
|
||||
const { productivityStatsForStudytrack, throughputStatsForStudytrack } = require('./services/studyprogramme')
|
||||
const { updateBasicView, updateStudytrackView } = require('./services/studyprogrammeUpdates')
|
||||
const { findAndSaveTeachers } = require('./services/topteachers')
|
||||
const { setProductivity, setThroughput, patchProductivity, patchThroughput } = require('./services/analyticsService')
|
||||
const { isProduction } = require('./conf-backend')
|
||||
const { getCurrentSemester } = require('./services/semesters')
|
||||
const logger = require('./util/logger')
|
||||
@@ -25,49 +23,6 @@ const refreshStudyrightAssociations = async () => {
|
||||
logger.info('Studyright associations refreshed!')
|
||||
}
|
||||
|
||||
const refreshOverview = async () => {
|
||||
logger.info('Refreshing throughput and productivity for programmes...')
|
||||
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH') || code.includes('MH'))
|
||||
let ready = 0
|
||||
for (const code of codes) {
|
||||
let programmeStatsSince = new Date('2017-07-31')
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
programmeStatsSince = new Date('2017-07-31')
|
||||
} else {
|
||||
programmeStatsSince = new Date('2000-07-31')
|
||||
}
|
||||
try {
|
||||
await patchThroughput({ [code]: { status: 'RECALCULATING' } })
|
||||
const data = await throughputStatsForStudytrack(code, programmeStatsSince.getFullYear())
|
||||
await setThroughput(data)
|
||||
} catch (e) {
|
||||
logger.error({ message: `failed to recalculate throughput for ${code}`, meta: e })
|
||||
try {
|
||||
await patchThroughput({ [code]: { status: 'RECALCULATION ERRORED' } })
|
||||
} catch (e) {
|
||||
logger.error({ message: 'failed to update throughtput status to error', meta: e })
|
||||
}
|
||||
}
|
||||
try {
|
||||
await patchProductivity({ [code]: { status: 'RECALCULATING' } })
|
||||
const data = await productivityStatsForStudytrack(code, programmeStatsSince)
|
||||
await setProductivity(data)
|
||||
} catch (e) {
|
||||
logger.error({ message: `failed to recalculate productivity for ${code}`, meta: e })
|
||||
try {
|
||||
await patchProductivity({
|
||||
[code]: { status: 'RECALCULATION ERRORED' },
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error({ message: 'failed to update productivity status to error', meta: e })
|
||||
}
|
||||
}
|
||||
ready += 1
|
||||
logger.info(`${ready}/${codes.length} programmes done`)
|
||||
}
|
||||
logger.info('Throughput and productivity for programmes refreshed')
|
||||
}
|
||||
|
||||
const refreshNewOverviews = async () => {
|
||||
logger.info('Refreshing studyprogramme and studytrack overview statistics for all programmes')
|
||||
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH') || code.includes('MH'))
|
||||
@@ -165,7 +120,7 @@ const refreshProtoCProgrammeToRedis = async () => {
|
||||
}
|
||||
|
||||
const refreshStatistics = async () => {
|
||||
const statfuncs = [refreshStudyrightAssociations, refreshOverview, refreshTeacherLeaderboard]
|
||||
const statfuncs = [refreshStudyrightAssociations, refreshTeacherLeaderboard]
|
||||
logger.info('Refreshing statistics')
|
||||
for (const func of statfuncs) {
|
||||
await func()
|
||||
|
||||
@@ -104,20 +104,6 @@ const MandatoryCourseLabels = sequelizeKone.define('mandatory_course_labels', {
|
||||
},
|
||||
})
|
||||
|
||||
const ThesisCourse = sequelizeKone.define('thesis_courses', {
|
||||
programmeCode: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
courseCode: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
thesisType: {
|
||||
type: Sequelize.ENUM([ThesisTypeEnums.BACHELOR, ThesisTypeEnums.MASTER]),
|
||||
},
|
||||
})
|
||||
|
||||
const CustomPopulationSearch = sequelizeKone.define('custom_population_searches', {
|
||||
id: {
|
||||
primaryKey: true,
|
||||
@@ -173,7 +159,6 @@ module.exports = {
|
||||
MandatoryCourse,
|
||||
TagStudent,
|
||||
Tag,
|
||||
ThesisCourse,
|
||||
ThesisTypeEnums,
|
||||
MandatoryCourseLabels,
|
||||
CustomPopulationSearch,
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const MandatoryCourses = require('../services/mandatoryCourses')
|
||||
const {
|
||||
productivityStatsForStudytrack,
|
||||
throughputStatsForStudytrack,
|
||||
optionData,
|
||||
} = require('../services/studyprogramme')
|
||||
const { findProgrammeTheses, createThesisCourse, deleteThesisCourse } = require('../services/thesis')
|
||||
const {
|
||||
getProductivity,
|
||||
setProductivity,
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchProductivity,
|
||||
patchThroughput,
|
||||
} = require('../services/analyticsService')
|
||||
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const programmeStatsSince = new Date('2017-07-31')
|
||||
|
||||
router.get('/elementdetails/all', async (req, res) => {
|
||||
const elementdetails = await getAllElementDetails()
|
||||
@@ -29,22 +12,6 @@ router.get('/studyprogrammes', async (req, res) => {
|
||||
res.json(studyrights)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/optiondata', async (req, res) => {
|
||||
const code = req.params.id
|
||||
|
||||
let level
|
||||
if (code.includes('MH')) {
|
||||
level = 'MSC'
|
||||
} else if (code.includes('KH')) {
|
||||
level = 'BSC'
|
||||
} else {
|
||||
return res.json([])
|
||||
}
|
||||
|
||||
const data = await optionData(new Date('2017-07-31'), new Date(), code, level)
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
|
||||
if (req.params.id) {
|
||||
const codes = await MandatoryCourses.byStudyprogramme(req.params.id)
|
||||
@@ -54,138 +21,4 @@ router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/productivity', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getProductivity(code)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} productivity`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const stats = await productivityStatsForStudytrack(code, programmeStatsSince)
|
||||
data = await setProductivity(stats)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
|
||||
}
|
||||
}
|
||||
return res.json(data)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/productivity/recalculate', async (req, res) => {
|
||||
const code = req.query.code
|
||||
|
||||
await patchThroughput({ [code]: { status: 'RECALCULATING' } })
|
||||
res.status(200).end()
|
||||
|
||||
try {
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
const data = await productivityStatsForStudytrack(code, new Date('2017-07-31'))
|
||||
await setProductivity(data)
|
||||
} else {
|
||||
const data = await productivityStatsForStudytrack(code, new Date('2000-07-31'))
|
||||
await setProductivity(data)
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
await patchProductivity({
|
||||
[code]: { status: 'RECALCULATION ERRORED' },
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
|
||||
return
|
||||
}
|
||||
|
||||
logger.error(`Failed to update productivity stats for code: ${code}, reason: ${e.message}`)
|
||||
}
|
||||
logger.info(`Produdtiviy stats recalculation for studyprogramme ${code} done`)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/throughput', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (code) {
|
||||
const thesisPromise = findProgrammeTheses(req.params.id)
|
||||
|
||||
let data = null
|
||||
try {
|
||||
data = await getThroughput(code)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} throughput`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await throughputStatsForStudytrack(req.params.id, programmeStatsSince.getFullYear())
|
||||
data = await setThroughput(result)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
|
||||
}
|
||||
}
|
||||
return res.json({ ...data, thesis: await thesisPromise })
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/throughput/recalculate', async (req, res) => {
|
||||
const code = req.query.code
|
||||
|
||||
await patchThroughput({ [code]: { status: 'RECALCULATING' } })
|
||||
res.status(200).end()
|
||||
|
||||
try {
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
const data = await throughputStatsForStudytrack(code, 2017)
|
||||
await setThroughput(data)
|
||||
} else {
|
||||
const data = await throughputStatsForStudytrack(code, 2000)
|
||||
await setThroughput(data)
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
await patchThroughput({ [code]: { status: 'RECALCULATION ERRORED' } })
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} patching status to recalculation erroring`)
|
||||
return
|
||||
}
|
||||
logger.error(`Failed to update throughput stats for code: ${code}, reason: ${e.message}`)
|
||||
}
|
||||
logger.info(`Throughput stats recalculation for studyprogramme ${code} done`)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/thesis', async (req, res) => {
|
||||
const { id } = req.params
|
||||
if (id) {
|
||||
const thesis = await findProgrammeTheses(id)
|
||||
res.json(thesis)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/v2/studyprogrammes/:id/thesis', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { course, thesisType } = req.body
|
||||
if (id && course && thesisType) {
|
||||
const thesis = await createThesisCourse(id, course, thesisType)
|
||||
res.status(201).json(thesis)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/v2/studyprogrammes/:id/thesis/:course', async (req, res) => {
|
||||
const { id, course } = req.params
|
||||
if (id && course) {
|
||||
const deleted = await deleteThesisCourse(id, course)
|
||||
res.status(204).json(deleted)
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const router = require('express').Router()
|
||||
const {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
} = require('../services/studyprogrammeStats')
|
||||
const { getBasicStatsForStudytrack } = require('../services/studyprogrammeBasics')
|
||||
const { getCreditStatsForStudytrack } = require('../services/studyprogrammeCredits')
|
||||
const { getGraduationStatsForStudytrack } = require('../services/studyprogrammeGraduations')
|
||||
const { getStudytrackStatsForStudyprogramme } = require('../services/studytrackStats')
|
||||
const {
|
||||
getBasicStats,
|
||||
@@ -21,11 +19,12 @@ const logger = require('../util/logger')
|
||||
router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
const specialGroups = req.query?.special_groups
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getBasicStats(code, yearType)
|
||||
data = await getBasicStats(code, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} basic stats: ${e}`)
|
||||
}
|
||||
@@ -33,9 +32,12 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getBasicStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setBasicStats(result, yearType)
|
||||
data = await setBasicStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} basic stats: ${e}`)
|
||||
}
|
||||
@@ -49,11 +51,12 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
const specialGroups = req.query?.special_groups
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getCreditStats(code, yearType)
|
||||
data = await getCreditStats(code, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} credit stats`)
|
||||
}
|
||||
@@ -61,9 +64,12 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getCreditStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setCreditStats(result, yearType)
|
||||
data = await setCreditStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} credit stats`)
|
||||
}
|
||||
@@ -77,11 +83,12 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const yearType = req.query?.year_type
|
||||
const specialGroups = req.query?.special_groups
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getGraduationStats(code, yearType)
|
||||
data = await getGraduationStats(code, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} graduation stats`)
|
||||
}
|
||||
@@ -89,9 +96,12 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
try {
|
||||
const result = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
yearType,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
data = await setGraduationStats(result, yearType)
|
||||
data = await setGraduationStats(result, yearType, specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} graduation stats`)
|
||||
}
|
||||
@@ -104,18 +114,28 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/studytrackstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const graduated = req.query?.graduated
|
||||
const specialGroups = req.query?.special_groups
|
||||
const yearsCombined = req.query?.years_combined
|
||||
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getStudytrackStats(code)
|
||||
data = await getStudytrackStats(code, graduated, specialGroups, yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} studytrack stats: ${e}`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
const result = await getStudytrackStatsForStudyprogramme({ studyprogramme: req.params.id })
|
||||
data = await setStudytrackStats(result)
|
||||
const result = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
graduated: graduated === 'GRADUATED_INCLUDED',
|
||||
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
|
||||
yearsCombined: yearsCombined === 'YEARS_COMBINED',
|
||||
},
|
||||
})
|
||||
data = await setStudytrackStats(result, graduated, specialGroups, yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} studytrack stats: ${e}`)
|
||||
}
|
||||
|
||||
@@ -1,106 +1,23 @@
|
||||
const { redisClient } = require('../services/redis')
|
||||
const moment = require('moment')
|
||||
|
||||
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
|
||||
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
|
||||
const createRedisKeyForBasicStats = (id, yearType) => `BASIC_STATS_${id}_${yearType}`
|
||||
const createRedisKeyForCreditStats = (id, yearType) => `CREDIT_STATS_${id}_${yearType}`
|
||||
const createRedisKeyForGraduationStats = (id, yearType) => `GRADUATION_STATS_${id}_${yearType}`
|
||||
const createRedisKeyForStudytrackStats = id => `STUDYTRACK_STATS_${id}`
|
||||
const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForCreditStats = (id, yearType, specialGroups) => `CREDIT_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForGraduationStats = (id, yearType, specialGroups) =>
|
||||
`GRADUATION_STATS_${id}_${yearType}_${specialGroups}`
|
||||
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups, yearsCombined) =>
|
||||
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}_${yearsCombined}`
|
||||
|
||||
const getProductivity = async id => {
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return {
|
||||
[id]: JSON.parse(dataFromRedis),
|
||||
}
|
||||
}
|
||||
|
||||
const setProductivity = async data => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return {
|
||||
[id]: dataToRedis,
|
||||
}
|
||||
}
|
||||
|
||||
const patchProductivity = async data => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
const dataFromRedis = JSON.parse(await redisClient.getAsync(redisKey))
|
||||
const patchedData = {
|
||||
...dataFromRedis,
|
||||
...data,
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return {
|
||||
[id]: patchedData,
|
||||
}
|
||||
}
|
||||
|
||||
const getThroughput = async id => {
|
||||
const redisKey = createRedisKeyForThroughput(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return {
|
||||
[id]: JSON.parse(dataFromRedis),
|
||||
}
|
||||
}
|
||||
|
||||
const setThroughput = async data => {
|
||||
const { id, data: dataToSet } = data
|
||||
const redisKey = createRedisKeyForThroughput(id)
|
||||
const dataToRedis = {
|
||||
data: dataToSet.years,
|
||||
totals: dataToSet.totals,
|
||||
stTotals: dataToSet.stTotals,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return {
|
||||
[id]: dataToRedis,
|
||||
}
|
||||
}
|
||||
|
||||
const patchThroughput = async data => {
|
||||
const [id, dataToPatch] = Object.entries(data)[0]
|
||||
const redisKey = createRedisKeyForThroughput(id)
|
||||
const { status } = dataToPatch
|
||||
const dataFromRedis = JSON.parse(await redisClient.getAsync(redisKey))
|
||||
const patchedData = {
|
||||
...dataFromRedis,
|
||||
status,
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return {
|
||||
[id]: patchedData,
|
||||
}
|
||||
}
|
||||
|
||||
const getBasicStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType)
|
||||
const getBasicStats = async (id, yearType, specialGroups) => {
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setBasicStats = async (data, yearType) => {
|
||||
const setBasicStats = async (data, yearType, specialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType)
|
||||
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
@@ -111,16 +28,16 @@ const setBasicStats = async (data, yearType) => {
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getCreditStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType)
|
||||
const getCreditStats = async (id, yearType, specialGroups) => {
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType, specialGroups)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setCreditStats = async (data, yearType) => {
|
||||
const setCreditStats = async (data, yearType, specialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType)
|
||||
const redisKey = createRedisKeyForCreditStats(id, yearType, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
@@ -131,16 +48,16 @@ const setCreditStats = async (data, yearType) => {
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getGraduationStats = async (id, yearType) => {
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType)
|
||||
const getGraduationStats = async (id, yearType, specialGroups) => {
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType, specialGroups)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setGraduationStats = async (data, yearType) => {
|
||||
const setGraduationStats = async (data, yearType, specialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType)
|
||||
const redisKey = createRedisKeyForGraduationStats(id, yearType, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
@@ -151,16 +68,16 @@ const setGraduationStats = async (data, yearType) => {
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getStudytrackStats = async id => {
|
||||
const redisKey = createRedisKeyForStudytrackStats(id)
|
||||
const getStudytrackStats = async (id, graduated, specialGroups, yearsCombined) => {
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setStudytrackStats = async data => {
|
||||
const setStudytrackStats = async (data, graduated, specialGroups, yearsCombined) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForStudytrackStats(id)
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
@@ -172,12 +89,6 @@ const setStudytrackStats = async data => {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProductivity,
|
||||
setProductivity,
|
||||
patchProductivity,
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchThroughput,
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
|
||||
@@ -59,10 +59,12 @@ const findOneByCode = code => {
|
||||
})
|
||||
}
|
||||
|
||||
const creditsForCourses = async (codes, anonymizationSalt, unifyOpenUniCourses) => {
|
||||
const creditsForCourses = async (codes, anonymizationSalt, unification) => {
|
||||
let is_open = false
|
||||
|
||||
if (unifyOpenUniCourses) {
|
||||
if (unification === 'open') is_open = true
|
||||
|
||||
if (unification === 'unify') {
|
||||
is_open = {
|
||||
[Op.in]: [false, true],
|
||||
}
|
||||
@@ -162,23 +164,19 @@ const allCodeAlternatives = async code => {
|
||||
return sortMainCode([...course.substitutions, code])
|
||||
}
|
||||
|
||||
const yearlyStatsOfNew = async (coursecode, separate, unifyOpenUniCourses, anonymizationSalt) => {
|
||||
const yearlyStatsOfNew = async (coursecode, separate, unification, anonymizationSalt) => {
|
||||
const courseForSubs = await Course.findOne({
|
||||
where: { code: coursecode },
|
||||
})
|
||||
|
||||
let codes = courseForSubs.substitutions ? sortMainCode([...courseForSubs.substitutions, coursecode]) : [coursecode]
|
||||
|
||||
if (isOpenUniCourseCode(coursecode) && !unifyOpenUniCourses) {
|
||||
codes = [coursecode]
|
||||
}
|
||||
|
||||
if (!isOpenUniCourseCode(coursecode) && !unifyOpenUniCourses) {
|
||||
codes = codes.filter(code => !code.match(/^[A][0-9]|^AY/))
|
||||
if (unification === 'reqular') {
|
||||
codes = codes.filter(course => !isOpenUniCourseCode(course))
|
||||
}
|
||||
|
||||
const [credits, course] = await Promise.all([
|
||||
creditsForCourses(codes, anonymizationSalt, unifyOpenUniCourses),
|
||||
creditsForCourses(codes, anonymizationSalt, unification),
|
||||
Course.findOne({
|
||||
where: {
|
||||
code: coursecode,
|
||||
@@ -234,6 +232,7 @@ const yearlyStatsOfNew = async (coursecode, separate, unifyOpenUniCourses, anony
|
||||
counter.markCreditToStudentCategories(studentnumber, passed, attainment_date, groupcode)
|
||||
}
|
||||
const statistics = counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode,
|
||||
@@ -292,10 +291,19 @@ const maxYearsToCreatePopulationFrom = async coursecodes => {
|
||||
}
|
||||
|
||||
const courseYearlyStats = async (coursecodes, separate, unifyOpenUniCourses, anonymizationSalt) => {
|
||||
const stats = await Promise.all(
|
||||
coursecodes.map(code => yearlyStatsOfNew(code, separate, unifyOpenUniCourses, anonymizationSalt))
|
||||
const [unify, reqular, open] = ['unify', 'reqular', 'open']
|
||||
|
||||
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)
|
||||
|
||||
return { unifyStats, reqularStats, openStats }
|
||||
})
|
||||
)
|
||||
return stats
|
||||
|
||||
return statsReqular
|
||||
}
|
||||
|
||||
const nameLikeTerm = name => {
|
||||
|
||||
@@ -3,7 +3,7 @@ const { isProduction, pateToken } = require('../conf-backend')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const pateClient = axios.create({
|
||||
baseURL: 'https://pate.toska.cs.helsinki.fi',
|
||||
baseURL: 'https://importer.cs.helsinki.fi/api/pate/',
|
||||
params: {
|
||||
token: pateToken,
|
||||
},
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
const sequelize = require('sequelize')
|
||||
const { Op } = sequelize
|
||||
const {
|
||||
Credit,
|
||||
Course,
|
||||
Organization,
|
||||
Studyright,
|
||||
StudyrightElement,
|
||||
ElementDetail,
|
||||
Transfer,
|
||||
Student,
|
||||
SemesterEnrollment,
|
||||
Semester,
|
||||
} = require('../models')
|
||||
const { formatStudyright, formatStudent } = require('./studyprogrammeHelpers')
|
||||
|
||||
const whereStudents = studentnumbers => {
|
||||
return studentnumbers ? studentnumbers : { [Op.not]: null }
|
||||
}
|
||||
|
||||
const studytrackStudents = async studentnumbers =>
|
||||
(
|
||||
await Student.findAll({
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(formatStudent)
|
||||
|
||||
const enrolledStudyrights = async studytrack => {
|
||||
const studyrights = await Studyright.findAll({
|
||||
attributes: ['studyrightid', 'studystartdate', 'enddate', 'prioritycode'],
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: { code: studytrack },
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const studentnumbers = studyrights.map(s => s.student.studentnumber)
|
||||
|
||||
const students = await Student.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: Studyright,
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
],
|
||||
attributes: ['studyrightid'],
|
||||
},
|
||||
{
|
||||
model: SemesterEnrollment,
|
||||
attributes: ['semestercode'],
|
||||
include: [
|
||||
{
|
||||
model: Semester,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
where: {
|
||||
enrollmenttype: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let enrollments = new Set()
|
||||
|
||||
students.forEach(student => {
|
||||
student.semester_enrollments.forEach(e => {
|
||||
const enrollmentStartDate = e.semester.startdate
|
||||
const enrollmentEndDate = e.semester.enddate
|
||||
const correctStudyRight = studyrights.find(
|
||||
s =>
|
||||
student.studentnumber === s.student.studentnumber &&
|
||||
s.studystartdate <= enrollmentStartDate && // Studying in the programme should have started on the same day or earlier than the enrollment
|
||||
s.enddate >= enrollmentEndDate && // Studying in the programme should have ended on the same day or later than the enrollment
|
||||
(!s.canceldate || s.canceldate > enrollmentStartDate) && // Studyright should not be canceled OR the possible canceldate should be after the enrollmentStartDate
|
||||
(s.prioritycode === 1 || s.prioritycode === 30) // Studyright should be primary or the student should have graduated
|
||||
)
|
||||
if (correctStudyRight) {
|
||||
enrollments.add({
|
||||
studentnumber: student.studentnumber,
|
||||
startdate: e.semester.startdate,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return enrollments
|
||||
}
|
||||
|
||||
const startedStudyrights = async (studytrack, since, studentnumbers) =>
|
||||
(
|
||||
await Studyright.findAll({
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
where: {
|
||||
studystartdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
student_studentnumber: whereStudents(studentnumbers),
|
||||
},
|
||||
})
|
||||
).map(formatStudyright)
|
||||
|
||||
const graduatedStudyRights = async (studytrack, since, studentnumbers) =>
|
||||
await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
graduated: 1,
|
||||
enddate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
student_studentnumber: whereStudents(studentnumbers),
|
||||
},
|
||||
})
|
||||
|
||||
const cancelledStudyRights = async (studytrack, since) => {
|
||||
return await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.eq]: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
canceldate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const transfersAway = async (studytrack, since) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
sourcecode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const transfersTo = async (studytrack, since) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
targetcode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const getProgrammesStudents = async studyprogramme =>
|
||||
(
|
||||
await Studyright.findAll({
|
||||
attributes: ['studyrightid', 'studystartdate', 'enddate', 'graduated', 'prioritycode', 'extentcode'],
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: [studyprogramme],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
).map(formatStudyright)
|
||||
|
||||
const getCreditsForStudyProgramme = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.notIn]: [10, 9, 7],
|
||||
},
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getTransferredCredits = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.eq]: [9],
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getThesisCredits = async (provider, since, thesisType) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
course_unit_type: {
|
||||
[Op.in]: thesisType,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.notIn]: [10, 9, 7],
|
||||
},
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
studytrackStudents,
|
||||
enrolledStudyrights,
|
||||
startedStudyrights,
|
||||
graduatedStudyRights,
|
||||
cancelledStudyRights,
|
||||
transfersAway,
|
||||
transfersTo,
|
||||
getProgrammesStudents,
|
||||
getCreditsForStudyProgramme,
|
||||
getTransferredCredits,
|
||||
getThesisCredits,
|
||||
}
|
||||
@@ -363,8 +363,10 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
tag
|
||||
transferredOutStudents,
|
||||
tag,
|
||||
transferredToStudents,
|
||||
graduatedStudents
|
||||
) => {
|
||||
// eslint-disable-line
|
||||
|
||||
@@ -497,11 +499,11 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
return studyRights.includes(newestStudytrack.code)
|
||||
})
|
||||
|
||||
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
let studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
|
||||
// fetch students that have transferred out of the programme and filter out these studentnumbers
|
||||
if (!transferredStudents) {
|
||||
const transferredOutStudents = (
|
||||
if (!transferredOutStudents) {
|
||||
const transfersOut = (
|
||||
await Transfer.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: {
|
||||
@@ -519,8 +521,63 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
})
|
||||
).map(s => s.studentnumber)
|
||||
|
||||
const notTransferredStudents = studentnumberlist.filter(sn => !transferredOutStudents.includes(sn))
|
||||
return notTransferredStudents
|
||||
studentnumberlist = studentnumberlist.filter(sn => !transfersOut.includes(sn))
|
||||
}
|
||||
|
||||
// fetch students that have transferred to the programme and filter out these studentnumbers
|
||||
if (transferredToStudents) {
|
||||
const transfersTo = (
|
||||
await Transfer.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: {
|
||||
targetcode: {
|
||||
[Op.in]: studyRights,
|
||||
},
|
||||
transferdate: {
|
||||
[Op.gt]: startDate,
|
||||
},
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumberlist,
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(s => s.studentnumber)
|
||||
studentnumberlist = studentnumberlist.filter(sn => !transfersTo.includes(sn))
|
||||
}
|
||||
|
||||
// fetch students that have graduated from the programme and filter out these studentnumbers
|
||||
if (graduatedStudents) {
|
||||
const graduated = (
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
include: [
|
||||
{
|
||||
model: Studyright,
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: studyRights,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
graduated: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map(s => s.studentnumber)
|
||||
studentnumberlist = studentnumberlist.filter(sn => !graduated.includes(sn))
|
||||
}
|
||||
|
||||
return studentnumberlist
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const moment = require('moment')
|
||||
const sequelize = require('sequelize')
|
||||
const { Semester } = require('../models')
|
||||
const Op = sequelize.Op
|
||||
@@ -28,8 +29,8 @@ const getSemestersAndYears = async () => {
|
||||
acc.years[yearcode] = {
|
||||
yearcode,
|
||||
yearname,
|
||||
startdate: Math.min(acc.years[yearcode].startdate, startdate),
|
||||
enddate: Math.max(acc.years[yearcode].enddate, enddate),
|
||||
startdate: moment.min(moment(acc.years[yearcode].startdate), moment(startdate)),
|
||||
enddate: moment.max(moment(acc.years[yearcode].enddate), moment(enddate)),
|
||||
}
|
||||
}
|
||||
return acc
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
const { indexOf } = require('lodash')
|
||||
const {
|
||||
getCorrectStudentnumbers,
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
getYearsArray,
|
||||
getStartDate,
|
||||
tableTitles,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
allStudyrights,
|
||||
graduatedStudyRights,
|
||||
startedStudyrights,
|
||||
transfersAway,
|
||||
transfersTo,
|
||||
} = require('./studyprogramme')
|
||||
const { getYearStartAndEndDates } = require('../util/semester')
|
||||
|
||||
const getStartedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
|
||||
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
|
||||
codes: [studyprogramme],
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials,
|
||||
})
|
||||
const studyrights = await startedStudyrights(studyprogramme, since, studentnumbersOfTheYear)
|
||||
|
||||
studyrights.forEach(({ studystartdate }) => {
|
||||
const startYear = defineYear(studystartdate, isAcademicYear)
|
||||
graphStats[indexOf(years, startYear)] += 1
|
||||
tableStats[startYear] += 1
|
||||
})
|
||||
})
|
||||
)
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
|
||||
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
|
||||
codes: [studyprogramme],
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials,
|
||||
})
|
||||
|
||||
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
|
||||
studyrights.forEach(({ enddate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
graphStats[indexOf(years, graduationYear)] += 1
|
||||
tableStats[graduationYear] += 1
|
||||
})
|
||||
})
|
||||
)
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getCancelledStats = async ({ studyprogramme, years, isAcademicYear }) => {
|
||||
const studyrights = await allStudyrights(studyprogramme)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ canceldate }) => {
|
||||
if (canceldate !== null && canceldate !== undefined) {
|
||||
const cancelYear = defineYear(canceldate, isAcademicYear)
|
||||
graphStats[indexOf(years, cancelYear)] += 1
|
||||
tableStats[cancelYear] += 1
|
||||
}
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredAwayStats = async ({ studyprogramme, since, years, isAcademicYear }) => {
|
||||
const studyrights = await transfersAway(studyprogramme, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredToStats = async ({ studyprogramme, since, years, isAcademicYear }) => {
|
||||
const studyrights = await transfersTo(studyprogramme, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { includeAllSpecials, isAcademicYear } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
|
||||
const started = await getStartedStats(queryParameters)
|
||||
const graduated = await getGraduatedStats(queryParameters)
|
||||
const cancelled = await getCancelledStats(queryParameters)
|
||||
const transferredAway = await getTransferredAwayStats(queryParameters)
|
||||
const transferredTo = await getTransferredToStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const titles = tableTitles['basics'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
|
||||
const tableStats = reversedYears.map(year =>
|
||||
includeAllSpecials
|
||||
? [
|
||||
year,
|
||||
started.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
]
|
||||
: [year, started.tableStats[year], graduated.tableStats[year]]
|
||||
)
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
graphStats: includeAllSpecials
|
||||
? [
|
||||
{
|
||||
name: 'Started',
|
||||
data: started.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Cancelled',
|
||||
data: cancelled.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred away',
|
||||
data: transferredAway.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred To',
|
||||
data: transferredTo.graphStats,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Started',
|
||||
data: started.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
],
|
||||
tableStats,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBasicStatsForStudytrack,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
const { indexOf } = require('lodash')
|
||||
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const {
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
isMajorStudentCredit,
|
||||
getYearsArray,
|
||||
getStartDate,
|
||||
isSpecialGroupCredit,
|
||||
tableTitles,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
getProgrammesStudyrights,
|
||||
getCreditsForStudyProgramme,
|
||||
getTransferredCredits,
|
||||
allTransfers,
|
||||
} = require('./studyprogramme')
|
||||
|
||||
// Fetches all the credits for the studyprogramme and divides them into major-students and non-major students credits
|
||||
// Division is done on the basis that whether the student had a primary studyright to the programme on the attainment_date
|
||||
// If special groups are excluded, the transfer students are filtered away from the major-students as well
|
||||
const getRegularCreditStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const providercode = mapToProviders([studyprogramme])[0]
|
||||
let studyrights = await getProgrammesStudyrights(studyprogramme, since)
|
||||
const transfers = (await allTransfers(studyprogramme, since)).map(t => t.studyrightid)
|
||||
|
||||
if (!includeAllSpecials) {
|
||||
studyrights.filter(s => !transfers.includes(s.studyrightid))
|
||||
}
|
||||
|
||||
const credits = await getCreditsForStudyProgramme(providercode, since)
|
||||
|
||||
let majors = getStatsBasis(years)
|
||||
let nonMajors = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
|
||||
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
|
||||
if (!includeAllSpecials && isSpecialGroupCredit(studyright, attainment_date, transfers)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isMajorStudentCredit(studyright, attainment_date)) {
|
||||
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
majors.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
nonMajors.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
})
|
||||
|
||||
return { majors, nonMajors }
|
||||
}
|
||||
|
||||
// Fetches all the credits with the type "transferred / hyväksiluettu" and divides them by year
|
||||
const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcademicYear }) => {
|
||||
const providercode = mapToProviders([studyprogramme])[0]
|
||||
const credits = await getTransferredCredits(providercode, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date, credits }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
tableStats[attainmentYear] += credits || 0
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
// Fetches all credits for the studytrack and combines them into the statistics for the table
|
||||
// and graph in the studyprogramme overview
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { isAcademicYear, includeAllSpecials } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
|
||||
const { majors, nonMajors } = await getRegularCreditStats(queryParameters)
|
||||
const transferred = await getTransferredCreditStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
|
||||
|
||||
const tableStats = reversedYears.map(year =>
|
||||
includeAllSpecials
|
||||
? [
|
||||
year,
|
||||
majors.tableStats[year] + nonMajors.tableStats[year] + transferred.tableStats[year],
|
||||
majors.tableStats[year],
|
||||
nonMajors.tableStats[year],
|
||||
transferred.tableStats[year],
|
||||
]
|
||||
: [
|
||||
year,
|
||||
majors.tableStats[year] + nonMajors.tableStats[year] + transferred.tableStats[year],
|
||||
majors.tableStats[year],
|
||||
transferred.tableStats[year],
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Major students credits',
|
||||
data: majors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major students credits',
|
||||
data: nonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred credits',
|
||||
data: transferred.graphStats,
|
||||
},
|
||||
],
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreditStatsForStudytrack,
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
const moment = require('moment')
|
||||
const { indexOf, uniqBy } = require('lodash')
|
||||
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const {
|
||||
getCorrectStudentnumbers,
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
getYearsObject,
|
||||
getAcademicYearsObject,
|
||||
getYearsArray,
|
||||
getMedian,
|
||||
getMean,
|
||||
getStartDate,
|
||||
getThesisType,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
graduatedStudyRights,
|
||||
getThesisCredits,
|
||||
followingStudyrights,
|
||||
previousStudyrights,
|
||||
allStudyrights,
|
||||
allTransfers,
|
||||
} = require('./studyprogramme')
|
||||
const { getYearStartAndEndDates } = require('../util/semester')
|
||||
const { getAllProgrammes } = require('./studyrights')
|
||||
|
||||
const getGraduatedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
|
||||
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
|
||||
codes: [studyprogramme],
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials,
|
||||
})
|
||||
|
||||
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
|
||||
studyrights.forEach(({ enddate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
graphStats[indexOf(years, graduationYear)] += 1
|
||||
tableStats[graduationYear] += 1
|
||||
})
|
||||
})
|
||||
)
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const providercode = mapToProviders([studyprogramme])[0]
|
||||
const thesisType = getThesisType(studyprogramme)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
|
||||
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
|
||||
codes: [studyprogramme],
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials,
|
||||
})
|
||||
|
||||
const credits = await getThesisCredits(providercode, since, thesisType, studentnumbersOfTheYear)
|
||||
|
||||
credits?.forEach(({ attainment_date }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += 1
|
||||
tableStats[attainmentYear] += 1
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
let graduationAmounts = getYearsObject({ years })
|
||||
let graduationTimes = getYearsObject({ years, emptyArrays: true })
|
||||
let totalAmounts = isAcademicYear ? getAcademicYearsObject({ years }) : getYearsObject({ years })
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
const { startDate, endDate } = getYearStartAndEndDates(year, isAcademicYear)
|
||||
const studentnumbersOfTheYear = await getCorrectStudentnumbers({
|
||||
codes: [studyprogramme],
|
||||
startDate,
|
||||
endDate,
|
||||
includeAllSpecials,
|
||||
})
|
||||
|
||||
const studyrights = await graduatedStudyRights(studyprogramme, since, studentnumbersOfTheYear)
|
||||
totalAmounts[year] = studentnumbersOfTheYear.length
|
||||
|
||||
studyrights.forEach(({ enddate, studystartdate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
|
||||
graduationAmounts[graduationYear] += 1
|
||||
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
|
||||
})
|
||||
})
|
||||
)
|
||||
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
|
||||
const comparisonValue = studyprogramme.includes('KH') ? 72 : 48
|
||||
|
||||
const medians = getYearsObject({ years, emptyArrays: true })
|
||||
const means = getYearsObject({ years, emptyArrays: true })
|
||||
|
||||
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
|
||||
years.forEach(year => {
|
||||
const median = getMedian(graduationTimes[year])
|
||||
const mean = getMean(graduationTimes[year])
|
||||
medians[year] = [
|
||||
['', median],
|
||||
['', comparisonValue - median],
|
||||
]
|
||||
means[year] = [
|
||||
['', mean],
|
||||
['', comparisonValue - mean],
|
||||
]
|
||||
})
|
||||
return { medians, means, graduationAmounts, totalAmounts }
|
||||
}
|
||||
|
||||
// Creates (studyrightid, transfer)-map out of programmes transfers
|
||||
const getTransferStudyrightMap = async (studyprogramme, since) => {
|
||||
const transfers = await allTransfers(studyprogramme, since)
|
||||
const studyrightTransferMap = new Map()
|
||||
transfers.forEach(t => studyrightTransferMap.set(t.studyrightid, t))
|
||||
return studyrightTransferMap
|
||||
}
|
||||
|
||||
const formatStats = (stats, years) => {
|
||||
const tableStats = Object.values(stats)
|
||||
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
|
||||
.map(p => [p.code, p.name['fi'], ...years.map(year => p[year])])
|
||||
|
||||
const graphStats = Object.values(stats)
|
||||
.filter(p => years.map(year => p[year]).find(started => started !== 0)) // Filter out programmes with no-one started between the selected years
|
||||
.map(p => ({ name: p.name['fi'], code: p.code, data: years.map(year => p[year]) }))
|
||||
|
||||
return { tableStats, graphStats }
|
||||
}
|
||||
|
||||
const getProgrammesBeforeStarting = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const all = await allStudyrights(studyprogramme)
|
||||
const allStudents = all.map(s => s.studentnumber)
|
||||
|
||||
// Get studyrights for bachelor studyprogrammes. This excludes studytracks.
|
||||
const programmes = await getAllProgrammes()
|
||||
let studyrightsBeforeMasters = await previousStudyrights(since, programmes, allStudents)
|
||||
const studyprogrammeCodes = uniqBy(studyrightsBeforeMasters, 'code').map(s => ({ code: s.code, name: s.name }))
|
||||
const stats = {}
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
|
||||
|
||||
// Map transfers to the Master's programme
|
||||
const transfers = await getTransferStudyrightMap(studyprogramme, since)
|
||||
|
||||
studyrightsBeforeMasters.forEach(({ studentnumber, code, givendate }) => {
|
||||
const masterStudyright = all.find(s => s.studentnumber === studentnumber)
|
||||
const transfer = transfers.get(masterStudyright.studyrightid)
|
||||
|
||||
// If special studyrights are excluded, transfers to the master's programme should be excluded
|
||||
if (!includeAllSpecials && transfers.get(masterStudyright.studyrightid)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If all studyrights are included, and transfer exists, define the start year in the programme, based on when the student transferred to it
|
||||
// Otherwise, the year is defined based on when the student started in the chosen master's programme
|
||||
const programmeStartDate = includeAllSpecials && transfer ? transfer.transferdate : masterStudyright?.studystartdate
|
||||
const startYear = defineYear(programmeStartDate, isAcademicYear)
|
||||
|
||||
// The master studyright should have been given on the same date as the bachelor's
|
||||
if (years.includes(startYear) && code && masterStudyright.givendate.getTime() === givendate.getTime()) {
|
||||
stats[code][startYear] += 1
|
||||
}
|
||||
})
|
||||
|
||||
const graphAndTableStats = formatStats(stats, years)
|
||||
return graphAndTableStats
|
||||
}
|
||||
|
||||
const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
|
||||
const graduated = await graduatedStudyRights(studyprogramme, since)
|
||||
const graduatedStudents = graduated.map(s => s.studentnumber)
|
||||
|
||||
// Get studyrights for masters studyprogramme. This excludes studytracks.
|
||||
const programmes = await getAllProgrammes()
|
||||
let studyrightsAfterThisProgramme = await followingStudyrights(since, programmes, graduatedStudents)
|
||||
const studyprogrammeCodes = uniqBy(studyrightsAfterThisProgramme, 'code').map(s => ({ code: s.code, name: s.name }))
|
||||
const stats = {}
|
||||
studyprogrammeCodes.forEach(c => (stats[c.code] = { ...c, ...getYearsObject({ years }) }))
|
||||
|
||||
const transfers = await getTransferStudyrightMap(studyprogramme, since)
|
||||
|
||||
studyrightsAfterThisProgramme.forEach(({ studentnumber, studystartdate, code, givendate }) => {
|
||||
const bachelorStudyright = graduated.find(s => s.studentnumber === studentnumber)
|
||||
|
||||
// If special studyrights are excluded, transfers to the bachelor programme should be excluded
|
||||
if (!includeAllSpecials && transfers.get(bachelorStudyright.studyrightid)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If all studyrights are included, and transfer exists, define the start year in the programme, based on when the student transferred to it
|
||||
// Otherwise, the year is defined based on when the student started in the chosen master's programme
|
||||
const startYear = defineYear(studystartdate, isAcademicYear)
|
||||
|
||||
// The master studyright should have been given on the same date as the bachelor's
|
||||
if (years.includes(startYear) && code && bachelorStudyright.givendate.getTime() === givendate.getTime()) {
|
||||
stats[code][startYear] += 1
|
||||
}
|
||||
})
|
||||
|
||||
const graphAndTableStats = formatStats(stats, years)
|
||||
return graphAndTableStats
|
||||
}
|
||||
|
||||
const getGraduationStatsForStudytrack = async ({ studyprogramme, settings }) => {
|
||||
const { isAcademicYear, includeAllSpecials } = settings
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
|
||||
const queryParameters = { studyprogramme, since, years, isAcademicYear, includeAllSpecials }
|
||||
const thesis = await getThesisStats(queryParameters)
|
||||
const graduated = await getGraduatedStats(queryParameters)
|
||||
const graduationTimeStats = await getGraduationTimeStats(queryParameters)
|
||||
const programmesBeforeOrAfter = studyprogramme.includes('KH')
|
||||
? await getProgrammesAfterGraduation(queryParameters)
|
||||
: await getProgrammesBeforeStarting(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
|
||||
const titles = ['', 'Graduated', 'Wrote thesis']
|
||||
const programmesBeforeOrAfterTitles = ['Code', 'Programme', ...years]
|
||||
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
titles,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Graduated students',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Wrote thesis',
|
||||
data: thesis.graphStats,
|
||||
},
|
||||
],
|
||||
graduationMedianTime: graduationTimeStats.medians,
|
||||
graduationMeanTime: graduationTimeStats.means,
|
||||
graduationAmounts: graduationTimeStats.graduationAmounts,
|
||||
totalAmounts: graduationTimeStats.totalAmounts,
|
||||
programmesBeforeOrAfterTableStats: programmesBeforeOrAfter.tableStats,
|
||||
programmesBeforeOrAfterGraphStats: programmesBeforeOrAfter.graphStats,
|
||||
programmesBeforeOrAfterTitles,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGraduationStatsForStudytrack,
|
||||
}
|
||||
@@ -1,16 +1,69 @@
|
||||
const { mean } = require('lodash')
|
||||
const { studentnumbersWithAllStudyrightElements } = require('./populations')
|
||||
|
||||
// Helper functions
|
||||
|
||||
const getCorrectStudentnumbers = async ({ codes, startDate, endDate, includeAllSpecials, includeGraduated = true }) => {
|
||||
let studentnumbers = []
|
||||
const exchangeStudents = includeAllSpecials
|
||||
const cancelledStudents = includeAllSpecials
|
||||
const nondegreeStudents = includeAllSpecials
|
||||
const transferredOutStudents = includeAllSpecials
|
||||
const transferredToStudents = !includeAllSpecials
|
||||
const graduatedStudents = !includeGraduated
|
||||
|
||||
studentnumbers = await studentnumbersWithAllStudyrightElements(
|
||||
codes,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredOutStudents,
|
||||
null,
|
||||
transferredToStudents,
|
||||
graduatedStudents
|
||||
)
|
||||
|
||||
return studentnumbers
|
||||
}
|
||||
|
||||
const defineStartDate = (studyright_elements, studystartdate) => {
|
||||
if (studyright_elements?.length && studyright_elements[0].startdate > studystartdate) {
|
||||
return studyright_elements[0].startdate
|
||||
}
|
||||
return studystartdate
|
||||
}
|
||||
|
||||
const formatStudyright = studyright => {
|
||||
const { studyrightid, studystartdate, enddate, graduated, prioritycode, extentcode, student } = studyright
|
||||
return {
|
||||
const {
|
||||
studyrightid,
|
||||
studystartdate,
|
||||
enddate,
|
||||
givendate,
|
||||
canceldate,
|
||||
graduated,
|
||||
prioritycode,
|
||||
extentcode,
|
||||
student,
|
||||
studyright_elements,
|
||||
} = studyright
|
||||
|
||||
return {
|
||||
studyrightid,
|
||||
studystartdate: defineStartDate(studyright_elements, studystartdate),
|
||||
enddate,
|
||||
givendate,
|
||||
canceldate,
|
||||
graduated,
|
||||
prioritycode,
|
||||
extentcode,
|
||||
studentnumber: student.studentnumber,
|
||||
code: studyright_elements?.length ? studyright_elements[0].code : null,
|
||||
name:
|
||||
studyright_elements?.length && studyright_elements[0].element_detail && studyright_elements[0].element_detail.name
|
||||
? studyright_elements[0].element_detail.name
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +77,19 @@ const formatStudent = student => {
|
||||
}
|
||||
}
|
||||
|
||||
const getYearsArray = (since, isAcademicYear) => {
|
||||
const formatTransfer = transfer => {
|
||||
const { sourcecode, targetcode, transferdate, studyrightid } = transfer
|
||||
return {
|
||||
sourcecode,
|
||||
targetcode,
|
||||
transferdate,
|
||||
studyrightid,
|
||||
}
|
||||
}
|
||||
|
||||
const getYearsArray = (since, isAcademicYear, yearsCombined) => {
|
||||
const years = []
|
||||
if (yearsCombined) return [`${since} - ${new Date().getFullYear()}`]
|
||||
for (let i = since; i <= new Date().getFullYear(); i++) {
|
||||
const year = isAcademicYear ? `${i} - ${i + 1}` : i
|
||||
years.push(year)
|
||||
@@ -33,7 +97,7 @@ const getYearsArray = (since, isAcademicYear) => {
|
||||
return years
|
||||
}
|
||||
|
||||
const getYearsObject = (years, emptyArrays = false) => {
|
||||
const getYearsObject = ({ years, emptyArrays = false }) => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [year]: emptyArrays ? [] : 0 }
|
||||
@@ -41,20 +105,39 @@ const getYearsObject = (years, emptyArrays = false) => {
|
||||
return yearsObject
|
||||
}
|
||||
|
||||
const getAcademicYearsObject = ({ years, emptyArrays = false }) => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [`${year} - ${year + 1}`]: emptyArrays ? [] : 0 }
|
||||
}
|
||||
return yearsObject
|
||||
}
|
||||
|
||||
const getStatsBasis = years => {
|
||||
return {
|
||||
graphStats: new Array(years.length).fill(0),
|
||||
tableStats: getYearsObject(years),
|
||||
tableStats: getYearsObject({ years }),
|
||||
}
|
||||
}
|
||||
|
||||
const isMajorStudentCredit = (studyright, attainment_date) =>
|
||||
studyright &&
|
||||
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
|
||||
studyright.studystartdate && // The student has started studying in the programme
|
||||
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
|
||||
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
|
||||
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
|
||||
|
||||
const isSpecialGroupCredit = (studyright, attainment_date, transfers) => {
|
||||
if (!studyright) return true // If there is no studyright matching the credit, is not a major student credit
|
||||
if (studyright.canceldate) return true // Cancelled studyrights are in the special groups
|
||||
if (studyright.studystartdate > attainment_date) return true // Credits before the studyright started are not major student credits
|
||||
if (studyright.enddate && attainment_date > studyright.enddate) return true // Credits after studyright are not major student credits
|
||||
if ([7, 9, 16, 34, 33, 99, 14, 13].includes(studyright.extentcode)) return true // Excludes non-degree studyrights and exchange students
|
||||
if (transfers.includes(studyright.studyrightid)) return true // Excludes both transfers in and out of the programme
|
||||
return false
|
||||
}
|
||||
|
||||
const getMedian = values => {
|
||||
if (values.length === 0) return 0
|
||||
values.sort((a, b) => a - b)
|
||||
@@ -79,8 +162,8 @@ const defineYear = (date, isAcademicYear) => {
|
||||
const getStartDate = (studyprogramme, isAcademicYear) => {
|
||||
if ((studyprogramme.includes('KH') || studyprogramme.includes('MH')) && isAcademicYear) return new Date('2017-08-01')
|
||||
if (studyprogramme.includes('KH') || studyprogramme.includes('MH')) return new Date('2017-01-01')
|
||||
if (isAcademicYear) return new Date('2000-08-01')
|
||||
return new Date('2000-01-01')
|
||||
if (isAcademicYear) return new Date('2017-08-01')
|
||||
return new Date('2017-01-01')
|
||||
}
|
||||
|
||||
// There are 9 course_unit_types
|
||||
@@ -114,7 +197,7 @@ const getPercentage = (value, total) => {
|
||||
|
||||
const getEmptyArray = length => new Array(length).fill(0)
|
||||
|
||||
const getCreditGraphStats = years => ({
|
||||
const getBachelorCreditGraphStats = years => ({
|
||||
lte30: {
|
||||
name: 'Less than 30 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
@@ -141,21 +224,117 @@ const getCreditGraphStats = years => ({
|
||||
},
|
||||
})
|
||||
|
||||
const creditThresholds = ['lte30', 'lte60', 'lte90', 'lte120', 'lte150', 'mte150']
|
||||
const getMasterCreditGraphStats = 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),
|
||||
},
|
||||
mte280: {
|
||||
name: 'More than 280 credits',
|
||||
data: getEmptyArray(years.length),
|
||||
},
|
||||
})
|
||||
|
||||
const getCreditGraphStats = (studyprogramme, years) =>
|
||||
studyprogramme.includes('KH') ? getBachelorCreditGraphStats(years) : getMasterCreditGraphStats(years)
|
||||
|
||||
const bachelorCreditThresholds = ['lte30', 'lte60', 'lte90', 'lte120', 'lte150', 'mte150']
|
||||
const masterCreditThresholds = ['lte200', 'lte220', 'lte240', 'lte260', 'lte280', 'mte280']
|
||||
const bachelorCreditAmounts = [30, 60, 90, 120, 150, 150]
|
||||
const masterCreditAmounts = [200, 220, 240, 260, 280, 280]
|
||||
|
||||
const getCreditThresholds = studyprogramme => {
|
||||
if (studyprogramme.includes('KH')) {
|
||||
return { creditThresholdKeys: bachelorCreditThresholds, creditThresholdAmounts: bachelorCreditAmounts }
|
||||
}
|
||||
return { creditThresholdKeys: masterCreditThresholds, creditThresholdAmounts: masterCreditAmounts }
|
||||
}
|
||||
|
||||
const tableTitles = {
|
||||
basics: {
|
||||
SPECIAL_EXCLUDED: ['', 'Started studying', 'Graduated'],
|
||||
SPECIAL_INCLUDED: ['', 'Started studying', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to'],
|
||||
},
|
||||
credits: {
|
||||
SPECIAL_EXCLUDED: ['', 'Total', 'Major students credits', 'Transferred credits'],
|
||||
SPECIAL_INCLUDED: ['', 'Total', 'Major students credits', 'Non major students credits', 'Transferred credits'],
|
||||
},
|
||||
creditProgress: {
|
||||
bachelor: [
|
||||
'',
|
||||
'All',
|
||||
'< 30 credits',
|
||||
'30-59 credits',
|
||||
'60-89 credits',
|
||||
'90-119 credits',
|
||||
'120-149 credits',
|
||||
'> 150 credits',
|
||||
],
|
||||
master: [
|
||||
'',
|
||||
'All',
|
||||
'< 200 credits',
|
||||
'200-219 credits',
|
||||
'220-239 credits',
|
||||
'240-259 credits',
|
||||
'260-279 credits',
|
||||
'> 280 credits',
|
||||
],
|
||||
},
|
||||
studytracks: [
|
||||
'',
|
||||
'All',
|
||||
'Started studying',
|
||||
'Currently enrolled',
|
||||
'Absent',
|
||||
'Cancelled',
|
||||
'Graduated',
|
||||
'Men',
|
||||
'Women',
|
||||
'Finnish',
|
||||
],
|
||||
}
|
||||
|
||||
const getCreditProgressTableTitles = studyprogramme =>
|
||||
studyprogramme.includes('KH') ? tableTitles.bachelorCreditProgress : tableTitles.masterCreditProgress
|
||||
|
||||
module.exports = {
|
||||
getCorrectStudentnumbers,
|
||||
formatStudyright,
|
||||
formatStudent,
|
||||
formatTransfer,
|
||||
getYearsArray,
|
||||
getYearsObject,
|
||||
getAcademicYearsObject,
|
||||
getStatsBasis,
|
||||
isMajorStudentCredit,
|
||||
isSpecialGroupCredit,
|
||||
getMedian,
|
||||
getMean,
|
||||
defineYear,
|
||||
getStartDate,
|
||||
getThesisType,
|
||||
getPercentage,
|
||||
getBachelorCreditGraphStats,
|
||||
getMasterCreditGraphStats,
|
||||
getCreditGraphStats,
|
||||
creditThresholds,
|
||||
getCreditThresholds,
|
||||
tableTitles,
|
||||
getCreditProgressTableTitles,
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
const moment = require('moment')
|
||||
const { indexOf } = require('lodash')
|
||||
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const {
|
||||
getStatsBasis,
|
||||
defineYear,
|
||||
isMajorStudentCredit,
|
||||
getYearsObject,
|
||||
getYearsArray,
|
||||
getMedian,
|
||||
getMean,
|
||||
getStartDate,
|
||||
getThesisType,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const {
|
||||
graduatedStudyRights,
|
||||
startedStudyrights,
|
||||
cancelledStudyRights,
|
||||
transfersAway,
|
||||
transfersTo,
|
||||
getProgrammesStudents,
|
||||
getCreditsForStudyProgramme,
|
||||
getTransferredCredits,
|
||||
getThesisCredits,
|
||||
} = require('./newStudyprogramme')
|
||||
|
||||
// const getEnrolledStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
// const students = await enrolledStudyrights(studytrack)
|
||||
// const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
// students.forEach(({ startdate }) => {
|
||||
// if (startdate.getMonth() === 7) {
|
||||
// const enrolledYear = defineYear(startdate, isAcademicYear)
|
||||
|
||||
// graphStats[indexOf(years, enrolledYear)] += 1
|
||||
// tableStats[enrolledYear] += 1
|
||||
// }
|
||||
// })
|
||||
|
||||
// return { graphStats, tableStats }
|
||||
// }
|
||||
|
||||
const getGraduatedStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await graduatedStudyRights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ enddate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
graphStats[indexOf(years, graduationYear)] += 1
|
||||
tableStats[graduationYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getStartedStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await startedStudyrights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ studystartdate }) => {
|
||||
const startYear = defineYear(studystartdate, isAcademicYear)
|
||||
graphStats[indexOf(years, startYear)] += 1
|
||||
tableStats[startYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getCancelledStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await cancelledStudyRights(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ canceldate }) => {
|
||||
const cancelYear = defineYear(canceldate, isAcademicYear)
|
||||
graphStats[indexOf(years, cancelYear)] += 1
|
||||
tableStats[cancelYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredAwayStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await transfersAway(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredToStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await transfersTo(studytrack, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = defineYear(transferdate, isAcademicYear)
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getRegularCreditStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const studyrights = await getProgrammesStudents(studytrack)
|
||||
const credits = await getCreditsForStudyProgramme(providercode, since)
|
||||
|
||||
let majors = getStatsBasis(years)
|
||||
let nonMajors = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
|
||||
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
|
||||
if (isMajorStudentCredit(studyright, attainment_date)) {
|
||||
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
majors.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
nonMajors.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
})
|
||||
|
||||
return { majors, nonMajors }
|
||||
}
|
||||
|
||||
const getTransferredCreditStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const credits = await getTransferredCredits(providercode, since)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date, credits }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
tableStats[attainmentYear] += credits || 0
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getThesisStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const thesisType = getThesisType(studytrack)
|
||||
const credits = await getThesisCredits(providercode, since, thesisType)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date }) => {
|
||||
const attainmentYear = defineYear(attainment_date, isAcademicYear)
|
||||
graphStats[indexOf(years, attainmentYear)] += 1
|
||||
tableStats[attainmentYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getGraduationTimeStats = async (studytrack, since, years, isAcademicYear) => {
|
||||
const studyrights = await graduatedStudyRights(studytrack, since)
|
||||
let graduationAmounts = getYearsObject(years)
|
||||
let graduationTimes = getYearsObject(years, true)
|
||||
|
||||
studyrights.forEach(({ enddate, studystartdate }) => {
|
||||
const graduationYear = defineYear(enddate, isAcademicYear)
|
||||
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
|
||||
graduationAmounts[graduationYear] += 1
|
||||
graduationTimes[graduationYear] = [...graduationTimes[graduationYear], timeToGraduation]
|
||||
})
|
||||
|
||||
const medians = getYearsObject(years, true)
|
||||
const means = getYearsObject(years, true)
|
||||
|
||||
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
|
||||
const comparisonValue = studytrack.includes('KH') ? 72 : 48
|
||||
|
||||
// HighCharts graph require the data to have this format (ie. actual value, "empty value")
|
||||
years.forEach(year => {
|
||||
const median = getMedian(graduationTimes[year])
|
||||
const mean = getMean(graduationTimes[year])
|
||||
medians[year] = [
|
||||
['', median],
|
||||
['', comparisonValue - median],
|
||||
]
|
||||
means[year] = [
|
||||
['', mean],
|
||||
['', comparisonValue - mean],
|
||||
]
|
||||
})
|
||||
return { medians, means, graduationAmounts }
|
||||
}
|
||||
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const started = await getStartedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const cancelled = await getCancelledStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferredAway = await getTransferredAwayStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferredTo = await getTransferredToStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
started.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Started',
|
||||
data: started.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Cancelled',
|
||||
data: cancelled.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred away',
|
||||
data: transferredAway.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred To',
|
||||
data: transferredTo.graphStats,
|
||||
},
|
||||
],
|
||||
tableStats,
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, since, years, isAcademicYear)
|
||||
const transferred = await getTransferredCreditStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
majors.tableStats[year] + nonMajors.tableStats[year] + transferred.tableStats[year],
|
||||
majors.tableStats[year],
|
||||
nonMajors.tableStats[year],
|
||||
transferred.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Major students credits',
|
||||
data: majors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major students credits',
|
||||
data: nonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred credits',
|
||||
data: transferred.graphStats,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType }) => {
|
||||
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear)
|
||||
const thesis = await getThesisStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduated = await getGraduatedStats(studyprogramme, since, years, isAcademicYear)
|
||||
const graduationTimeStats = await getGraduationTimeStats(studyprogramme, since, years, isAcademicYear)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
|
||||
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Graduated students',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Wrote thesis',
|
||||
data: thesis.graphStats,
|
||||
},
|
||||
],
|
||||
graduationMedianTime: graduationTimeStats.medians,
|
||||
graduationMeanTime: graduationTimeStats.means,
|
||||
graduationAmounts: graduationTimeStats.graduationAmounts,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
const {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
} = require('../services/studyprogrammeStats')
|
||||
const { getBasicStatsForStudytrack } = require('../services/studyprogrammeBasics')
|
||||
const { getCreditStatsForStudytrack } = require('../services/studyprogrammeCredits')
|
||||
const { getGraduationStatsForStudytrack } = require('../services/studyprogrammeGraduations')
|
||||
const { getStudytrackStatsForStudyprogramme } = require('../services/studytrackStats')
|
||||
const {
|
||||
setBasicStats,
|
||||
@@ -10,53 +8,133 @@ const {
|
||||
setGraduationStats,
|
||||
setStudytrackStats,
|
||||
} = require('../services/analyticsService')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const updateBasicView = async code => {
|
||||
const academic = 'ACADEMIC_YEAR'
|
||||
const calendar = 'CALENDAR_YEAR'
|
||||
const specialCalendar = {
|
||||
yearType: 'CALENDAR_YEAR',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
}
|
||||
const specialAcademic = {
|
||||
yearType: 'ACADEMIC_YEAR',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
}
|
||||
const specialExcludedCalendar = {
|
||||
yearType: 'CALENDAR_YEAR',
|
||||
specialGroups: 'SPECIAL_EXCLUDED',
|
||||
}
|
||||
|
||||
const basicStatsAcademic = await getBasicStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: academic,
|
||||
})
|
||||
await setBasicStats(basicStatsAcademic, academic)
|
||||
const specialExcludedAcademic = {
|
||||
yearType: 'ACADEMIC_YEAR',
|
||||
specialGroups: 'SPECIAL_EXCLUDED',
|
||||
}
|
||||
|
||||
const basicStatsCalendar = await getBasicStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: calendar,
|
||||
})
|
||||
await setBasicStats(basicStatsCalendar, calendar)
|
||||
const options = [specialCalendar, specialAcademic, specialExcludedCalendar, specialExcludedAcademic]
|
||||
|
||||
const creditStatsAcademic = await getCreditStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: academic,
|
||||
})
|
||||
await setCreditStats(creditStatsAcademic, academic)
|
||||
for (const option of options) {
|
||||
try {
|
||||
const basicStats = await getBasicStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setBasicStats(basicStats, option.yearType, option.specialGroups)
|
||||
|
||||
const creditStatsCalendar = await getCreditStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: calendar,
|
||||
})
|
||||
await setCreditStats(creditStatsCalendar, calendar)
|
||||
const creditStats = await getCreditStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setCreditStats(creditStats, option.yearType, option.specialGroups)
|
||||
|
||||
const graduationStatsAcademic = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: academic,
|
||||
})
|
||||
await setGraduationStats(graduationStatsAcademic, academic)
|
||||
|
||||
const graduationStatsCalendar = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
yearType: calendar,
|
||||
})
|
||||
await setGraduationStats(graduationStatsCalendar, calendar)
|
||||
|
||||
return 'OK'
|
||||
const graduationStats = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
await setGraduationStats(graduationStats, option.yearType, option.specialGroups)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
return 'OK'
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudytrackView = async code => {
|
||||
const result = await getStudytrackStatsForStudyprogramme({ studyprogramme: code })
|
||||
await setStudytrackStats(result)
|
||||
const specialGraduatedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_SEPARATED',
|
||||
}
|
||||
const specialGraduatedExcludedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedExcludedCombined = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialGraduatedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_SEPARATED',
|
||||
}
|
||||
const specialGraduatedExcludedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
const specialExcludedGraduatedExcludedSeparated = {
|
||||
graduated: 'GRADUATED_INCLUDED',
|
||||
specialGroups: 'SPECIAL_INCLUDED',
|
||||
yearsCombined: 'YEARS_COMBINED',
|
||||
}
|
||||
|
||||
const options = [
|
||||
specialGraduatedCombined,
|
||||
specialExcludedGraduatedCombined,
|
||||
specialGraduatedExcludedCombined,
|
||||
specialExcludedGraduatedExcludedCombined,
|
||||
specialGraduatedSeparated,
|
||||
specialExcludedGraduatedSeparated,
|
||||
specialGraduatedExcludedSeparated,
|
||||
specialExcludedGraduatedExcludedSeparated,
|
||||
]
|
||||
|
||||
for (const option of options) {
|
||||
try {
|
||||
const stats = await getStudytrackStatsForStudyprogramme({
|
||||
studyprogramme: code,
|
||||
settings: {
|
||||
specialGroups: option.specialGroups === 'SPECIAL_INCLUDED',
|
||||
graduated: option.graduated === 'GRADUATED_INCLUDED',
|
||||
yearsCombined: option.yearsCombined === 'YEARS_COMBINED',
|
||||
},
|
||||
})
|
||||
await setStudytrackStats(stats, option.graduated, option.specialGroups, option.yearsCombined)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return 'OK'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +1,167 @@
|
||||
const { indexOf } = require('lodash')
|
||||
const moment = require('moment')
|
||||
|
||||
const { getAssociations } = require('./studyrights')
|
||||
const { studentnumbersWithAllStudyrightElements } = require('./populations')
|
||||
const {
|
||||
getMedian,
|
||||
getMean,
|
||||
getStartDate,
|
||||
getYearsArray,
|
||||
getPercentage,
|
||||
getCreditGraphStats,
|
||||
getYearsObject,
|
||||
creditThresholds,
|
||||
getCorrectStudentnumbers,
|
||||
getCreditThresholds,
|
||||
getCreditGraphStats,
|
||||
tableTitles,
|
||||
} = require('./studyprogrammeHelpers')
|
||||
const { studytrackStudents, startedStudyrights, graduatedStudyRights } = require('./newStudyprogramme')
|
||||
const {
|
||||
studytrackStudents,
|
||||
allStudyrights,
|
||||
startedStudyrights,
|
||||
enrolledStudents,
|
||||
absentStudents,
|
||||
graduatedStudyRights,
|
||||
} = require('./studyprogramme')
|
||||
const { getAcademicYearDates } = require('../util/semester')
|
||||
|
||||
const getStudentData = students => {
|
||||
const data = { female: 0, male: 0, finnish: 0, lte30: 0, lte60: 0, lte90: 0, lte120: 0, lte150: 0, mte150: 0 }
|
||||
const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
|
||||
let data = { female: 0, male: 0, finnish: 0 }
|
||||
thresholdKeys.forEach(t => (data[t] = 0))
|
||||
|
||||
students.forEach(({ gender_code, home_country_en, creditcount }) => {
|
||||
data.male += gender_code === '1' ? 1 : 0
|
||||
data.female += gender_code === '2' ? 1 : 0
|
||||
data.finnish += home_country_en === 'Finland' ? 1 : 0
|
||||
data.lte30 += creditcount < 30 ? 1 : 0
|
||||
data.lte60 += creditcount >= 30 && creditcount < 60 ? 1 : 0
|
||||
data.lte90 += creditcount >= 60 && creditcount < 90 ? 1 : 0
|
||||
data.lte120 += creditcount >= 90 && creditcount < 120 ? 1 : 0
|
||||
data.lte150 += creditcount >= 120 && creditcount < 150 ? 1 : 0
|
||||
data.mte150 += creditcount >= 150 ? 1 : 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
|
||||
data[thresholdKeys[4]] += creditcount >= thresholdAmounts[3] && creditcount < thresholdAmounts[4] ? 1 : 0
|
||||
data[thresholdKeys[5]] += creditcount >= thresholdAmounts[5] ? 1 : 0
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const getGraduationTimeStats = async ({
|
||||
year,
|
||||
graduated,
|
||||
studyprogramme,
|
||||
track,
|
||||
graduationMeanTime,
|
||||
graduationMedianTime,
|
||||
graduationAmounts,
|
||||
}) => {
|
||||
// Count how long each student took to graduate
|
||||
let graduationTimes = []
|
||||
graduated.forEach(({ enddate, studystartdate }) => {
|
||||
const timeToGraduation = moment(enddate).diff(moment(studystartdate), 'months')
|
||||
graduationAmounts[track][year] += 1
|
||||
graduationTimes = [...graduationTimes, timeToGraduation]
|
||||
})
|
||||
|
||||
// The maximum amount of months in the graph depends on the studyprogramme intended graduation time
|
||||
const comparisonValue = studyprogramme.includes('KH') ? 72 : 48
|
||||
|
||||
// HighCharts graph requires the data to have this format (ie. actual value, "empty value")
|
||||
const median = getMedian(graduationTimes)
|
||||
const mean = getMean(graduationTimes)
|
||||
graduationMedianTime[track][year] = [
|
||||
['', median],
|
||||
['', comparisonValue - median],
|
||||
]
|
||||
graduationMeanTime[track][year] = [
|
||||
['', mean],
|
||||
['', comparisonValue - mean],
|
||||
]
|
||||
}
|
||||
|
||||
// Goes through the programme and all its studytracks for the said year and adds the wanted stats to the data objects
|
||||
const getStudytrackDataForTheYear = async ({ studyprogramme, studytracks, studytrackNames, year, years, data }) => {
|
||||
const { mainStatsByYear, mainStatsByTrack, creditGraphStats, creditTableStats, emptyTracks } = data
|
||||
const { startDate, endDate } = getAcademicYearDates(year)
|
||||
const getStudytrackDataForTheYear = async ({
|
||||
studyprogramme,
|
||||
settings,
|
||||
studytracks,
|
||||
studytrackNames,
|
||||
year,
|
||||
years,
|
||||
data,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
}) => {
|
||||
const { specialGroups: includeAllSpecials, graduated: includeGraduated, yearsCombined } = settings
|
||||
const { startDate, endDate } = getAcademicYearDates(year, yearsCombined)
|
||||
const {
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
creditGraphStats,
|
||||
creditTableStats,
|
||||
graduationMeanTime,
|
||||
graduationMedianTime,
|
||||
graduationAmounts,
|
||||
totalAmounts,
|
||||
emptyTracks,
|
||||
} = data
|
||||
|
||||
await Promise.all(
|
||||
studytracks.map(async track => {
|
||||
const codes = studyprogramme === track ? [studyprogramme] : [studyprogramme, track]
|
||||
const studentnumbers = await studentnumbersWithAllStudyrightElements(
|
||||
|
||||
const studentnumbers = await getCorrectStudentnumbers({
|
||||
codes,
|
||||
startDate,
|
||||
endDate,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
includeAllSpecials,
|
||||
includeGraduated,
|
||||
})
|
||||
|
||||
// All the stats are counted for the students who actually started studying
|
||||
// Get all the studyrights and students for the calculations
|
||||
const all = await allStudyrights(track, null, studentnumbers)
|
||||
const students = await studytrackStudents(studentnumbers)
|
||||
const studentData = getStudentData(students, creditThresholdKeys, creditThresholdAmounts)
|
||||
const started = await startedStudyrights(track, startDate, studentnumbers)
|
||||
const startedStudentnumbers = [...new Set(started.map(s => s.studentnumber))]
|
||||
const students = await studytrackStudents(startedStudentnumbers)
|
||||
const studentData = getStudentData(students)
|
||||
const enrolled = await enrolledStudents(track, startDate, studentnumbers)
|
||||
const absent = await absentStudents(track, studentnumbers)
|
||||
const cancelled = all.filter(s => s.canceldate !== null && s.canceldate !== undefined)
|
||||
const graduated = await graduatedStudyRights(track, startDate, studentnumbers)
|
||||
|
||||
// If the track has no stats for that year, it should be removed from the table and dropdown options
|
||||
if (started.length === 0) {
|
||||
if (all.length === 0) {
|
||||
emptyTracks.has(track) ? emptyTracks.set(track, emptyTracks.get(track) + 1) : emptyTracks.set(track, 1)
|
||||
return
|
||||
}
|
||||
|
||||
// Count stats for creditgraph for the year per track
|
||||
creditThresholds.forEach(
|
||||
creditThresholdKeys.forEach(
|
||||
threshold => (creditGraphStats[track][threshold].data[indexOf(years, year)] = studentData[threshold])
|
||||
)
|
||||
|
||||
// Count stats for the credit progress table for the year per track
|
||||
creditTableStats[track] = [
|
||||
...creditTableStats[track],
|
||||
[
|
||||
`${year} - ${year + 1}`,
|
||||
started.length,
|
||||
studentData.lte30,
|
||||
studentData.lte60,
|
||||
studentData.lte90,
|
||||
studentData.lte120,
|
||||
studentData.lte150,
|
||||
studentData.mte150,
|
||||
],
|
||||
[year, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
|
||||
]
|
||||
|
||||
// Count stats for the main studytrack table grouped by tracks
|
||||
mainStatsByTrack[track] = [
|
||||
...mainStatsByTrack[track],
|
||||
[
|
||||
`${year} - ${year + 1}`,
|
||||
year,
|
||||
all.length,
|
||||
getPercentage(all.length, all.length),
|
||||
started.length,
|
||||
getPercentage(started.length, started.length),
|
||||
studentData.male,
|
||||
getPercentage(studentData.male, started.length),
|
||||
studentData.female,
|
||||
getPercentage(studentData.female, started.length),
|
||||
studentData.finnish,
|
||||
getPercentage(studentData.finnish, started.length),
|
||||
getPercentage(started.length, all.length),
|
||||
enrolled.length,
|
||||
getPercentage(enrolled.length, all.length),
|
||||
absent.length,
|
||||
getPercentage(absent.length, all.length),
|
||||
cancelled.length,
|
||||
getPercentage(cancelled.length, all.length),
|
||||
graduated.length,
|
||||
getPercentage(graduated.length, started.length),
|
||||
getPercentage(graduated.length, all.length),
|
||||
studentData.male,
|
||||
getPercentage(studentData.male, all.length),
|
||||
studentData.female,
|
||||
getPercentage(studentData.female, all.length),
|
||||
studentData.finnish,
|
||||
getPercentage(studentData.finnish, all.length),
|
||||
],
|
||||
]
|
||||
|
||||
@@ -102,19 +169,39 @@ const getStudytrackDataForTheYear = async ({ studyprogramme, studytracks, studyt
|
||||
mainStatsByYear[year] = [
|
||||
...mainStatsByYear[year],
|
||||
[
|
||||
studytrackNames[track]?.name['fi'] || `${year} - ${year + 1}`,
|
||||
studytrackNames[track]?.name['fi'] ? `${studytrackNames[track]?.name['fi']}, ${track}` : year,
|
||||
all.length,
|
||||
getPercentage(all.length, all.length),
|
||||
started.length,
|
||||
getPercentage(started.length, started.length),
|
||||
studentData.male,
|
||||
getPercentage(studentData.male, started.length),
|
||||
studentData.female,
|
||||
getPercentage(studentData.female, started.length),
|
||||
studentData.finnish,
|
||||
getPercentage(studentData.finnish, started.length),
|
||||
getPercentage(started.length, all.length),
|
||||
enrolled.length,
|
||||
getPercentage(enrolled.length, all.length),
|
||||
absent.length,
|
||||
getPercentage(absent.length, all.length),
|
||||
cancelled.length,
|
||||
getPercentage(cancelled.length, all.length),
|
||||
graduated.length,
|
||||
getPercentage(graduated.length, started.length),
|
||||
getPercentage(graduated.length, all.length),
|
||||
studentData.male,
|
||||
getPercentage(studentData.male, all.length),
|
||||
studentData.female,
|
||||
getPercentage(studentData.female, all.length),
|
||||
studentData.finnish,
|
||||
getPercentage(studentData.finnish, all.length),
|
||||
],
|
||||
]
|
||||
|
||||
// Count stats for the graduation time charts grouped by year
|
||||
totalAmounts[track][year] = await all.length
|
||||
await getGraduationTimeStats({
|
||||
year,
|
||||
graduated,
|
||||
studyprogramme,
|
||||
track,
|
||||
graduationMedianTime,
|
||||
graduationMeanTime,
|
||||
graduationAmounts,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -133,27 +220,45 @@ const getStudytrackOptions = (studyprogramme, studytrackNames, studytracks, empt
|
||||
}
|
||||
|
||||
// Creates empty objects for each statistic type, which are then updated with the studytrack data
|
||||
const getEmptyStatsObjects = (years, studytracks) => {
|
||||
const mainStatsByYear = getYearsObject(years, true)
|
||||
const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
|
||||
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
|
||||
const mainStatsByTrack = {}
|
||||
const creditGraphStats = {}
|
||||
const creditTableStats = {}
|
||||
const graduationMedianTime = {}
|
||||
const graduationMeanTime = {}
|
||||
const graduationAmounts = {}
|
||||
const totalAmounts = {}
|
||||
const emptyTracks = new Map()
|
||||
|
||||
studytracks.forEach(async track => {
|
||||
mainStatsByTrack[track] = []
|
||||
creditGraphStats[track] = await getCreditGraphStats(years)
|
||||
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years)
|
||||
creditTableStats[track] = []
|
||||
graduationMedianTime[track] = getYearsObject({ years, emptyArrays: true })
|
||||
graduationMeanTime[track] = getYearsObject({ years, emptyArrays: true })
|
||||
graduationAmounts[track] = getYearsObject({ years })
|
||||
totalAmounts[track] = getYearsObject({ years })
|
||||
})
|
||||
|
||||
return { mainStatsByYear, mainStatsByTrack, creditGraphStats, creditTableStats, emptyTracks }
|
||||
return {
|
||||
mainStatsByYear,
|
||||
mainStatsByTrack,
|
||||
creditGraphStats,
|
||||
creditTableStats,
|
||||
graduationMedianTime,
|
||||
graduationMeanTime,
|
||||
graduationAmounts,
|
||||
totalAmounts,
|
||||
emptyTracks,
|
||||
}
|
||||
}
|
||||
|
||||
// Combines all the data for the Populations and Studytracks -view
|
||||
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme }) => {
|
||||
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, settings }) => {
|
||||
const isAcademicYear = true
|
||||
const since = getStartDate(studyprogramme, isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear())
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined)
|
||||
|
||||
const associations = await getAssociations()
|
||||
const studytracks = associations.programmes[studyprogramme]
|
||||
@@ -162,17 +267,21 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme }) => {
|
||||
|
||||
const studytrackNames = associations.studyTracks
|
||||
|
||||
const data = getEmptyStatsObjects(years, studytracks)
|
||||
const data = getEmptyStatsObjects(years, studytracks, studyprogramme)
|
||||
const { creditThresholdKeys, creditThresholdAmounts } = getCreditThresholds(studyprogramme)
|
||||
|
||||
await Promise.all(
|
||||
years.map(async year => {
|
||||
return await getStudytrackDataForTheYear({
|
||||
studyprogramme,
|
||||
settings,
|
||||
studytracks,
|
||||
studytrackNames,
|
||||
year,
|
||||
years,
|
||||
data,
|
||||
creditThresholdKeys,
|
||||
creditThresholdAmounts,
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -181,12 +290,19 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme }) => {
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years: getYearsArray(since.getFullYear(), isAcademicYear),
|
||||
years: getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined),
|
||||
mainStatsByTrack: data.mainStatsByTrack,
|
||||
mainStatsByYear: data.mainStatsByYear,
|
||||
creditTableStats: data.creditTableStats,
|
||||
creditGraphStats: data.creditGraphStats,
|
||||
graduationMedianTime: data.graduationMedianTime,
|
||||
graduationMeanTime: data.graduationMeanTime,
|
||||
graduationAmounts: data.graduationAmounts,
|
||||
totalAmounts: data.totalAmounts,
|
||||
studytrackOptions,
|
||||
includeGraduated: settings.graduated,
|
||||
populationTitles: tableTitles['studytracks'],
|
||||
creditTableTitles: tableTitles['creditProgress'][studyprogramme.includes('KH') ? 'bachelor' : 'master'],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
const { ThesisCourse } = require('../models/models_kone')
|
||||
|
||||
const createThesisCourse = (programme, course, type) =>
|
||||
ThesisCourse.create({
|
||||
programmeCode: programme,
|
||||
courseCode: course,
|
||||
thesisType: type,
|
||||
})
|
||||
|
||||
const deleteThesisCourse = (programme, course) =>
|
||||
ThesisCourse.destroy({
|
||||
where: {
|
||||
programmeCode: programme,
|
||||
courseCode: course,
|
||||
},
|
||||
})
|
||||
|
||||
const findProgrammeTheses = programme =>
|
||||
ThesisCourse.findAll({
|
||||
where: {
|
||||
programmeCode: programme,
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
createThesisCourse,
|
||||
deleteThesisCourse,
|
||||
findProgrammeTheses,
|
||||
}
|
||||
@@ -107,7 +107,9 @@ const findTopTeachers = async yearcode => {
|
||||
.filter(isRegularCourse)
|
||||
.map(credit => {
|
||||
const { credits, course, credittypecode } = credit
|
||||
const teachers = credit.teachers.map(({ id, name }) => ({ id, name }))
|
||||
const teachers = credit.teachers
|
||||
.filter(({ id }) => !id.includes('hy-hlo-org')) // Remove faculties from the leaderboards
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
const passed = Credit.passed(credit) || Credit.improved(credit)
|
||||
const failed = Credit.failed(credit)
|
||||
const transferred = credittypecode === 9
|
||||
|
||||
@@ -93,7 +93,9 @@ const calculateStatusGraduated = async (unixMillis, showByYear) => {
|
||||
// init faculty
|
||||
if (!acc[curr.faculty_code]) {
|
||||
const org = orgs.find(o => o.code === curr.faculty_code)
|
||||
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
|
||||
if (org) {
|
||||
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
|
||||
}
|
||||
}
|
||||
if (!acc[curr.faculty_code]['yearly'][data.year])
|
||||
acc[curr.faculty_code]['yearly'][data.year] = { acc: 0, total: 0 }
|
||||
|
||||
@@ -29,16 +29,36 @@ const getPassingSemester = (startYear, date) => {
|
||||
return yearCount < 6 ? `${yearCount}-${semester}` : 'LATER'
|
||||
}
|
||||
|
||||
const getAcademicYearDates = year => {
|
||||
const getAcademicYearDates = (year, yearsCombined) => {
|
||||
const startYear = typeof year === 'number' ? year : Number(year.slice(0, 4))
|
||||
const correctEndYear = yearsCombined ? new Date().getFullYear() : startYear
|
||||
return {
|
||||
startDate: `${year}-${semesterStart['FALL']}`,
|
||||
endDate: `${moment(year, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
|
||||
startDate: `${startYear}-${semesterStart['FALL']}`,
|
||||
endDate: `${moment(correctEndYear, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`,
|
||||
}
|
||||
}
|
||||
|
||||
const getYearStartAndEndDates = (year, isAcademicYear) => {
|
||||
if (isAcademicYear) {
|
||||
const firstYear = year.slice(0, 4)
|
||||
return {
|
||||
startDate: new Date(`${firstYear}-${semesterStart['FALL']}`).toISOString(),
|
||||
endDate: new Date(
|
||||
`${moment(firstYear, 'YYYY').add(1, 'years').format('YYYY')}-${semesterEnd['SPRING']}`
|
||||
).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: new Date(`${year}-01-01`).toISOString(),
|
||||
endDate: new Date(`${year}-12-31`).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPassingSemester,
|
||||
getAcademicYearDates,
|
||||
getYearStartAndEndDates,
|
||||
semesterEnd,
|
||||
semesterStart,
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -6837,9 +6837,9 @@
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
|
||||
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
|
||||
"version": "1.14.8",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
|
||||
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
@@ -15616,9 +15616,9 @@
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
0: {
|
||||
Attempts: `
|
||||
Table - Attempts
|
||||
Tables: {
|
||||
ATTEMPTS: `
|
||||
**Table - Attempts**
|
||||
|
||||
Taulukko näyttää suorituskerrat lukuvuositasolla.
|
||||
Samalla opiskelijalla voi olla useampia suorituskertoja.
|
||||
@@ -13,8 +13,8 @@ export default {
|
||||
Arvosanajakaumaa katsoessasi voit muuttaa luvut prosenteiksi, klikkaamalla painiketta "Relative".
|
||||
Mahdollinen sarake "Other passed" sisältää sekä arvosanan "Hyv." saaneet että kurssin hyväksilukeneet.
|
||||
`,
|
||||
Students: `
|
||||
Table - Students
|
||||
STUDENT: `
|
||||
**Table - Students**
|
||||
|
||||
Taulukko näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
|
||||
Jokainen opiskelija on jaoteltu luokkiin: "läpäissyt ensiyrittämällä", "läpäissyt lopulta" tai "ei koskaan läpäissyt kurssia".
|
||||
@@ -26,17 +26,17 @@ export default {
|
||||
Total-rivillä näkyy niiden opiskelijoiden kokonaismäärä, jotka ovat kurssista joskus arvosanan saaneet.
|
||||
`,
|
||||
},
|
||||
1: {
|
||||
Attempts: `
|
||||
Pass rate chart - Attempts
|
||||
PassRate: {
|
||||
ATTEMPTS: `
|
||||
**Pass rate chart - Attempts**
|
||||
|
||||
Graafi näyttää suorituskerrat lukuvuositasolla jaoteltuina hyväksyttyihin ja hylättyihin suorituksiin.
|
||||
Hyväksytyt suoritukset sisältävät arvosanat 1-5 sekä arvosanan Hyv.
|
||||
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
|
||||
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin).
|
||||
`,
|
||||
Students: `
|
||||
Pass rate chart - Students
|
||||
STUDENT: `
|
||||
**Pass rate chart - Students**
|
||||
|
||||
Graafi näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
|
||||
Jokainen opiskelija on jaoteltu luokkiin: "läpäissyt ensiyrittämällä", "läpäissyt lopulta" tai "ei koskaan läpäissyt kurssia".
|
||||
@@ -46,18 +46,9 @@ export default {
|
||||
myöhemmin keväällä 2019, tulisi merkityksi lukuvuodelle 2017-2018 pylvääseen "passed eventually".
|
||||
`,
|
||||
},
|
||||
2: {
|
||||
Attempts: `
|
||||
Grade distribution
|
||||
|
||||
Graafi näyttää suorituskertojen arvosanajakauman eri lukuvuosille. Mahdollinen kategoria
|
||||
"Other passed" sisältää sekä arvosanan "Hyv." että kurssin hyväksiluvut.
|
||||
`,
|
||||
Students: `
|
||||
Grade distribution
|
||||
GradeDistribution: `
|
||||
**Grade distribution**
|
||||
|
||||
Graafi näyttää suorituskertojen arvosanajakauman eri lukuvuosille. Mahdollinen kategoria
|
||||
"Other passed" sisältää sekä arvosanan "Hyv." että kurssin hyväksiluvut.
|
||||
`,
|
||||
},
|
||||
Graafi näyttää suorituskertojen arvosanajakauman eri lukuvuosille. Mahdollinen kategoria
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export default {
|
||||
},
|
||||
transferred: {
|
||||
label: 'Include and exclude students from this program',
|
||||
short: 'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet tutkinto-ohjelmaan',
|
||||
short:
|
||||
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
|
||||
},
|
||||
courseCredits: {
|
||||
label: null,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import PopulationStatistics from './populationStatistics'
|
||||
import PopulationOverview from './populationOverview'
|
||||
import CoolDataScience from './coolDataScience'
|
||||
import CourseStatistics from './courseStatistics'
|
||||
import CoursePopulation from './coursePopulation'
|
||||
@@ -7,7 +6,6 @@ import Studyprogramme from './studyprogramme'
|
||||
|
||||
export default {
|
||||
PopulationStatistics,
|
||||
PopulationOverview,
|
||||
CoolDataScience,
|
||||
CourseStatistics,
|
||||
CoursePopulation,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export default {
|
||||
PopulationProgress: `Tämä koontisivu sisältää koulutusohjelman kaikkien opiskelijoiden perustiedot lukuvuosittain. Luvut perustuvat opinto-oikeuksiin kyseisessä ohjelmassa.
|
||||
|
||||
Population progress:
|
||||
Vuosittain ohjelmassa aloittaneet, ohjelman keskeyttäneet / peruneet sekä ohjelmaan siirtyneet opiskelijat.
|
||||
Huomattavaa on, että sama opiskelija voi olla kahdessa luokassa, jos hän on esimerkiksi aloittanut opinnot jo, mutta keskeyttänyt ne lukuvuoden aikana.
|
||||
Tarkemmat tiedot saat esille klikkaamalla nuolta kunkin lukuvuoden kohdalla.`,
|
||||
YearlyProductivity: `
|
||||
Yearly productivity:
|
||||
Koulutusohjelman kunakin kalenterivuonna tuottamat opintopisteet, valmistuneiden määrän, ohjelman pääaineopiskelijoiden
|
||||
opintopisteet sekä muiden kuin pääaineopiskelijoiden opintopisteet. Mukana on myös kuhunkin koulutusohjelmaan hyväksiluetut opintopisteet.
|
||||
|
||||
Vanhojen koulutusohjelmien lakkauttaminen ja opiskelijoiden siirtäminen uusiin ohjelmiin voi aiheuttaa tavallista suuremmat opintopistemäärät lukuvuodelle 2020-2021.`,
|
||||
Year: `Lukuvuosi`,
|
||||
Students: `Opiskelijat, jotka ovat koulutusohjelmassa aloittaneet, opinto-oikeutensa peruneet sekä koulutusohjelmaan tai siitä pois siirtyneet.`,
|
||||
Started: `Koulutusohjelmassa kunakin lukuvuonna aloittaneet opiskelijat. EI sisällä ohjelmaan siirtyneitä opiskelijoita.`,
|
||||
Cancelled: `Koulutusohjelmaan oikeuttavan opinto-oikeutensa kunakin lukuvuonna peruttaneet`,
|
||||
Graduated: `Tiettynä lukuvuonna aloittaneista opiskelijoista on valmistunut`,
|
||||
Transferred: `Koulutusohjelmaan kyseisenä lukuvuonna siirtyneet opiskelijat.
|
||||
|
||||
Vanhojen koulutusohjelmien lakkauttaminen aiheuttaa tavallista suuremmat siirtoluvut lukuvuodelle 2020-2021.`,
|
||||
Credits: `Kunakin vuonna aloittaneista opiskelijoista ne, jotka ovat ylittäneet tietyn opintopisterajapyykin`,
|
||||
Thesis: `Loppututkielman kirjoittaneiden määrä`,
|
||||
}
|
||||
@@ -1,43 +1,76 @@
|
||||
export default {
|
||||
YearToggle: `
|
||||
Academic year: Näyttää tilastot akateemisille vuosille jaoteltuna, esimerkiksi 1.8.2019-31.7.2020
|
||||
Calendar year: Näyttää tilastot kalenterivuosille jaoteltuna, esimerkiksi 1.1.2019-31.12.2019
|
||||
`,
|
||||
StudentToggle: `
|
||||
All studyrights: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat, opintonsa keskeyttäneet opiskelijat, ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
|
||||
|
||||
Special studyrights excluded: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
|
||||
`,
|
||||
GraduatedToggle: `
|
||||
Graduated included: Valinnassa ovat mukana ohjelmasta jo valmistuneet opiskelijat.
|
||||
|
||||
Graduated excluded: Valinnasta on poistettu ohjelmasta jo valmistuneet opiskelijat.
|
||||
`,
|
||||
StudentsOfTheStudyprogramme: `
|
||||
**Started**: Sisältää kyseisenä vuonna aloitetut opinto-oikeudet riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
|
||||
**All**: Sisältää kaikki kyseisenä vuonna alkaneet opinto-oikeudet riippumatta siitä, onko opintoja aloitettu tai onko niitä aloitettu ko. vuonna. Mukana myös ohjelmaan siirtyneet opiskelijat.
|
||||
|
||||
**Started studying**: Sisältää kyseisenä vuonna aloittaneet opiskelijat (aloitetut opinto-oikeudet) riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut myöhemmin keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
|
||||
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
|
||||
|
||||
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
|
||||
|
||||
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opinto-oikeudet. Sama opiskelija voi esiintyä tilastoissa useana vuonna. Kyseiset opiskelijat ovat saattaneet aloittaa ohjelmassa tai eivät koskaan ole aloittaneet ohjelmassa.
|
||||
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opiskelijat (opinto-oikeudet). Sama opiskelija voi esiintyä tilastoissa useana vuonna, mikäli hän on siirtynyt ohjelmaan ja siitä pois useasti.
|
||||
|
||||
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opinto-oikeudet.
|
||||
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opiskelijat (opinto-oikeudet).
|
||||
|
||||
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
|
||||
`,
|
||||
CreditsProducedByTheStudyprogramme: `
|
||||
Sisältää opintopisteet suoritusvuosittain (Sisussa näkyvä kurssin suorituspäivä)
|
||||
Sisältää opintopisteet suoritusvuosittain. Suoritukset on jaoteltu Sisussa näkyvän kurssin suorituspäivän mukaan.
|
||||
|
||||
**Major students' credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan.
|
||||
**Major students' credits**: Sisältää ne opintopisteet, joiden suorittajalla on suoritushetkellä ollut ensisijainen opinto-oikeus kyseiseen koulutusohjelmaan. Sisältää myös ohjelmaan siirtyneet ensisijaiset opiskelijat.
|
||||
|
||||
**Non-major students' credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan.
|
||||
**Non-major students' credits**: Sisältää koulutusohjelman tuottamat opintopisteet, joiden suorittajalla EI ole ollut suoritushetkellä ensisijaista opinto-oikeutta kyseiseen koulutusohjelmaan. Sisältää
|
||||
esimerkiksi vaihto-opiskelijat ja erillis-opinto-oikeudella opiskelevat. Sisältää myös opintopisteet, jotka opiskelija on suorittanut koulutusohjelmaan ennen opintojen aloittamista koulutusohjelmassa tai opintojen päätyttyä.
|
||||
|
||||
**Transferred credits**: Sisältää opintopisteet, jotka on suoritettu kyseisenä vuonna, ja hyväksiluettu tähän koulutusohjelmaan.
|
||||
**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.
|
||||
`,
|
||||
GraduatedAndThesisWritersOfTheProgramme: `
|
||||
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
|
||||
**Wrote thesis**: Sisältää kyseisenä vuonna kandidaatintutkielman tai pro gradu-tutkielman hyväksytysti suorittaneet opiskelijat. Mukana ei ole mahdollisia arvosanan korotuksia.
|
||||
|
||||
**Graduated**: Sisältää kyseisenä vuonna valmistuneet opiskelijat riippumatta siitä, minä vuonna opiskeluoikeus ohjelmaan on myönnetty. Jaottelu on tehty opinto-oikeuden päättymispäivän (=valmistumispäivän) perusteella.
|
||||
`,
|
||||
AverageGraduationTimes: `
|
||||
**Median time**: Näyttää kyseisenä vuonna valmistuneiden opintojen keston **mediaanin** kuukausina.
|
||||
**Median time**: Näyttää kyseisenä vuonna valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Luku sisältää mahdolliset poissaolokuukaudet.
|
||||
|
||||
**Mean time**: Näyttää kyseisenä vuonna valmistuneiden opintojen keston **keskiarvon** kuukausina.
|
||||
**Mean time**: Näyttää kyseisenä vuonna valmistuneiden opiskelijoiden opintojen keston **keskiarvon** kuukausina. Luku sisältää mahdolliset poissaolokuukaudet.
|
||||
|
||||
Mukana ovat **vain valmistuneet opiskelijat**. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
|
||||
StudytrackOverview: `
|
||||
Taulukko näyttää kyseisenä lukuvuonna aloittaneet pääaineopiskelijat. Taulukko näyttää myös näistä opiskelijoista miesten, naisten ja suomalaisten osuudet sekä valmistuneiden määrän.
|
||||
n = **valmistuneet opiskelijat / kaikki ohjelmassa kyseisenä vuonna alkaneet opinto-oikeudet**. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
|
||||
ProgrammesBeforeOrAfter: `
|
||||
**Mikäli valittuna on kandiohjelma:** Sisältää maisteriohjelmat, joissa tämän kandiohjelman opiskelijat ovat aloittaneet valmistuttuaan kandeiksi. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
|
||||
|
||||
Mukana ovat **vain valmistuneet opiskelijat ja vain maisteriohjelmat.**
|
||||
|
||||
**Mikäli valittuna on maisteriohjelma:** Sisältää kandiohjelmat, joissa tämän ohjelman opiskelijat ovat opiskelleet ennen tuloaan maisteriohjelmaan. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
|
||||
`,
|
||||
StudytrackOverview: `
|
||||
Kuvaa koulutusohjelman kyseiseen ohjelmaan opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2020-2021 aloittaneiden opintopistejakaumaa.
|
||||
|
||||
HUOM!
|
||||
**All** Sisältää kaikki opiskelijat, joiden opinto-oikeudella on aloituspäivä kyseisenä vuonna, riippumatta siitä, onko opiskelija aloittanut opiskelemaan ko. vuonna.
|
||||
**Started studying** Sisältää kaikki opiskelijat, jotka ovat aloittaneet opiskelemaan koulutusohjelmassa kyseisenä lukuvuonna.
|
||||
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, tutkinnonsuoritusoikeudesta luopuneet ja erillisopinto-oikeudella opiskelevat.
|
||||
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
|
||||
kuvaavat tällöin kyseisen opintosuunnan opiskelijoita.
|
||||
`,
|
||||
StudytrackProgress: `
|
||||
Lähtökohtaisesti kuvaa koulutusohjelman kaikkien pääaineoopiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2020-2021 aloittaneiden opintopistejakaumaa.
|
||||
Kuvaa koulutusohjelman kyseiseen ohjelmaan opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna. Esimerkiksi 2020-2021 aloittaneiden opintopistejakaumaa.
|
||||
Mukana ovat oletusarvoisesti myös vaihto-opiskelijat, tutkinnonsuoritusoikeudesta luopuneet ja erillisopinto-oikeudella opiskelevat.
|
||||
|
||||
Mikäli yläosan valikosta valittuna on jokin tietty opintosuunta, myös nämä graafit ja taulukot kuvaavat kyseisen opintosuunnan opiskelijoita.
|
||||
`,
|
||||
|
||||
@@ -101,6 +101,22 @@ export const getTextIn = (texts, language) => {
|
||||
return null
|
||||
}
|
||||
|
||||
export const getUnifyTextIn = unify => {
|
||||
switch (unify) {
|
||||
case 'reqularStats':
|
||||
return ''
|
||||
|
||||
case 'openStats':
|
||||
return 'Open University: '
|
||||
|
||||
case 'unifyStats':
|
||||
return 'Unified with open uni: '
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const cancelablePromise = promise => {
|
||||
let hasCanceled = false
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ const StatusContainer = ({
|
||||
max1,
|
||||
showYearlyValues,
|
||||
showRelativeValues,
|
||||
showCountingFrom,
|
||||
showByYear,
|
||||
showStudentCounts,
|
||||
clickable,
|
||||
@@ -84,11 +85,31 @@ const StatusContainer = ({
|
||||
let yearlyValues = null
|
||||
|
||||
if (showYearlyValues) {
|
||||
yearlyValues = _.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => ({
|
||||
label: showByYear ? year : `${year}-${`${Number(year) + 1}`.slice(-2)}`,
|
||||
accumulated: showStudentCounts && stats.type === 'course' ? yearStats.accStudents : yearStats.acc,
|
||||
total: showStudentCounts && stats.type === 'course' ? yearStats.totalStudents : yearStats.total,
|
||||
}))
|
||||
yearlyValues = _.chain(stats.yearly)
|
||||
.toPairs()
|
||||
.map(([year, stats]) => ({
|
||||
dateRange: {
|
||||
start: showByYear ? moment([year]).startOf('year') : moment([year]).month(7).date(1),
|
||||
end: showByYear
|
||||
? moment([year]).startOf('year')
|
||||
: moment([parseInt(year, 10) + 1])
|
||||
.month(6)
|
||||
.date(31),
|
||||
},
|
||||
stats,
|
||||
}))
|
||||
.filter(({ dateRange: { start } }) => moment(showCountingFrom).isSameOrAfter(start))
|
||||
.orderBy(({ dateRange: { start } }) => start, ['desc'])
|
||||
.map(({ dateRange, stats }) => ({
|
||||
label: _.chain([dateRange.start, dateRange.end])
|
||||
.map(date => date.year())
|
||||
.uniq()
|
||||
.join('-')
|
||||
.value(),
|
||||
accumulated: showStudentCounts && stats.type === 'course' ? stats.accStudents : stats.acc,
|
||||
total: showStudentCounts && stats.type === 'course' ? stats.totalStudents : stats.total,
|
||||
}))
|
||||
.value()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,7 +129,7 @@ const StatusContainer = ({
|
||||
}
|
||||
|
||||
const StatusContent = ({ data, settings }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
|
||||
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts, showCountingFrom } = settings
|
||||
|
||||
return (
|
||||
<DrillStack
|
||||
@@ -124,6 +145,7 @@ const StatusContent = ({ data, settings }) => {
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
showStudentCounts={showStudentCounts}
|
||||
showCountingFrom={showCountingFrom}
|
||||
min1={-2 * medianDiff}
|
||||
max1={2 * medianDiff}
|
||||
showByYear={showByYear}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const CoursePopulationCreditDist = ({ singleCourseStats, pending, selectedStuden
|
||||
const [courseGrades, setCourseGrades] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (samples && singleCourseStats.alternatives) {
|
||||
if (samples && singleCourseStats?.unifyStats?.alternatives) {
|
||||
const filteredGradeArray = []
|
||||
const grades = {}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import { getStudentToTargetCourseDateMap, getTextIn } from '../../common'
|
||||
import { getStudentToTargetCourseDateMap, getTextIn, getUnifyTextIn } from '../../common'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import {
|
||||
@@ -47,9 +47,9 @@ const CoursePopulation = ({
|
||||
getSemestersDispatch,
|
||||
semesters,
|
||||
getFacultiesDispatch,
|
||||
unifyOpen,
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [codes, setCodes] = useState([])
|
||||
const [headerYears, setYears] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState(null)
|
||||
@@ -124,7 +124,8 @@ const CoursePopulation = ({
|
||||
}
|
||||
}, [semesters])
|
||||
|
||||
const header = courseData ? `${getTextIn(courseData.name, language)} ${headerYears}` : null
|
||||
const avoin = getUnifyTextIn(unifyOpen)
|
||||
const header = courseData ? `${avoin} ${getTextIn(courseData.name, language)} ${headerYears}` : null
|
||||
|
||||
const subHeader = codes.join(', ')
|
||||
|
||||
@@ -364,11 +365,12 @@ const CustomPopulationCoursesWrapper = props => {
|
||||
return <CustomPopulationCourses {...props} courses={courseStatistics} />
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ singleCourseStats, populations, semesters }) => {
|
||||
const mapStateToProps = ({ singleCourseStats, populations, semesters, courseSearch }) => {
|
||||
return {
|
||||
studentData: populations.data,
|
||||
pending: populations.pending,
|
||||
courseData: singleCourseStats.stats || {},
|
||||
courseData: singleCourseStats.stats?.[courseSearch.openOrReqular],
|
||||
unifyOpen: courseSearch.openOrReqular,
|
||||
semesters: semesters.data,
|
||||
elementDetails: populations?.data?.elementdetails?.data,
|
||||
}
|
||||
|
||||
@@ -84,11 +84,13 @@ const CourseTable = ({ course, courseInstance, language, selectedYear }) => {
|
||||
|
||||
const FacultyLevelStatistics = () => {
|
||||
const { language: activeLanguage } = useLanguage()
|
||||
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
|
||||
const { courseStats, language } = useSelector(({ courseStats }) => ({
|
||||
courseStats: courseStats.data,
|
||||
language: activeLanguage,
|
||||
}))
|
||||
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c.facultyStats))))
|
||||
|
||||
const yearcodes = uniq(flatten(Object.values(courseStats).map(c => Object.keys(c[openOrReqular].facultyStats))))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -103,13 +105,13 @@ const FacultyLevelStatistics = () => {
|
||||
const yearsCourseStats = Object.values(courseStats)
|
||||
.map(course => ({
|
||||
course,
|
||||
courseInstance: course.facultyStats[selectedYear],
|
||||
courseInstance: course[openOrReqular].facultyStats[selectedYear],
|
||||
}))
|
||||
.sort((a, b) => (a.courseInstance === undefined) - (b.courseInstance === undefined))
|
||||
|
||||
const courseTables = yearsCourseStats.map(({ course, courseInstance }) => (
|
||||
<CourseTable
|
||||
course={course}
|
||||
course={course[openOrReqular]}
|
||||
courseInstance={courseInstance}
|
||||
language={language}
|
||||
key={course.coursecode}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { Popup, Button, Icon } from 'semantic-ui-react'
|
||||
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
|
||||
export default ({ tab, viewMode }) => {
|
||||
let content = infotooltips.CourseStatistics[tab]
|
||||
|
||||
if (viewMode) {
|
||||
content = content[viewMode]
|
||||
}
|
||||
|
||||
content = content.replace(/\n +/g, '\n')
|
||||
|
||||
return (
|
||||
<Popup
|
||||
on="click"
|
||||
wide="very"
|
||||
trigger={
|
||||
<Button icon labelPosition="left">
|
||||
<Icon name="question circle outline" />
|
||||
Help
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
+7
-25
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import qs from 'query-string'
|
||||
import _, { uniq } from 'lodash'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { arrayOf, number, oneOfType, shape, string, bool } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import SortableTable from '../../../../SortableTable'
|
||||
import { defineCellColor, getGradeSpread, getThesisGradeSpread, isThesisGrades, THESIS_GRADE_KEYS } from '../util'
|
||||
|
||||
@@ -40,7 +40,7 @@ const getTableData = (stats, notThesisGrades, isRelative) =>
|
||||
passRate: stat.attempts.passRate,
|
||||
attempts,
|
||||
rowObfuscated,
|
||||
...gradeSpread,
|
||||
..._.mapValues(gradeSpread, x => x[0]),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,14 +69,12 @@ const getGradeColumns = (notThesisGrades, addHTAndTT) => {
|
||||
}
|
||||
|
||||
const AttemptsTable = ({
|
||||
stats,
|
||||
name,
|
||||
data: { stats, name },
|
||||
settings: { showGrades, separate },
|
||||
alternatives,
|
||||
separate,
|
||||
isRelative,
|
||||
userHasAccessToAllStats,
|
||||
headerVisible = false,
|
||||
showGrades,
|
||||
}) => {
|
||||
const {
|
||||
attempts: { grades },
|
||||
@@ -145,9 +143,8 @@ const AttemptsTable = ({
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
defaultdescending
|
||||
getRowKey={s => s.code}
|
||||
tableProps={{ celled: true, structured: true }}
|
||||
title={`Yearly attempt statistics for group ${name}`}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
@@ -158,19 +155,4 @@ const AttemptsTable = ({
|
||||
)
|
||||
}
|
||||
|
||||
AttemptsTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired,
|
||||
alternatives: arrayOf(string).isRequired,
|
||||
separate: bool,
|
||||
isRelative: bool.isRequired,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
headerVisible: bool.isRequired,
|
||||
showGrades: bool.isRequired,
|
||||
}
|
||||
|
||||
AttemptsTable.defaultProps = {
|
||||
separate: false,
|
||||
}
|
||||
|
||||
export default connect(null)(AttemptsTable)
|
||||
|
||||
+88
-88
@@ -1,16 +1,16 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import qs from 'query-string'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Header, Icon, Item } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { uniq } from 'lodash'
|
||||
import { shape, string, number, oneOfType, arrayOf, bool } from 'prop-types'
|
||||
import { string, object, arrayOf, bool } from 'prop-types'
|
||||
import SortableTable from '../../../../SortableTable'
|
||||
import { defineCellColor } from '../util'
|
||||
|
||||
const formatPercentage = p => `${(p * 100).toFixed(2)} %`
|
||||
|
||||
const StudentTable = ({ stats, name, alternatives, separate, userHasAccessToAllStats, headerVisible = false }) => {
|
||||
const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate) => {
|
||||
const showPopulation = (yearcode, years) => {
|
||||
const queryObject = {
|
||||
from: yearcode,
|
||||
@@ -23,6 +23,86 @@ const StudentTable = ({ stats, name, alternatives, separate, userHasAccessToAllS
|
||||
return `/coursepopulation?${searchString}`
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'TIME',
|
||||
title: 'Time',
|
||||
getRowVal: s => s.code,
|
||||
getRowExportVal: s => s.name,
|
||||
getRowContent: s => (
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
{s.name}
|
||||
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
|
||||
{s.name !== 'Total' && userHasAccessToAllStats && (
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
},
|
||||
{
|
||||
key: 'TOTAL',
|
||||
title: 'Total Students',
|
||||
getRowVal: s => (s.rowObfuscated ? '5 or less students' : s.students.total),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
},
|
||||
{
|
||||
key: 'PASS_RATE',
|
||||
title: 'Pass-%',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.passRate),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.passRate || 0)),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
},
|
||||
{
|
||||
key: 'PASS_FIRST',
|
||||
title: 'On First Attempt',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedFirst || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
headerProps: { style: { borderLeft: '0' } },
|
||||
onlyInDetailedView: true,
|
||||
},
|
||||
{
|
||||
key: 'PASS_EVENTUALLY',
|
||||
title: 'Eventually',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedEventually || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
headerProps: { style: { borderLeft: '0' } },
|
||||
onlyInDetailedView: true,
|
||||
},
|
||||
{
|
||||
key: 'FAIL_RATE',
|
||||
title: 'Fail-%',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.failRate),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.failRate || 0)),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
},
|
||||
{
|
||||
key: 'NEVER PASSED',
|
||||
title: 'Yet to Pass',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.neverPassed || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
headerProps: { style: { borderLeft: '0' } },
|
||||
onlyInDetailedView: true,
|
||||
},
|
||||
]
|
||||
|
||||
return columns.filter(column => showDetails || !column.onlyInDetailedView)
|
||||
}
|
||||
|
||||
const StudentTable = ({
|
||||
data: { name, stats },
|
||||
settings: { showDetails, separate },
|
||||
alternatives,
|
||||
userHasAccessToAllStats,
|
||||
headerVisible = false,
|
||||
}) => {
|
||||
const columns = useMemo(
|
||||
() => getColumns(showDetails, userHasAccessToAllStats, alternatives, separate),
|
||||
[showDetails, userHasAccessToAllStats, alternatives, separate]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{headerVisible && (
|
||||
@@ -31,90 +111,12 @@ const StudentTable = ({ stats, name, alternatives, separate, userHasAccessToAllS
|
||||
</Header>
|
||||
)}
|
||||
<SortableTable
|
||||
title={`Student statistics for group ${name}`}
|
||||
defaultSort={['TIME', 'desc']}
|
||||
defaultdescending
|
||||
getRowKey={s => s.code}
|
||||
tableProps={{ celled: true, structured: true }}
|
||||
columns={[
|
||||
{
|
||||
key: 'TIME',
|
||||
title: 'Time',
|
||||
getRowVal: s => s.code,
|
||||
getRowContent: s => (
|
||||
<div>
|
||||
{s.name}
|
||||
{s.name === 'Total' && !userHasAccessToAllStats && <strong>*</strong>}
|
||||
{s.name !== 'Total' && userHasAccessToAllStats && (
|
||||
<Item as={Link} to={showPopulation(s.code, s.name, s)}>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
headerProps: { rowSpan: 2, width: 3 },
|
||||
},
|
||||
{
|
||||
key: 'TOTAL',
|
||||
title: 'All students',
|
||||
getRowVal: s => (s.rowObfuscated ? '5 or less students' : s.students.total),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
headerProps: { rowSpan: 2, width: 3 },
|
||||
},
|
||||
{
|
||||
key: 'PASSED',
|
||||
title: 'Passed',
|
||||
parent: true,
|
||||
headerProps: { colSpan: 3, width: 5 },
|
||||
},
|
||||
{
|
||||
key: 'PASS_FIRST',
|
||||
title: 'first try',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedFirst || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
cellProps: { width: 2 },
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'PASS_EVENTUALLY',
|
||||
title: 'passed eventually',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.passedEventually || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
cellProps: { width: 2 },
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'PASS_RATE',
|
||||
title: 'percentage',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.passRate),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.passRate || 0)),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
cellProps: { width: 1 },
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'FAIL',
|
||||
title: 'Failed',
|
||||
parent: true,
|
||||
headerProps: { colSpan: 3, width: 5 },
|
||||
},
|
||||
{
|
||||
key: 'NEVER PASSED',
|
||||
title: 'never passed',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.categories.neverPassed || 0),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
cellProps: { width: 2 },
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'FAIL_RATE',
|
||||
title: 'percentage',
|
||||
getRowVal: s => (s.rowObfuscated ? 'NA' : s.students.failRate),
|
||||
getRowContent: s => (s.rowObfuscated ? 'NA' : formatPercentage(s.students.failRate || 0)),
|
||||
getCellProps: s => defineCellColor(s),
|
||||
cellProps: { width: 1 },
|
||||
child: true,
|
||||
},
|
||||
]}
|
||||
tableProps={{ celled: true, fixed: true }}
|
||||
columns={columns}
|
||||
data={stats}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
@@ -125,12 +127,10 @@ const StudentTable = ({ stats, name, alternatives, separate, userHasAccessToAllS
|
||||
}
|
||||
|
||||
StudentTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired,
|
||||
data: object.isRequired,
|
||||
alternatives: arrayOf(string).isRequired,
|
||||
separate: bool,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
headerVisible: bool.isRequired,
|
||||
}
|
||||
|
||||
StudentTable.defaultProps = {
|
||||
|
||||
+39
-54
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable quotes */
|
||||
import React from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { bool } from 'prop-types'
|
||||
import { Menu, Radio } from 'semantic-ui-react'
|
||||
import HelpButton from '../HelpButton'
|
||||
import { gradeGraphOptions } from '../../../../constants'
|
||||
import {
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
dataSeriesType,
|
||||
getGradeSpread,
|
||||
getThesisGradeSpread,
|
||||
isThesisSeries,
|
||||
@@ -71,61 +70,47 @@ const getGradeSeries = series => {
|
||||
}
|
||||
}
|
||||
|
||||
const Distribution = ({ primary, comparison, isRelative, userHasAccessToAllStats }) => {
|
||||
const primaryStats = primary.stats.filter(stat => stat.name !== 'Total' || isRelative)
|
||||
|
||||
const statYears = primaryStats.map(year => year.name)
|
||||
const comparisonStats = comparison ? comparison.stats.filter(stat => stat.name !== 'Total' || isRelative) : []
|
||||
const primaryGrades = primaryStats.flatMap(s => s.attempts.grades)
|
||||
const comparisonGrades = comparisonStats.flatMap(s => s.attempts.grades)
|
||||
|
||||
const gradeGraphSeries = getGradeSeries(primaryGrades)
|
||||
const comparisonGraphSeries = getGradeSeries(comparisonGrades)
|
||||
|
||||
const maxGradeValue = isRelative ? 1 : getMaxValueOfSeries(gradeGraphSeries.absolute)
|
||||
|
||||
const primaryDistributionOptions = comparison
|
||||
? gradeGraphOptions(statYears, maxGradeValue, 'Primary Grades')
|
||||
: gradeGraphOptions(statYears, maxGradeValue, 'Grades')
|
||||
const comparisonDistributionOptions = gradeGraphOptions(statYears, maxGradeValue, 'Comparison Grades')
|
||||
export const DistributionSettings = ({ value, onChange }) => {
|
||||
const { isRelative } = value
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={primaryDistributionOptions}
|
||||
series={isRelative ? gradeGraphSeries.relative : gradeGraphSeries.absolute}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{comparison && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={comparisonDistributionOptions}
|
||||
series={isRelative ? comparisonGraphSeries.relative : comparisonGraphSeries.absolute}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)}
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are shown as 0 in the chart</span>
|
||||
)}
|
||||
</>
|
||||
<Menu secondary style={{ marginBottom: 0 }}>
|
||||
<Menu.Item>
|
||||
<Radio
|
||||
toggle
|
||||
label="Show relative"
|
||||
checked={isRelative}
|
||||
onChange={() => onChange({ ...value, isRelative: !isRelative })}
|
||||
/>
|
||||
<Menu.Item>
|
||||
<HelpButton tab="GradeDistribution" />
|
||||
</Menu.Item>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
Distribution.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
isRelative: bool,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
}
|
||||
export const Distribution = ({ data, settings: { isRelative }, userHasAccessToAllStats }) => {
|
||||
const stats = data.stats.filter(stat => stat.name !== 'Total' || isRelative)
|
||||
|
||||
Distribution.defaultProps = {
|
||||
comparison: undefined,
|
||||
isRelative: false,
|
||||
}
|
||||
const statYears = stats.map(year => year.name)
|
||||
const grades = stats.flatMap(s => s.attempts.grades)
|
||||
|
||||
export default Distribution
|
||||
const gradeGraphSeries = getGradeSeries(grades)
|
||||
|
||||
const maxGradeValue = isRelative ? 1 : getMaxValueOfSeries(gradeGraphSeries.absolute)
|
||||
|
||||
const primaryDistributionOptions = gradeGraphOptions(statYears, maxGradeValue, 'Grades')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StackedBarChart
|
||||
options={primaryDistributionOptions}
|
||||
series={isRelative ? gradeGraphSeries.relative : gradeGraphSeries.absolute}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are shown as 0 in the chart</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
/* eslint-disable quotes */
|
||||
import React from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { bool } from 'prop-types'
|
||||
import { Menu, Radio } from 'semantic-ui-react'
|
||||
import StackedBarChart from '../../../StackedBarChart'
|
||||
import { passRateAttemptGraphOptions, passRateStudGraphOptions } from '../../../../constants'
|
||||
import {
|
||||
viewModeNames,
|
||||
getDataObject,
|
||||
getMaxValueOfSeries,
|
||||
dataSeriesType,
|
||||
viewModeType,
|
||||
absoluteToRelative,
|
||||
} from './util'
|
||||
import HelpButton from '../HelpButton'
|
||||
import { viewModeNames, getDataObject, getMaxValueOfSeries, absoluteToRelative } from './util'
|
||||
|
||||
const getPassRateAttemptSeriesFromStats = stats => {
|
||||
const all = []
|
||||
@@ -70,62 +63,62 @@ const getPassRateStudSeriesFromStats = stats => {
|
||||
}
|
||||
}
|
||||
|
||||
const PassRate = ({ primary, comparison, viewMode, isRelative = false, userHasAccessToAllStats }) => {
|
||||
const isAttemptsMode = viewMode === viewModeNames.ATTEMPTS
|
||||
|
||||
const primaryStats = primary.stats.filter(stat => stat.name !== 'Total')
|
||||
const statYears = primaryStats.map(year => year.name)
|
||||
const comparisonStats = comparison ? comparison.stats : []
|
||||
const passGraphSerieFn = isAttemptsMode ? getPassRateAttemptSeriesFromStats : getPassRateStudSeriesFromStats
|
||||
|
||||
const passGraphSerie = passGraphSerieFn(primaryStats)
|
||||
const comparisonGraphSerie = passGraphSerieFn(comparisonStats)
|
||||
|
||||
const maxPassRateVal = isRelative ? 1 : getMaxValueOfSeries(passGraphSerie.absolute)
|
||||
const graphOptionsFn = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudGraphOptions
|
||||
const primaryGraphOptions = comparison
|
||||
? graphOptionsFn(statYears, maxPassRateVal, 'Primary pass rate chart', isRelative)
|
||||
: graphOptionsFn(statYears, maxPassRateVal, 'Pass rate chart', isRelative)
|
||||
const comparisonGraphOptions = graphOptionsFn(statYears, maxPassRateVal, 'Comparison pass rate chart', isRelative)
|
||||
export const PassRateSettings = ({ value, onChange }) => {
|
||||
const { viewMode, isRelative } = value
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={primaryGraphOptions}
|
||||
series={isRelative ? passGraphSerie.relative : passGraphSerie.absolute}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{comparison && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<StackedBarChart
|
||||
options={comparisonGraphOptions}
|
||||
series={isRelative ? comparisonGraphSerie.relative : comparisonGraphSerie.absolute}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)}
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are shown as 0 in the chart</span>
|
||||
)}
|
||||
</>
|
||||
<Menu secondary style={{ marginBottom: 0 }}>
|
||||
{Object.entries(viewModeNames).map(([key, name]) => (
|
||||
<Menu.Item
|
||||
active={viewMode === key}
|
||||
name={name}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
viewMode: key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Menu.Item>
|
||||
<Radio
|
||||
toggle
|
||||
label="Show relative"
|
||||
checked={isRelative}
|
||||
onChange={() => onChange({ ...value, isRelative: !isRelative })}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<HelpButton tab="PassRate" viewMode={viewMode} />
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
PassRate.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired,
|
||||
isRelative: bool,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
}
|
||||
export const PassRate = ({ data, settings: { viewMode, isRelative }, userHasAccessToAllStats }) => {
|
||||
const isAttemptsMode = viewMode === 'ATTEMPTS'
|
||||
|
||||
PassRate.defaultProps = {
|
||||
comparison: undefined,
|
||||
isRelative: false,
|
||||
}
|
||||
const stats = data.stats.filter(stat => stat.name !== 'Total')
|
||||
const statYears = stats.map(year => year.name)
|
||||
// const comparisonStats = comparison ? comparison.stats : []
|
||||
const passGraphSerieFn = isAttemptsMode ? getPassRateAttemptSeriesFromStats : getPassRateStudSeriesFromStats
|
||||
|
||||
export default PassRate
|
||||
const passGraphSerie = passGraphSerieFn(stats)
|
||||
// const comparisonGraphSerie = passGraphSerieFn(comparisonStats)
|
||||
|
||||
const maxPassRateVal = isRelative ? 1 : getMaxValueOfSeries(passGraphSerie.absolute)
|
||||
const graphOptionsFn = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudGraphOptions
|
||||
const primaryGraphOptions = graphOptionsFn(statYears, maxPassRateVal, 'Pass rate chart', isRelative)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StackedBarChart
|
||||
options={primaryGraphOptions}
|
||||
series={isRelative ? passGraphSerie.relative : passGraphSerie.absolute}
|
||||
/>
|
||||
{!userHasAccessToAllStats && (
|
||||
<span className="totalsDisclaimer">* Years with 5 students or less are shown as 0 in the chart</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +1,110 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { string, arrayOf, bool } from 'prop-types'
|
||||
import { dataSeriesType, viewModeNames, viewModeType } from './util'
|
||||
import React from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Menu, Radio, Form } from 'semantic-ui-react'
|
||||
import { viewModeNames } from './util'
|
||||
import HelpButton from '../HelpButton'
|
||||
import StudentTable from './Tables/student'
|
||||
import AttemptsTable from './Tables/attempts'
|
||||
import { toggleOpenAndReqularCourses } from '../../../../redux/coursesearch'
|
||||
import selectors from '../../../../selectors/courseStats'
|
||||
|
||||
const Tables = ({
|
||||
primary,
|
||||
comparison,
|
||||
viewMode,
|
||||
alternatives,
|
||||
separate,
|
||||
isRelative,
|
||||
showGrades,
|
||||
userHasAccessToAllStats,
|
||||
}) => {
|
||||
const getViewMode = statistics => {
|
||||
const { name, stats } = statistics
|
||||
const headerVisible = !!comparison
|
||||
export const TablesSettings = ({ value, onChange }) => {
|
||||
const { viewMode, showDetails, showGrades } = value
|
||||
const dispatch = useDispatch()
|
||||
|
||||
switch (viewMode) {
|
||||
case viewModeNames.ATTEMPTS:
|
||||
return (
|
||||
<AttemptsTable
|
||||
separate={separate}
|
||||
name={name}
|
||||
stats={stats}
|
||||
alternatives={alternatives}
|
||||
isRelative={isRelative}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
headerVisible={headerVisible}
|
||||
showGrades={showGrades}
|
||||
/>
|
||||
)
|
||||
case viewModeNames.STUDENT:
|
||||
return (
|
||||
<StudentTable
|
||||
separate={separate}
|
||||
name={name}
|
||||
stats={stats}
|
||||
alternatives={alternatives}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
headerVisible={headerVisible}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
|
||||
const toggleUnifyRadioValue = (event, { value }) => {
|
||||
dispatch(toggleOpenAndReqularCourses(value))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid.Column id="PrimaryDataTable">{getViewMode(primary)}</Grid.Column>
|
||||
{comparison && <Grid.Column id="ComparisonDataTable">{getViewMode(comparison)}</Grid.Column>}
|
||||
</>
|
||||
<div>
|
||||
<Menu secondary>
|
||||
{Object.entries(viewModeNames).map(([key, name]) => (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
active={viewMode === key}
|
||||
data-cy={`viewMode-${name}`}
|
||||
name={name}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
viewMode: key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Menu.Item>
|
||||
<Radio
|
||||
toggle
|
||||
label="Show details"
|
||||
disabled={viewMode !== 'STUDENT'}
|
||||
checked={showDetails}
|
||||
onChange={() => onChange({ ...value, showDetails: !showDetails })}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Radio
|
||||
toggle
|
||||
label="Show grades"
|
||||
data-cy="gradeToggle"
|
||||
disabled={viewMode !== 'ATTEMPTS'}
|
||||
checked={showGrades}
|
||||
onChange={() => onChange({ ...value, showGrades: !showGrades })}
|
||||
/>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item>
|
||||
<HelpButton tab="Tables" viewMode={viewMode} />
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<Menu secondary style={{ marginLeft: '0.5rem' }}>
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Field>
|
||||
<b>Provider organization:</b>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="university"
|
||||
name="radioGroup"
|
||||
value="reqularStats"
|
||||
checked={openOrReqular === 'reqularStats'}
|
||||
onChange={toggleUnifyRadioValue}
|
||||
data-cy="unify_radio_reqular"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="open university"
|
||||
name="radioGroup"
|
||||
value="openStats"
|
||||
checked={openOrReqular === 'openStats'}
|
||||
onChange={toggleUnifyRadioValue}
|
||||
data-cy="unify_radio_open"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="unify"
|
||||
name="radioGroup"
|
||||
value="unifyStats"
|
||||
checked={openOrReqular === 'unifyStats'}
|
||||
onChange={toggleUnifyRadioValue}
|
||||
data-cy="unify_radio_unify"
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Tables.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired,
|
||||
alternatives: arrayOf(string).isRequired,
|
||||
separate: bool,
|
||||
isRelative: bool.isRequired,
|
||||
showGrades: bool.isRequired,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
export const Tables = props => {
|
||||
const alternatives = useSelector(selectors.getCourseAlternatives)
|
||||
const viewModes = { ATTEMPTS: AttemptsTable, STUDENT: StudentTable }
|
||||
const Content = viewModes[props.settings.viewMode]
|
||||
|
||||
return <Content {...props} alternatives={alternatives} />
|
||||
}
|
||||
|
||||
Tables.defaultProps = {
|
||||
comparison: null,
|
||||
separate: false,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { selectedCourse } = state.singleCourseStats
|
||||
|
||||
return {
|
||||
alternatives: state.courseStats.data[selectedCourse].alternatives,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Tables)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { arrayOf, number, oneOfType, shape, string, oneOf } from 'prop-types'
|
||||
|
||||
export const viewModeNames = {
|
||||
ATTEMPTS: 'Attempts',
|
||||
STUDENT: 'Students',
|
||||
ATTEMPTS: 'Attempts',
|
||||
}
|
||||
|
||||
export const getDataObject = (name, data, stack) => ({ name, data, stack })
|
||||
|
||||
@@ -1,166 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Tab, Grid, Radio, Menu } from 'semantic-ui-react'
|
||||
import React, { useState } from 'react'
|
||||
import { Tab, Segment, Menu, Icon } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { shape, bool } from 'prop-types'
|
||||
import { dataSeriesType, viewModeNames } from './Panes/util'
|
||||
import PassRate from './Panes/passRate'
|
||||
import Distribution from './Panes/distribution'
|
||||
import Tables from './Panes/tables'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import { PassRate, PassRateSettings } from './Panes/passRate'
|
||||
import { Distribution, DistributionSettings } from './Panes/distribution'
|
||||
import { Tables, TablesSettings } from './Panes/tables'
|
||||
import { useTabs } from '../../../common/hooks'
|
||||
|
||||
import './resultTabs.css'
|
||||
import TSA from '../../../common/tsa'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Course Statistics'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const paneViewIndex = {
|
||||
TABLE: 0,
|
||||
PASS_RAGE: 1,
|
||||
GRADE_DISTRIBUTION: 2,
|
||||
const PaneContent = ({ component: Component, settings: SettingsComponent, initialSettings, datasets, ...rest }) => {
|
||||
const [settings, setSettings] = useState(initialSettings)
|
||||
const [splitDirection, setSplitDirection] = useState('column')
|
||||
|
||||
return (
|
||||
<Tab.Pane>
|
||||
<Segment basic>
|
||||
<div style={{ display: 'flex', marginBottom: '2em' }}>
|
||||
<SettingsComponent value={settings} onChange={setSettings} />
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
{datasets.filter(i => i).length > 1 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1em' }}>
|
||||
<label>Split direction: </label>
|
||||
<Menu style={{ margin: 0 }}>
|
||||
<Menu.Item active={splitDirection === 'row'} onClick={() => setSplitDirection('row')}>
|
||||
<Icon name="arrows alternate horizontal" />
|
||||
</Menu.Item>
|
||||
<Menu.Item active={splitDirection === 'column'} onClick={() => setSplitDirection('column')}>
|
||||
<Icon name="arrows alternate vertical" />
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: splitDirection, gap: '2em' }}>
|
||||
{datasets
|
||||
.filter(i => i)
|
||||
.map(data => (
|
||||
<div style={{ flexGrow: 1, flexBasis: 1 }}>
|
||||
<h3>{data.name}</h3>
|
||||
<Component data={data} settings={settings} {...rest} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Segment>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultTabs = props => {
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, props.history)
|
||||
const [viewMode, setViewMode] = useState(viewModeNames.ATTEMPTS)
|
||||
const [selectedView, setSelectedView] = useState(true)
|
||||
const [isRelative, setIsRelative] = useState(false)
|
||||
const [showGrades, setShowGrades] = useState(false)
|
||||
|
||||
const handleModeChange = newViewMode => {
|
||||
sendAnalytics(`Current view mode '${newViewMode}'`, 'Course statistics')
|
||||
setViewMode(newViewMode)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const newViewMode = selectedView ? viewModeNames.ATTEMPTS : viewModeNames.STUDENT
|
||||
handleModeChange(newViewMode)
|
||||
}, [selectedView])
|
||||
const ResultTabs = ({ primary, comparison, history, separate }) => {
|
||||
const [tab, setTab] = useTabs('cs_tab', 0, history)
|
||||
const { userHasAccessToAllStats } = primary
|
||||
|
||||
const handleTabChange = (...params) => {
|
||||
const resetViewMode = params[1].activeIndex === paneViewIndex.TABLE && viewMode === viewModeNames.ATTEMPTS
|
||||
const { activeIndex } = params[1]
|
||||
const currentTab = params[1].panes[activeIndex]
|
||||
sendAnalytics(`Current tab '${currentTab.menuItem.content}'`, 'Course statistics')
|
||||
setTab(...params)
|
||||
setViewMode(resetViewMode ? viewMode : viewModeNames.ATTEMPTS)
|
||||
}
|
||||
|
||||
const getRadioButton = (firstLabel, secondLabel, value, setValue) => (
|
||||
<div className="toggleContainer">
|
||||
<label className="toggleLabel">{firstLabel}</label>
|
||||
<Radio toggle data-cy="gradeToggle" checked={value} onChange={() => setValue(!value)} />
|
||||
<label className="toggleLabel">{secondLabel}</label>
|
||||
</div>
|
||||
)
|
||||
const paneTypes = [
|
||||
{
|
||||
label: 'Tables',
|
||||
icon: 'table',
|
||||
initialSettings: { showDetails: false, viewMode: 'STUDENT', separate },
|
||||
settings: TablesSettings,
|
||||
component: Tables,
|
||||
},
|
||||
{
|
||||
label: 'Pass rate chart',
|
||||
icon: 'balance',
|
||||
initialSettings: { viewMode: 'STUDENT', separate },
|
||||
settings: PassRateSettings,
|
||||
component: PassRate,
|
||||
},
|
||||
{
|
||||
label: 'Grade distribution chart',
|
||||
icon: 'chart bar',
|
||||
initialSettings: { isRelative: false, viewMode: 'STUDENT' },
|
||||
settings: DistributionSettings,
|
||||
component: Distribution,
|
||||
},
|
||||
]
|
||||
|
||||
const renderViewModeSelector = () => {
|
||||
const isTogglePane = tab !== 0
|
||||
const getButtonMenu = () => (
|
||||
<Menu secondary>
|
||||
{Object.values(viewModeNames).map(name => (
|
||||
<Menu.Item key={name} name={name} active={viewMode === name} onClick={() => handleModeChange(name)} />
|
||||
))}
|
||||
{viewMode === 'Attempts' && getRadioButton('Totals', 'Grade distribution', showGrades, setShowGrades)}
|
||||
{viewMode === 'Attempts' && showGrades && getRadioButton('Absolute', 'Relative', isRelative, setIsRelative)}
|
||||
</Menu>
|
||||
)
|
||||
|
||||
const getToggle = () => {
|
||||
return (
|
||||
<div className="chartToggleContainer">
|
||||
{tab === 1 && getRadioButton('Student', 'Attempts', selectedView, setSelectedView)}
|
||||
{(tab === 2 || props.comparison) && getRadioButton('Absolute', 'Relative', isRelative, setIsRelative)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Remove "false" and activate infoboxes when the texts for them are ready
|
||||
return (
|
||||
<div className="modeSelectorContainer">
|
||||
{isTogglePane ? getToggle() : getButtonMenu()}
|
||||
<InfoBox content={infotooltips.CourseStatistics[tab][viewMode]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getPanes = () => {
|
||||
const { primary, comparison, separate } = props
|
||||
const { userHasAccessToAllStats } = primary
|
||||
|
||||
const paneMenuItems = [
|
||||
{
|
||||
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
|
||||
renderFn: () => (
|
||||
<Tables
|
||||
separate={separate}
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
isRelative={isRelative}
|
||||
showGrades={showGrades}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
|
||||
renderFn: () => (
|
||||
<PassRate
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
isRelative={isRelative && !!comparison}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
|
||||
renderFn: () => (
|
||||
<Distribution
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
isRelative={isRelative}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return paneMenuItems.map(p => {
|
||||
const { menuItem, renderFn } = p
|
||||
return {
|
||||
menuItem,
|
||||
render: () => (
|
||||
<Grid padded="vertically" columns="equal">
|
||||
<Grid.Row className="modeSelectorRow">{renderViewModeSelector()}</Grid.Row>
|
||||
{renderFn()}
|
||||
</Grid>
|
||||
),
|
||||
}
|
||||
const panes = paneTypes.map(
|
||||
({ icon, label, initialSettings, component: Component, settings: SettingsComponent }) => ({
|
||||
menuItem: { icon, content: label },
|
||||
render: () => (
|
||||
<PaneContent
|
||||
component={Component}
|
||||
settings={SettingsComponent}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
initialSettings={initialSettings}
|
||||
datasets={[primary, comparison]}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab id="CourseStatPanes" panes={getPanes()} onTabChange={handleTabChange} activeIndex={tab} />
|
||||
<Tab id="CourseStatPanes" panes={panes} onTabChange={handleTabChange} activeIndex={tab} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ResultTabs.propTypes = {
|
||||
primary: dataSeriesType.isRequired,
|
||||
comparison: dataSeriesType,
|
||||
history: shape({}).isRequired,
|
||||
separate: bool,
|
||||
}
|
||||
|
||||
ResultTabs.defaultProps = {
|
||||
comparison: undefined,
|
||||
separate: false,
|
||||
}
|
||||
|
||||
export default withRouter(ResultTabs)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect, Fragment } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Segment, Header, Form } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
import { sortBy } from 'lodash'
|
||||
import { func, arrayOf, shape, bool } from 'prop-types'
|
||||
import { clearCourses, findCoursesV2, toggleUnifyOpenUniCourses } from '../../../redux/coursesearch'
|
||||
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, usePrevious } from '../../../common/hooks'
|
||||
@@ -15,7 +15,6 @@ import AutoSubmitSearchInput from '../../AutoSubmitSearchInput'
|
||||
import CourseTable from '../CourseTable'
|
||||
import SearchHistory from '../../SearchHistory'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import filterCourseSearchResults from './searchFormUtils'
|
||||
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent('Course statistics search', action, name, value)
|
||||
|
||||
@@ -55,13 +54,18 @@ const useTSASearchResultsHook = (coursesLoading, courseName, courseCode, matchin
|
||||
|
||||
const SearchForm = props => {
|
||||
const { language } = useLanguage()
|
||||
const dispatch = useDispatch()
|
||||
const isLoading = useSelector(state => state.courseStats.pending)
|
||||
const coursesLoading = useSelector(state => state.courseSearch.pending)
|
||||
const matchingCourses = useSelector(getCourseSearchResults)
|
||||
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 { coursesLoading, isLoading, matchingCourses /* unifyOpenUniCourses */ } = props
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { location } = props
|
||||
@@ -71,7 +75,7 @@ const SearchForm = props => {
|
||||
...rest,
|
||||
courseCodes: JSON.parse(courseCodes),
|
||||
separate: JSON.parse(separate),
|
||||
unifyOpenUniCourses: JSON.parse(unifyOpenUniCourses || false),
|
||||
// unifyOpenUniCourses: JSON.parse(unifyOpenUniCourses || false),
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -79,14 +83,14 @@ const SearchForm = props => {
|
||||
const fetchStatisticsFromUrlParams = () => {
|
||||
const query = parseQueryFromUrl()
|
||||
setState({ ...state, ...query })
|
||||
props.getCourseStats(query, props.onProgress)
|
||||
dispatch(getCourseStats(query, props.onProgress))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const { location } = props
|
||||
if (!location.search) {
|
||||
props.clearCourses()
|
||||
props.clearCourseStats()
|
||||
dispatch(clearCourses())
|
||||
dispatch(clearCourseStats())
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -143,7 +147,6 @@ const SearchForm = props => {
|
||||
const params = {
|
||||
courseCodes: codes,
|
||||
separate,
|
||||
unifyOpenUniCourses: props.unifyOpenUniCourses,
|
||||
}
|
||||
const searchHistoryText = codes.map(code => `${getTextIn(selectedCourses[code].name, language)} ${code}`)
|
||||
addItemToSearchHistory({
|
||||
@@ -158,10 +161,10 @@ const SearchForm = props => {
|
||||
const isValidCode = validateInputLength(courseCode, 2)
|
||||
|
||||
if (isValidName || isValidCode) {
|
||||
return props.findCoursesV2({ name: courseName, code: courseCode })
|
||||
return dispatch(findCoursesV2({ name: courseName, code: courseCode }))
|
||||
}
|
||||
if (courseName.length < 5 && courseCode.length < 2) {
|
||||
props.clearCourses()
|
||||
dispatch(clearCourses())
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -172,14 +175,6 @@ const SearchForm = props => {
|
||||
sendAnalytics('Toggle Separate stats for spring & fall', `${newValue}`)
|
||||
}
|
||||
|
||||
const onToggleUnifyOpenUniCoursesCheckbox = () => {
|
||||
const newValue = !unifyOpenUniCourses
|
||||
sendAnalytics('Toggle Unify open university courses', `${newValue}`)
|
||||
|
||||
setState({ ...state, selectedCourses: {} })
|
||||
props.toggleUnifyOpenUniCourses()
|
||||
}
|
||||
|
||||
const courses = matchingCourses.filter(c => !selectedCourses[c.code])
|
||||
|
||||
const disabled = isLoading || Object.keys(selectedCourses).length === 0
|
||||
@@ -235,16 +230,6 @@ const SearchForm = props => {
|
||||
data-cy="course-code-input"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field width={4} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<label>Unify open university courses:</label>
|
||||
<span style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Checkbox
|
||||
name="unifyOpenUniCourses"
|
||||
onChange={onToggleUnifyOpenUniCoursesCheckbox}
|
||||
checked={unifyOpenUniCourses}
|
||||
/>
|
||||
</span>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
<div style={!noSelectedCourses ? searchBoxStyle : null}>
|
||||
<CourseTable
|
||||
@@ -314,38 +299,9 @@ SearchForm.defaultProps = {
|
||||
}
|
||||
|
||||
SearchForm.propTypes = {
|
||||
findCoursesV2: func.isRequired,
|
||||
getCourseStats: func.isRequired,
|
||||
clearCourses: func.isRequired,
|
||||
clearCourseStats: func.isRequired,
|
||||
matchingCourses: arrayOf(shape({})).isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
coursesLoading: bool.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
location: shape({}).isRequired,
|
||||
unifyOpenUniCourses: bool.isRequired,
|
||||
toggleUnifyOpenUniCourses: func.isRequired,
|
||||
onProgress: func,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { courses } = getCourseSearchResults(state)
|
||||
const { pending: courseStatsPending } = state.courseStats
|
||||
const { unifyOpenUniCourses } = state.courseSearch
|
||||
return {
|
||||
matchingCourses: filterCourseSearchResults(courses, unifyOpenUniCourses),
|
||||
isLoading: courseStatsPending,
|
||||
coursesLoading: state.courseSearch.pending,
|
||||
unifyOpenUniCourses,
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
connect(mapStateToProps, {
|
||||
getCourseStats,
|
||||
clearCourses,
|
||||
findCoursesV2,
|
||||
clearCourseStats,
|
||||
toggleUnifyOpenUniCourses,
|
||||
})(SearchForm)
|
||||
)
|
||||
export default withRouter(SearchForm)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { orderBy } from 'lodash'
|
||||
// import { orderBy } from 'lodash'
|
||||
|
||||
// handle special case where the course's actual code starts with "A" so it is mistakenly taken as Open Uni course
|
||||
const isAvoin = course => !!course.code.match(/^[A][0-9]|^AY/)
|
||||
// const isAvoin = course => !!course.code.match(/^[A][0-9]|^AY/)
|
||||
|
||||
const sortAlternatives = alternatives => {
|
||||
/* const sortAlternatives = alternatives => {
|
||||
const result = orderBy(
|
||||
alternatives,
|
||||
[
|
||||
@@ -20,17 +20,18 @@ const sortAlternatives = alternatives => {
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
} */
|
||||
|
||||
// If special case of Open Uni course that starts with A let's just sort them length, that
|
||||
// should be good enough in this case.
|
||||
const getAlternatives = course => sortAlternatives(course.alternatives)
|
||||
|
||||
const filterCourseSearchResults = (courses, unifyOpenUniCourses) => {
|
||||
/* const getAlternatives = course => sortAlternatives(course.alternatives)
|
||||
|
||||
const filterCourseSearchResults = courses => {
|
||||
const mergedCourses = {}
|
||||
const sortedCourses = sortAlternatives(courses)
|
||||
sortedCourses.forEach(course => {
|
||||
const groupId = isAvoin(course) && !unifyOpenUniCourses ? course.code : course.subsId
|
||||
const groupId = course.subsId
|
||||
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
return
|
||||
@@ -64,6 +65,4 @@ const filterCourseSearchResults = (courses, unifyOpenUniCourses) => {
|
||||
}))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export default filterCourseSearchResults
|
||||
} */
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Segment, Label, Header, Divider, Form } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, oneOfType, number, string, bool } from 'prop-types'
|
||||
import { oneOfType, number, string, bool } from 'prop-types'
|
||||
import SingleCourseStats from '../SingleCourseStats'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import selectors from '../../../selectors/courseStats'
|
||||
import { getTextIn } from '../../../common'
|
||||
|
||||
const SingleCourseTab = ({ selected, stats, courses, userHasAccessToAllStats }) => {
|
||||
const SingleCourseTab = ({ selected, userHasAccessToAllStats }) => {
|
||||
const [selection, setSelection] = useState(selected)
|
||||
const { language } = useLanguage()
|
||||
const stats = useSelector(selectors.getCourseStats)
|
||||
const courses = useSelector(selectors.getCourses).map(({ code, name }) => ({
|
||||
key: code,
|
||||
value: code,
|
||||
text: <Header content={<>{name}</>} />,
|
||||
content: name,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
setSelection(selected)
|
||||
@@ -50,20 +57,8 @@ const SingleCourseTab = ({ selected, stats, courses, userHasAccessToAllStats })
|
||||
}
|
||||
|
||||
SingleCourseTab.propTypes = {
|
||||
stats: shape({}).isRequired,
|
||||
courses: arrayOf(shape({})).isRequired,
|
||||
selected: oneOfType([number, string]).isRequired,
|
||||
userHasAccessToAllStats: bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
stats: selectors.getCourseStats(state),
|
||||
courses: selectors.getCourses(state).map(({ code, name }) => ({
|
||||
key: code,
|
||||
value: code,
|
||||
text: <Header content={<>{name}</>} />,
|
||||
content: name,
|
||||
})),
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {})(SingleCourseTab)
|
||||
export default SingleCourseTab
|
||||
|
||||
@@ -226,7 +226,7 @@ const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences,
|
||||
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
|
||||
|
||||
if (singleStudent) {
|
||||
const starting = _.chain(students[0].studyRights || students[0].courses)
|
||||
const starting = _.chain(students[0].studyrights || students[0].courses)
|
||||
.map(elem => new Date(elem.startdate || elem.date))
|
||||
.sortBy()
|
||||
.head()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { Icon, Card, Dropdown, Portal, Popup } from 'semantic-ui-react'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
const FigureContext = createContext(null)
|
||||
|
||||
const Header = ({ children, actions, contextItems }) => {
|
||||
const { isFullscreen, setFullscreen } = useContext(FigureContext)
|
||||
|
||||
return (
|
||||
<Card.Content style={{ flexGrow: 0, display: 'flex', alignItems: 'center', padding: '1em', height: '3.25em' }}>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>{children}</div>
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<div style={{ marginRight: '1em' }}>{actions}</div>
|
||||
<Popup
|
||||
trigger={
|
||||
<Icon
|
||||
name={isFullscreen ? 'compress' : 'expand'}
|
||||
onClick={() => setFullscreen(!isFullscreen)}
|
||||
style={{ cursor: 'pointer', fontSize: '1.1rem', marginRight: '0.5em', position: 'relative', top: '-1px' }}
|
||||
/>
|
||||
}
|
||||
on="hover"
|
||||
position="bottom"
|
||||
>
|
||||
Toggle Fullscreen
|
||||
</Popup>
|
||||
{contextItems && (
|
||||
<div>
|
||||
<Dropdown position="bottom center" direction="left" icon="ellipsis vertical">
|
||||
<Dropdown.Menu>
|
||||
{contextItems.map(({ label, onClick }) => (
|
||||
<Dropdown.Item onClick={onClick}>{label}</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
)
|
||||
}
|
||||
|
||||
const Content = ({ children, ...rest }) => {
|
||||
const { isFullscreen } = useContext(FigureContext)
|
||||
|
||||
const style = {
|
||||
...rest.style,
|
||||
overflow: 'auto',
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
style.maxHeight = 'initial'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card.Content {...rest} style={style}>
|
||||
{children}
|
||||
</Card.Content>
|
||||
)
|
||||
}
|
||||
|
||||
const FigureContainer = ({ children, style }) => {
|
||||
const [isFullscreen, setFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.body.classList.add('figure-fullscreen')
|
||||
} else {
|
||||
document.body.classList.remove('figure-fullscreen')
|
||||
}
|
||||
|
||||
return () => document.body.classList.remove('figure-fullscreen')
|
||||
}, [isFullscreen])
|
||||
|
||||
return (
|
||||
<FigureContext.Provider value={{ isFullscreen, setFullscreen }}>
|
||||
<Card fluid style={{ overflow: 'hidden', maxHeight: '100%', ...style }}>
|
||||
{children}
|
||||
</Card>
|
||||
<Portal
|
||||
open={isFullscreen}
|
||||
onClose={() => setFullscreen(false)}
|
||||
style={{
|
||||
width: '100% !important',
|
||||
height: '100% !important',
|
||||
}}
|
||||
>
|
||||
<div style={{ inset: 0, position: 'absolute', zIndex: 910, height: '100000vh' }}>
|
||||
<Card
|
||||
fluid
|
||||
style={{ position: 'sticky', margin: 0, inset: 0, height: '100vh', border: 'none', borderRadius: 0 }}
|
||||
>
|
||||
{isFullscreen && children}
|
||||
</Card>
|
||||
</div>
|
||||
</Portal>
|
||||
</FigureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FigureContainer.Header = Header
|
||||
FigureContainer.Content = Content
|
||||
|
||||
export default FigureContainer
|
||||
@@ -0,0 +1,3 @@
|
||||
body.figure-fullscreen {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Icon } from 'semantic-ui-react'
|
||||
import './filterToggleIcon.css'
|
||||
|
||||
const FilterToggleIcon = ({ onClick, isActive }) => (
|
||||
<span className={`flexIcon ${isActive ? 'active' : ''}`}>
|
||||
<span className={`flexIcon ${isActive ? 'active' : ''}`} style={{ cursor: 'pointer' }}>
|
||||
<Icon onClick={onClick} name="filter" />
|
||||
<Icon onClick={onClick} name={isActive ? 'remove' : 'add'} size="tiny" />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
+5
-45
@@ -1,51 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { images, checkUserAccess } from '../common'
|
||||
import { useTitle } from '../common/hooks'
|
||||
import { builtAt } from '../conf'
|
||||
import oodiTXT from '../static/oodi.txt'
|
||||
|
||||
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 (
|
||||
<div style={{ margin: 'auto', width: '50%' }}>
|
||||
{oodi.map((l, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<p key={index} style={{ textAlign: 'center', fontStyle: 'italic', margin: 0 }}>
|
||||
{index === 0 && '\u201C'}
|
||||
{l}
|
||||
{index === oodi.length - 1 && '\u201D'}
|
||||
</p>
|
||||
))}
|
||||
<p style={{ marginTop: '1.3em', textAlign: 'center' }}>
|
||||
- <a href="http://www.helsinki.fi/discovery">R. U. Nokone</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { images, checkUserAccess } from '../../common'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { builtAt } from '../../conf'
|
||||
import OodiToOodikone from './OoodiToOodikone'
|
||||
|
||||
const FrontPage = () => {
|
||||
const { rights, roles } = useGetAuthorizedUserQuery()
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
@@ -7,15 +7,19 @@ const LanguageContext = createContext([[], () => {}])
|
||||
LanguageContext.displayName = 'Language'
|
||||
|
||||
export const LanguageProvider = ({ children }) => {
|
||||
const [state, setState] = useState(LANGUAGE_CODES[0])
|
||||
let language = 'fi'
|
||||
const [state, setState] = useState(language)
|
||||
const user = useGetAuthorizedUserQuery()
|
||||
if (user) {
|
||||
language = user.language
|
||||
}
|
||||
|
||||
// Load selected language.
|
||||
useEffect(() => {
|
||||
if (LANGUAGE_CODES.includes(user?.language)) {
|
||||
setState(user.language)
|
||||
}
|
||||
}, [])
|
||||
}, [language])
|
||||
|
||||
return <LanguageContext.Provider value={[state, setState]}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ const NavigationBar = () => {
|
||||
|
||||
// Min-content sets logo always to the left
|
||||
const renderHome = () => (
|
||||
<Menu.Item as={Link} to="/" tabIndex="-1" style={{ width: 'min-content' }}>
|
||||
<Menu.Item as={Link} to="/" tabIndex="-1">
|
||||
<span className="logo">
|
||||
<h2 className="logoText">oodikone</h2>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { Popup } from 'semantic-ui-react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { isCourseSelected, toggleCourseSelection } from 'components/FilterView/filters/courses'
|
||||
import FilterToggleIcon from 'components/FilterToggleIcon'
|
||||
import useFilters from 'components/FilterView/useFilters'
|
||||
import { getTextIn } from '../../common'
|
||||
|
||||
const CourseFilterToggle = ({ course }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
|
||||
return (
|
||||
<Popup
|
||||
trigger={
|
||||
<FilterToggleIcon
|
||||
style={{ cursor: 'pointer' }}
|
||||
isActive={isActive}
|
||||
onClick={() => filterDispatch(toggleCourseSelection(course.code))}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CourseFilterToggle
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import GradeDistributionHeader from './GradeDistributionHeader'
|
||||
import GradeDistributionRow from './GradeDistributionRow'
|
||||
|
||||
const gradeTypes = [1, 2, 3, 4, 5]
|
||||
|
||||
const GradeDistributionFlat = () => {
|
||||
const { courseStatistics } = UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table celled sortable className="fixed-header">
|
||||
<GradeDistributionHeader gradeTypes={gradeTypes} />
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<GradeDistributionRow key={stats.course.code} courseStatistics={stats} gradeTypes={gradeTypes} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradeDistributionFlat
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import React from 'react'
|
||||
import { arrayOf, number } from 'prop-types'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import SortableHeaderCell from '../SortableHeaderCell'
|
||||
|
||||
const GradeDistributionHeader = ({ gradeTypes }) => {
|
||||
const { onSortableColumnHeaderClick, filterInput, reversed, sortCriteria, tableColumnNames } =
|
||||
UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
|
||||
<SortableHeaderCell
|
||||
content="Attempts"
|
||||
columnName={tableColumnNames.STUDENTS}
|
||||
onClickFn={onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
/>
|
||||
|
||||
<Table.HeaderCell>0</Table.HeaderCell>
|
||||
{gradeTypes.map(g => (
|
||||
<Table.HeaderCell content={g} key={g} />
|
||||
))}
|
||||
<Table.HeaderCell content="Other passed" />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
}
|
||||
|
||||
GradeDistributionHeader.propTypes = {
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistributionHeader
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table, Popup, Item, Icon } from 'semantic-ui-react'
|
||||
import { replace, sortBy, omit } from 'lodash'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { shape, string, number, arrayOf } from 'prop-types'
|
||||
import { getTextIn } from '../../../common'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import { useLanguage } from '../../../common/hooks'
|
||||
|
||||
const formatGradeDistribution = grades =>
|
||||
replace(
|
||||
JSON.stringify(
|
||||
sortBy(
|
||||
Object.entries(grades).map(([key, value]) => ({ [key]: value.count })),
|
||||
o => -Object.keys(o)
|
||||
),
|
||||
null,
|
||||
1
|
||||
),
|
||||
/\[\n|{\n*|{\s|}|\s*}|]|"|,/g,
|
||||
''
|
||||
)
|
||||
|
||||
const CourseRow = ({ courseStatistics, gradeTypes }) => {
|
||||
const language = useLanguage()
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const { course, grades } = courseStatistics
|
||||
const { name, code } = course
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
let attempts = 0
|
||||
let failedGrades = 0
|
||||
let otherPassed = 0
|
||||
|
||||
if (grades) {
|
||||
const countSumReducer = (acc, cur) => acc + cur.count
|
||||
const gradeValues = grades ? Object.values(grades) : null
|
||||
attempts = gradeValues.reduce(countSumReducer, 0)
|
||||
failedGrades = gradeValues.filter(g => g.status.failingGrade).reduce(countSumReducer, 0)
|
||||
otherPassed = Object.values(omit(grades, gradeTypes))
|
||||
.filter(g => g.status.passingGrade || g.status.improvedGrade)
|
||||
.reduce(countSumReducer, 0)
|
||||
}
|
||||
return (
|
||||
<Table.Row active={isActive}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
|
||||
<Table.Cell className="iconCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={failedGrades} />
|
||||
{gradeTypes.map(g => (
|
||||
<Table.Cell content={grades[g] ? grades[g].count || 0 : 0} key={code + g} />
|
||||
))}
|
||||
<Table.Cell content={otherPassed} />
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
const GradeDistributionRow = ({ courseStatistics, gradeTypes }) => {
|
||||
const { course, grades } = courseStatistics
|
||||
const { code } = course
|
||||
return (
|
||||
<Popup
|
||||
key={code}
|
||||
flowing
|
||||
hoverable
|
||||
inverted
|
||||
position="top right"
|
||||
hideOnScroll
|
||||
content={grades ? <pre>{formatGradeDistribution(grades)}</pre> : 'Nothing to see here'}
|
||||
trigger={<CourseRow courseStatistics={courseStatistics} gradeTypes={gradeTypes} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CourseRow.propTypes = {
|
||||
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
GradeDistributionRow.propTypes = {
|
||||
courseStatistics: shape({ course: shape({ code: string }) }).isRequired,
|
||||
gradeTypes: arrayOf(number).isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistributionRow
|
||||
+128
-38
@@ -1,49 +1,139 @@
|
||||
import React from 'react'
|
||||
import { Table, Icon } from 'semantic-ui-react'
|
||||
import { instanceOf, func } from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Icon, Item } from 'semantic-ui-react'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import _ from 'lodash'
|
||||
import { getTextIn } from '../../../common'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import GradeDistributionHeader from './GradeDistributionHeader'
|
||||
import GradeDistributionRow from './GradeDistributionRow'
|
||||
|
||||
const gradeTypes = [1, 2, 3, 4, 5]
|
||||
const mapCourseData = course => ({
|
||||
name: course.course.name,
|
||||
code: course.course.code,
|
||||
attempts: _.chain(course.grades).values().map('count').sum().value(),
|
||||
otherPassed: _.chain(course.grades)
|
||||
.omit(_.range(0, 6))
|
||||
.filter(g => g.status.passingGrade || g.status.improvedGrade)
|
||||
.map('count')
|
||||
.sum()
|
||||
.value(),
|
||||
grades: {
|
||||
...course.grades,
|
||||
0: {
|
||||
count: _.chain(course.grades)
|
||||
.filter(g => g.status.failingGrade)
|
||||
.map('count')
|
||||
.sum()
|
||||
.value(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const GradeDistribution = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const GradeDistribution = ({ flat }) => {
|
||||
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: row => getTextIn(row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: row => row.code,
|
||||
},
|
||||
{
|
||||
key: 'stats',
|
||||
noHeader: true,
|
||||
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
|
||||
children: [
|
||||
{
|
||||
key: 'attempts',
|
||||
title: 'Attempts',
|
||||
getRowVal: row => row.attempts,
|
||||
},
|
||||
..._.range(0, 6).map(grade => ({
|
||||
key: `grade-${grade}`,
|
||||
title: `${grade}`,
|
||||
getRowVal: row => row.grades[grade]?.count ?? 0,
|
||||
})),
|
||||
{
|
||||
key: 'other-passed',
|
||||
title: 'Other Passed',
|
||||
getRowVal: row => row.otherPassed,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (flat) {
|
||||
return courseStatistics.map(mapCourseData)
|
||||
}
|
||||
|
||||
return modules.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: ({ definition: { module }, children }) => ({
|
||||
name: module.name,
|
||||
code: module.code,
|
||||
attempts: _.chain(children).map('attempts').sum().value(),
|
||||
otherPassed: _.chain(children).map('otherPassed').sum().value(),
|
||||
grades: _.range(0, 6).map(grade => ({
|
||||
count: _.chain(children)
|
||||
.map(course => course.grades[grade]?.count ?? 0)
|
||||
.sum()
|
||||
.value(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
courses.map(mapCourseData)
|
||||
)
|
||||
)
|
||||
}, [modules])
|
||||
|
||||
return (
|
||||
<Table celled sortable className="fixed-header">
|
||||
<GradeDistributionHeader gradeTypes={gradeTypes} />
|
||||
<Table.Body>
|
||||
{modules.map(({ module, courses }) => (
|
||||
<React.Fragment key={module.code}>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
|
||||
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
|
||||
<b>{getTextIn(module.name, language)}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<b>{module.code}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell colSpan="8" />
|
||||
</Table.Row>
|
||||
{expandedGroups.has(module.code) &&
|
||||
courses.map(course => (
|
||||
<GradeDistributionRow key={course.course.code} courseStatistics={course} gradeTypes={gradeTypes} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<>
|
||||
<SortableTable title="Grade distribution of courses" data={data} columns={columns} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
GradeDistribution.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default GradeDistribution
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import PassFailHeader from './PassFailHeader'
|
||||
import PassFailRow from './PassFailRow'
|
||||
|
||||
const PassFail = () => {
|
||||
const { courseStatistics } = UsePopulationCourseContext()
|
||||
|
||||
return (
|
||||
<Table className="fixed-header" celled sortable>
|
||||
<PassFailHeader />
|
||||
<Table.Body>
|
||||
{courseStatistics.map(stats => (
|
||||
<PassFailRow key={stats.course.code} courseStats={stats} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassFail
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import SortableHeaderCell from '../SortableHeaderCell'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
|
||||
const PassFailHeader = () => {
|
||||
const { onSortableColumnHeaderClick, filterInput, tableColumnNames, sortCriteria, reversed } =
|
||||
UsePopulationCourseContext()
|
||||
|
||||
const getSortableHeaderCell = (label, columnName, rowSpan = 1) => (
|
||||
<SortableHeaderCell
|
||||
content={label}
|
||||
columnName={columnName}
|
||||
onClickFn={onSortableColumnHeaderClick}
|
||||
activeSortColumn={sortCriteria}
|
||||
reversed={reversed}
|
||||
rowSpan={rowSpan}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="4" content="Course" />
|
||||
{getSortableHeaderCell('Students', tableColumnNames.STUDENTS, 2)}
|
||||
<Table.HeaderCell colSpan="3" content="Passed" />
|
||||
<Table.HeaderCell colSpan="2" content="Failed" />
|
||||
<Table.HeaderCell colSpan="2" content="Attempts" />
|
||||
<Table.HeaderCell colSpan="2" content="percentage of population" />
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
{getSortableHeaderCell('n', tableColumnNames.PASSED)}
|
||||
{getSortableHeaderCell('after retry', tableColumnNames.RETRY_PASSED)}
|
||||
{getSortableHeaderCell('percentage', tableColumnNames.PERCENTAGE)}
|
||||
{getSortableHeaderCell('n', tableColumnNames.FAILED)}
|
||||
{getSortableHeaderCell('many times', tableColumnNames.FAILED_MANY)}
|
||||
{getSortableHeaderCell('n', tableColumnNames.ATTEMPTS)}
|
||||
{getSortableHeaderCell('per student', tableColumnNames.PER_STUDENT)}
|
||||
{getSortableHeaderCell('Passed', tableColumnNames.PASSED_OF_POPULATION)}
|
||||
{getSortableHeaderCell('attempted', tableColumnNames.TRIED_OF_POPULATION)}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassFailHeader
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Table, Icon, Popup, Item } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { shape, string } from 'prop-types'
|
||||
import { getTextIn } from '../../../common'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
|
||||
const PassFailRow = ({ courseStats }) => {
|
||||
const { language } = useLanguage()
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
const { onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
const { course, stats } = courseStats
|
||||
const { code, name } = course
|
||||
const {
|
||||
failed,
|
||||
passed,
|
||||
retryPassed,
|
||||
failedMany,
|
||||
attempts,
|
||||
percentage,
|
||||
perStudent,
|
||||
passedOfPopulation,
|
||||
triedOfPopulation,
|
||||
} = stats
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
return (
|
||||
<Table.Row key={code} active={isActive} data-cy={name.fi}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell content={getTextIn(name, language)} className="nameCell" />
|
||||
<Table.Cell className="iconCell clickableCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
data-cy={`coursestats-link-${code}`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell content={code} />
|
||||
<Table.Cell content={passed + failed} />
|
||||
<Table.Cell content={passed} />
|
||||
<Table.Cell content={retryPassed} />
|
||||
<Table.Cell content={`${percentage.toFixed(2)} %`} />
|
||||
<Table.Cell content={failed} />
|
||||
<Table.Cell content={failedMany} />
|
||||
<Table.Cell content={attempts} />
|
||||
<Table.Cell content={perStudent.toFixed(2)} />
|
||||
<Table.Cell content={`${passedOfPopulation.toFixed(2)} %`} />
|
||||
<Table.Cell content={`${triedOfPopulation.toFixed(2)} %`} />
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
PassFailRow.propTypes = {
|
||||
courseStats: shape({ course: shape({ code: string }) }).isRequired,
|
||||
}
|
||||
|
||||
export default PassFailRow
|
||||
@@ -1,34 +1,215 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { func, instanceOf } from 'prop-types'
|
||||
import React, { useMemo } from 'react'
|
||||
import _ from 'lodash'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { Item, Icon } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import CollapsibleModuleTable from '../CollapsibleModuleTable'
|
||||
import PassFailRow from './PassFailRow'
|
||||
import PassFailHeader from './PassFailHeader'
|
||||
import { getTextIn } from '../../../common'
|
||||
|
||||
const PassFail = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const createModuleAggregateRow = ({ definition, children }) => ({
|
||||
label_code: definition.module.code,
|
||||
label_name: definition.module.name,
|
||||
stats: {
|
||||
passed: _.chain(children).map('stats.passed').sum().value(),
|
||||
retryPassed: _.chain(children).map('stats.retryPassed').sum().value(),
|
||||
percentage: _.chain(children).map('stats.percentage').mean().value(),
|
||||
failed: _.chain(children).map('stats.failed').sum().value(),
|
||||
failedMany: _.chain(children).map('stats.failedMany').sum().value(),
|
||||
attempts: _.chain(children).map('stats.attempts').sum().value(),
|
||||
perStudent: _.chain(children).map('stats.perStudent').mean().value(),
|
||||
passedOfPopulation: _.chain(children).map('stats.passedOfPopulation').mean().value(),
|
||||
triedOfPopulation: _.chain(children).map('stats.triedOfPopulation').mean().value(),
|
||||
},
|
||||
})
|
||||
|
||||
const PassFail = ({ flat }) => {
|
||||
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course',
|
||||
title: 'Course',
|
||||
children: [
|
||||
{
|
||||
key: 'course-name-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'course-name',
|
||||
title: 'Name',
|
||||
getRowVal: (row, isGroup) => getTextIn(isGroup ? row.label_name : row.name ?? row.course.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'course-code',
|
||||
title: 'Code',
|
||||
getRowVal: (row, isGroup) => (isGroup ? row.label_code : row.code ?? row.course.code),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'statistics',
|
||||
noHeader: true,
|
||||
getRowVal: (_, isGroup) => (isGroup ? ' ' : undefined),
|
||||
children: [
|
||||
{
|
||||
key: 'passed',
|
||||
title: 'Passed',
|
||||
children: [
|
||||
{
|
||||
key: 'passed-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.passed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'passed-after-retry',
|
||||
title: 'After Retry',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.retryPassed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'passed-percentage',
|
||||
title: 'Passed-%',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.percentage ?? 0,
|
||||
formatValue: value =>
|
||||
value &&
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
title: 'Failed',
|
||||
children: [
|
||||
{
|
||||
key: 'failed-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.failed ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'failed-many',
|
||||
title: 'Multiple Times',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.failedMany ?? 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
title: 'Attempts',
|
||||
children: [
|
||||
{
|
||||
key: 'attempts-n',
|
||||
title: 'Total',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.attempts,
|
||||
},
|
||||
{
|
||||
key: 'attempts-per-student',
|
||||
title: 'per Student',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.perStudent,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'of-population',
|
||||
title: 'Percentage of Population',
|
||||
children: [
|
||||
{
|
||||
key: 'passed-of-population',
|
||||
title: 'Passed',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.passedOfPopulation,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
{
|
||||
key: 'attempted-of-population',
|
||||
title: 'Attempted',
|
||||
cellStyle: { textAlign: 'right' },
|
||||
getRowVal: row => row.stats?.triedOfPopulation,
|
||||
formatValue: value =>
|
||||
new Intl.NumberFormat('fi-FI', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (flat) {
|
||||
return courseStatistics.map(course => ({ ...course, code: course.course.code, name: course.course.name }))
|
||||
}
|
||||
|
||||
return _.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: createModuleAggregateRow,
|
||||
columnOverrides: {},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value()
|
||||
}, [modules])
|
||||
|
||||
return (
|
||||
<Table className="fixed-header" celled sortable>
|
||||
<PassFailHeader />
|
||||
<Table.Body>
|
||||
<CollapsibleModuleTable
|
||||
emptyColSpan={12}
|
||||
modules={modules}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
>
|
||||
{courses => courses.map(stats => <PassFailRow key={stats.course.code} courseStats={stats} />)}
|
||||
</CollapsibleModuleTable>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<>
|
||||
<SortableTable title="Pass and fail statistics of courses" data={data} columns={columns} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
PassFail.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default PassFail
|
||||
|
||||
+3
-1
@@ -14,7 +14,9 @@ const CumulativeCheckbox = ({ cumulativeStats, setCumulativeStats }) => {
|
||||
setCumulativeStats(!cumulativeStats)
|
||||
}
|
||||
|
||||
return <Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" />
|
||||
return (
|
||||
<Checkbox toggle checked={cumulativeStats} onChange={handleChange} label="Show cumulative stats" size="small" />
|
||||
)
|
||||
}
|
||||
|
||||
CumulativeCheckbox.propTypes = {
|
||||
|
||||
+137
-32
@@ -1,43 +1,148 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { func, instanceOf } from 'prop-types'
|
||||
import PassingSemesterRow from './PassingSemesterRow'
|
||||
import CollapsibleModuleTable from '../CollapsibleModuleTable'
|
||||
import PassingSemestersHeader from './PassingSemestersHeader'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Item, Icon, Button } from 'semantic-ui-react'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { getTextIn } from '../../../common'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import CumulativeCheckbox from './CumulativeCheckbox'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
|
||||
const PassingSemesters = ({ expandedGroups, toggleGroupExpansion }) => {
|
||||
const { modules } = UsePopulationCourseContext()
|
||||
const semesterColumn = (year, semester, cumulative) => ({
|
||||
key: `semester-${year}-${semester}`,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
title: semester,
|
||||
getRowVal: row =>
|
||||
((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
|
||||
`${year}-${semester.toUpperCase()}`
|
||||
],
|
||||
getRowContent: row => {
|
||||
const value = ((cumulative ? row?.stats?.passingSemestersCumulative : row?.stats?.passingSemesters) ?? {})[
|
||||
`${year}-${semester.toUpperCase()}`
|
||||
]
|
||||
|
||||
if (value === 0) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const yearColumn = (year, cumulative) => ({
|
||||
key: `year-${year}`,
|
||||
title: `${year + 1}${['st', 'nd', 'rd'][year] ?? 'th'} year`,
|
||||
children: [semesterColumn(year, 'Fall', cumulative), semesterColumn(year, 'Spring', cumulative)],
|
||||
})
|
||||
|
||||
const PassingSemesters = () => {
|
||||
const { modules, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
|
||||
const [cumulativeStats, setCumulativeStats] = useState(false)
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course-name',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: (row, isGroup, parents) => getTextIn(isGroup ? parents[0].module.name : row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: (row, isGroup, parents) => (isGroup ? parents[0].module.code : row.code),
|
||||
},
|
||||
{
|
||||
key: 'stats',
|
||||
noHeader: true,
|
||||
getRowVal: (__, isGroup) => (isGroup ? ' ' : null),
|
||||
children: [
|
||||
{
|
||||
key: 'students',
|
||||
title: 'Students',
|
||||
getRowVal: row => row.stats?.students,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
{
|
||||
key: 'passed',
|
||||
title: 'Passed',
|
||||
getRowVal: row => row.stats?.passed,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
{
|
||||
key: 'passed-before',
|
||||
title: 'Before 1st year',
|
||||
getRowVal: row =>
|
||||
(cumulativeStats ? row.stats?.passingSemesters : row.stats?.passingSemestersCumulative)?.BEFORE,
|
||||
cellProps: { style: { textAlign: 'right' } },
|
||||
},
|
||||
..._.range(0, 6).map(i => yearColumn(i, cumulativeStats)),
|
||||
],
|
||||
},
|
||||
],
|
||||
[cumulativeStats, onGoToCourseStatisticsClick]
|
||||
)
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
_.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
module,
|
||||
headerRowData: {},
|
||||
columnOverrides: {},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value(),
|
||||
[modules]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CumulativeCheckbox cumulativeStats={cumulativeStats} setCumulativeStats={setCumulativeStats} />
|
||||
<Table celled className="fixed-header">
|
||||
<PassingSemestersHeader />
|
||||
<Table.Body>
|
||||
<CollapsibleModuleTable
|
||||
modules={modules}
|
||||
emptyColSpan={15}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
>
|
||||
{courses =>
|
||||
courses.map(stats => (
|
||||
<PassingSemesterRow key={stats.course.code} statistics={stats} cumulative={cumulativeStats} />
|
||||
))
|
||||
}
|
||||
</CollapsibleModuleTable>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<SortableTable
|
||||
title="Students Passing a Course per Semester"
|
||||
actions={
|
||||
<Button size="mini" style={{ padding: '.75em .75em' }} onClick={() => setCumulativeStats(!cumulativeStats)}>
|
||||
{cumulativeStats ? 'Show yearly stats' : 'Show cumulative stats'}
|
||||
</Button>
|
||||
}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PassingSemesters.propTypes = {
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default PassingSemesters
|
||||
|
||||
+8
-8
@@ -6,8 +6,8 @@ import { clearCourseStats } from '../../redux/coursestats'
|
||||
import './populationCourseStats.css'
|
||||
import { PopulationCourseContext } from './PopulationCourseContext'
|
||||
import TSA from '../../common/tsa'
|
||||
import GradeDistribution from './GradeDistribution/GradeDistributionFlat'
|
||||
import PassFail from './PassFail/PassFailFlat'
|
||||
import GradeDistribution from './GradeDistribution'
|
||||
import PassFail from './PassFail'
|
||||
import { getTextIn } from '../../common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
|
||||
@@ -215,17 +215,17 @@ const PopulationCourseStatsFlat = ({ courses, pending, filteredStudents, showFil
|
||||
{
|
||||
menuItem: 'pass/fail',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<PassFail />
|
||||
</div>
|
||||
<Tab.Pane>
|
||||
<PassFail flat />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'grades',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<GradeDistribution />
|
||||
</div>
|
||||
<Tab.Pane>
|
||||
<GradeDistribution flat />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,99 +1,41 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Table, Icon, Popup, Item, Pagination } from 'semantic-ui-react'
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { Icon, Button, Item, Pagination } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSelector, connect } from 'react-redux'
|
||||
import { bool, instanceOf, func } from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
import SortableTable, { group } from 'components/SortableTable'
|
||||
import { UsePopulationCourseContext } from '../PopulationCourseContext'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
import useFilters from '../../FilterView/useFilters'
|
||||
import { isCourseSelected, toggleCourseSelection } from '../../FilterView/filters/courses'
|
||||
import CourseFilterToggle from '../CourseFilterToggle'
|
||||
import { getTextIn } from '../../../common'
|
||||
import StudentNameVisibilityToggle from '../../StudentNameVisibilityToggle'
|
||||
import { useStudentNameVisibility } from '../../StudentNameVisibilityToggle'
|
||||
|
||||
const verticalTitle = (...params) => {
|
||||
// https://stackoverflow.com/a/41396815
|
||||
return (
|
||||
<div className="studentVerticalTitle">
|
||||
{params.map(p => (
|
||||
<div key={p}>{p}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StudentsRow = ({ course, pagedStudents, hasCompleted, onGoToCourseStatisticsClick }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { useFilterSelector, filterDispatch } = useFilters()
|
||||
|
||||
const isActive = useFilterSelector(isCourseSelected(course.code))
|
||||
|
||||
return (
|
||||
<Table.Row key={course.code}>
|
||||
<Popup
|
||||
trigger={
|
||||
<Table.Cell className="filterCell clickableCell">
|
||||
<FilterToggleIcon isActive={isActive} onClick={() => filterDispatch(toggleCourseSelection(course.code))} />
|
||||
</Table.Cell>
|
||||
}
|
||||
content={
|
||||
isActive ? (
|
||||
<span>
|
||||
Poista rajaus kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Rajaa opiskelijat kurssin <b>{getTextIn(course.name, language)}</b> perusteella
|
||||
</span>
|
||||
)
|
||||
}
|
||||
position="top right"
|
||||
/>
|
||||
<Table.Cell className="nameCell" key="name" content={course.name.fi} />
|
||||
<Table.Cell className="iconCell clickableCell">
|
||||
<p>
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
course.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(course.code)} />
|
||||
</Item>
|
||||
</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell key="code" content={course.code} />
|
||||
{pagedStudents.map(student => (
|
||||
<Table.Cell
|
||||
key={student.studentnumber}
|
||||
content={hasCompleted(student.studentnumber) ? <Icon fitted name="check" color="green" /> : null}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, showNames }) => {
|
||||
const { courseStatistics, filterInput, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const Students = ({ filteredStudents }) => {
|
||||
const { courseStatistics, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
|
||||
const { visible: namesVisible, toggle: toggleStudentNames } = useStudentNameVisibility()
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const hasCompleted = (courseCode, student) => {
|
||||
const course = courseStatistics.find(c => c.course.code === courseCode)
|
||||
if (!course) return false
|
||||
const hasCompleted = useCallback(
|
||||
(courseCode, student) => {
|
||||
const course = courseStatistics.find(c => c.course.code === courseCode)
|
||||
if (!course) return false
|
||||
|
||||
return Boolean(course.students.passed[student])
|
||||
}
|
||||
return Boolean(course.students.passed[student])
|
||||
},
|
||||
[courseStatistics]
|
||||
)
|
||||
|
||||
const countCompleted = (courses, student) => {
|
||||
let completed = 0
|
||||
courses.forEach(course => {
|
||||
if (hasCompleted(course.code, student)) {
|
||||
completed++
|
||||
}
|
||||
})
|
||||
const countCompleted = useCallback(
|
||||
(courses, student) => {
|
||||
let completed = 0
|
||||
courses.forEach(course => {
|
||||
if (hasCompleted(course.code, student)) {
|
||||
completed++
|
||||
}
|
||||
})
|
||||
|
||||
return completed
|
||||
}
|
||||
return completed
|
||||
},
|
||||
[hasCompleted]
|
||||
)
|
||||
|
||||
const students = useMemo(() => {
|
||||
const studentSet = new Set()
|
||||
@@ -124,81 +66,123 @@ const Students = ({ filteredStudents, expandedGroups, toggleGroupExpansion, show
|
||||
|
||||
const pagedStudents = students.slice(page * 10, page * 10 + 10)
|
||||
|
||||
const data = useMemo(() => {
|
||||
return _.chain(modules)
|
||||
.map(({ module, courses }) =>
|
||||
group(
|
||||
{
|
||||
key: `module-${module.code}`,
|
||||
headerRowData: {
|
||||
name: module.name,
|
||||
code: module.code,
|
||||
courses,
|
||||
},
|
||||
},
|
||||
courses
|
||||
)
|
||||
)
|
||||
.value()
|
||||
}, [modules])
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'course',
|
||||
title: 'Course',
|
||||
children: [
|
||||
{
|
||||
key: 'title-parent',
|
||||
mergeHeader: 'title',
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'title',
|
||||
title: 'Name',
|
||||
getRowVal: row => getTextIn(row.name),
|
||||
},
|
||||
{
|
||||
key: 'filter-toggle',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) => {
|
||||
if (isGroup) return null
|
||||
|
||||
return <CourseFilterToggle course={row} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'go-to-course',
|
||||
export: false,
|
||||
getRowContent: (row, isGroup) =>
|
||||
!isGroup && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/coursestatistics?courseCodes=["${encodeURIComponent(
|
||||
row.code
|
||||
)}"]&separate=false&unifyOpenUniCourses=false`}
|
||||
>
|
||||
<Icon name="level up alternate" onClick={() => onGoToCourseStatisticsClick(row.code)} />
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Code',
|
||||
getRowVal: row => row.code,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'Students',
|
||||
children: pagedStudents.map(student => ({
|
||||
key: `student-${student.studentnumber}`,
|
||||
title: `${namesVisible ? student.name : student.studentnumber}`,
|
||||
getRowVal: (row, isGroup) =>
|
||||
isGroup
|
||||
? countCompleted(row.courses, student.studentnumber)
|
||||
: hasCompleted(row.code, student.studentnumber),
|
||||
getRowContent: (row, isGroup) =>
|
||||
isGroup
|
||||
? countCompleted(row.courses, student.studentnumber)
|
||||
: hasCompleted(row.code, student.studentnumber) && <Icon fitted name="check" color="green" />,
|
||||
formatValue: value => (value ? 'Passed' : 'Not passed'),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[modules, pagedStudents]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<StudentNameVisibilityToggle />
|
||||
</div>
|
||||
<Pagination
|
||||
secondary
|
||||
activePage={page + 1}
|
||||
totalPages={maxPages + 1}
|
||||
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
|
||||
ellipsisItem={null}
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
<SortableTable
|
||||
title={
|
||||
<>
|
||||
Courses passed by students
|
||||
<Pagination
|
||||
style={{ marginLeft: '1em' }}
|
||||
size="mini"
|
||||
secondary
|
||||
activePage={page + 1}
|
||||
totalPages={maxPages + 1}
|
||||
onPageChange={(e, { activePage }) => setPage(activePage - 1)}
|
||||
ellipsisItem={null}
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button size="tiny" onClick={() => toggleStudentNames()}>
|
||||
{namesVisible ? 'Hide student names' : 'Show student names'}
|
||||
</Button>
|
||||
}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
<Table sortable className="fixed-header" striped celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{filterInput('nameFilter', 'Name', '3')}
|
||||
{filterInput('codeFilter', 'Code')}
|
||||
{pagedStudents.map(student => (
|
||||
<Table.HeaderCell
|
||||
key={student.studentnumber}
|
||||
content={
|
||||
<Link style={{ color: 'black' }} to={`/students/${student.studentnumber}`}>
|
||||
{showNames ? verticalTitle(student.name) : verticalTitle(student.studentnumber)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{modules.map(({ module, courses }) => (
|
||||
<React.Fragment key={module.code}>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ cursor: 'pointer' }} colSpan="3" onClick={() => toggleGroupExpansion(module.code)}>
|
||||
<Icon name={expandedGroups.has(module.code) ? 'angle down' : 'angle right'} />
|
||||
<b>{getTextIn(module.name, language)}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<b>{module.code}</b>
|
||||
</Table.Cell>
|
||||
{pagedStudents.map(student => (
|
||||
<Table.Cell key={`${module.code}-${student.studentnumber}`}>
|
||||
{countCompleted(courses, student.studentnumber)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
{expandedGroups.has(module.code) &&
|
||||
courses
|
||||
.filter(c => c.visible.visibility)
|
||||
.map(col => (
|
||||
<StudentsRow
|
||||
course={col}
|
||||
pagedStudents={pagedStudents}
|
||||
hasCompleted={sn => hasCompleted(col.code, sn)}
|
||||
onGoToCourseStatisticsClick={onGoToCourseStatisticsClick}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showNames: state.settings.namesVisible,
|
||||
})
|
||||
|
||||
Students.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
expandedGroups: instanceOf(Set).isRequired,
|
||||
toggleGroupExpansion: func.isRequired,
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Students)
|
||||
export default Students
|
||||
|
||||
@@ -195,7 +195,7 @@ const PopulationCourseStats = props => {
|
||||
}))
|
||||
.sort((a, b) => a.module.order - b.module.order)
|
||||
)
|
||||
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, mandatoryCourses])
|
||||
}, [state.studentAmountLimit, props.courses.coursestatistics, state.codeFilter, state.nameFilter, mandatoryCourses])
|
||||
|
||||
const onFilterChange = (e, field) => {
|
||||
const {
|
||||
@@ -229,11 +229,14 @@ const PopulationCourseStats = props => {
|
||||
})
|
||||
}
|
||||
|
||||
const onGoToCourseStatisticsClick = courseCode => {
|
||||
const { clearCourseStats: clearCourseStatsfn } = props
|
||||
sendAnalytics('Courses of Population course stats button clicked', courseCode)
|
||||
clearCourseStatsfn()
|
||||
}
|
||||
const onGoToCourseStatisticsClick = useCallback(
|
||||
courseCode => {
|
||||
const { clearCourseStats: clearCourseStatsfn } = props
|
||||
sendAnalytics('Courses of Population course stats button clicked', courseCode)
|
||||
clearCourseStatsfn()
|
||||
},
|
||||
[sendAnalytics, props.clearCourseStats]
|
||||
)
|
||||
|
||||
const onFilterReset = field => {
|
||||
setFilterFields({ ...filterFields, [field]: '' })
|
||||
@@ -282,48 +285,49 @@ const PopulationCourseStats = props => {
|
||||
tableColumnNames,
|
||||
sortCriteria,
|
||||
reversed,
|
||||
courses,
|
||||
}
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: 'pass/fail',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<Tab.Pane className="menuTab">
|
||||
<PassFail expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'grades',
|
||||
render: () => (
|
||||
<div className="menuTab">
|
||||
<Tab.Pane className="menuTab">
|
||||
<GradeDistribution expandedGroups={expandedGroups} toggleGroupExpansion={toggleGroupExpansion} />
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'when passed',
|
||||
render: () => (
|
||||
<div className="menuTab" style={{ marginTop: '0.5em' }}>
|
||||
<Tab.Pane className="menuTab">
|
||||
<PassingSemesters
|
||||
filterInput={renderFilterInputHeaderCell}
|
||||
courseStatistics={courseStatistics}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'students',
|
||||
render: () => (
|
||||
<div className="menuTab" style={{ marginTop: '0.5em' }}>
|
||||
<Tab.Pane className="menuTab">
|
||||
<Students
|
||||
filteredStudents={props.filteredStudents}
|
||||
expandedGroups={expandedGroups}
|
||||
toggleGroupExpansion={toggleGroupExpansion}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -33,9 +33,3 @@
|
||||
white-space: initial;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menuTab {
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -212,32 +212,40 @@ const GeneralTab = ({
|
||||
const columnsAvailable = {
|
||||
lastname: { key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
|
||||
firstname: { key: 'firstname', title: 'given names', getRowVal: s => s.firstnames },
|
||||
studentnumber: {
|
||||
key: 'studentnumber',
|
||||
title: 'Student Number',
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
getRowContent: s => (
|
||||
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
|
||||
{!s.obfuscated ? s.studentNumber : 'hidden'}
|
||||
</span>
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
icon: {
|
||||
key: 'icon',
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'General tab')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
'studentnumber-parent': {
|
||||
key: 'studentnumber-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
children: [
|
||||
{
|
||||
key: 'studentnumber',
|
||||
title: 'Student Number',
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
getRowContent: s => (
|
||||
<span style={s.obfuscated ? { fontStyle: 'italic', color: 'graytext' } : {}}>
|
||||
{!s.obfuscated ? s.studentNumber : 'hidden'}
|
||||
</span>
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
export: false,
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'General tab')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { className: 'iconCellNoPointer' },
|
||||
},
|
||||
],
|
||||
},
|
||||
creditsSinceStart: {
|
||||
key: 'creditsSinceStart',
|
||||
@@ -303,19 +311,19 @@ const GeneralTab = ({
|
||||
studyStartDate: {
|
||||
key: 'studyStartDate',
|
||||
title: 'start of studyright',
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
|
||||
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]),
|
||||
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
|
||||
},
|
||||
studyStartDateActual: {
|
||||
key: 'studyStartDateActual',
|
||||
title: 'started in studyright',
|
||||
getRowVal: s => new Date(getActualStartDate(s.studentNumber)).getTime(),
|
||||
getRowContent: s => reformatDate(getActualStartDate(s.studentNumber), 'YYYY-MM-DD'),
|
||||
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
|
||||
getRowVal: s => new Date(getActualStartDate(s.studentNumber)),
|
||||
},
|
||||
endDate: {
|
||||
key: 'endDate',
|
||||
title: 'graduation date',
|
||||
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
|
||||
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]),
|
||||
getRowContent: s =>
|
||||
studentToStudyrightEndMap[s.studentNumber]
|
||||
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
|
||||
@@ -393,12 +401,13 @@ const GeneralTab = ({
|
||||
/>
|
||||
) : null,
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
cellProps: { className: 'iconCellNoPointer' },
|
||||
},
|
||||
updatedAt: {
|
||||
key: 'updatedAt',
|
||||
title: 'Last Updated At',
|
||||
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
|
||||
getRowVal: s => new Date(s.updatedAt),
|
||||
formatValue: value => reformatDate(value, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
}
|
||||
// Columns are shown in order they're declared above. JS guarantees this order of keys
|
||||
@@ -418,20 +427,20 @@ const GeneralTab = ({
|
||||
.value()
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
<SortableTable
|
||||
getRowKey={s => s.studentNumber}
|
||||
tableProps={{
|
||||
collapsing: true,
|
||||
basic: true,
|
||||
compact: 'very',
|
||||
padded: false,
|
||||
celled: true,
|
||||
}}
|
||||
columns={columns}
|
||||
data={selectedStudents.map(sn => students[sn])}
|
||||
/>
|
||||
</div>
|
||||
<SortableTable
|
||||
style={{ height: '80vh' }}
|
||||
title="General student information"
|
||||
getRowKey={s => s.studentNumber}
|
||||
tableProps={{
|
||||
collapsing: true,
|
||||
basic: true,
|
||||
compact: 'very',
|
||||
padded: false,
|
||||
celled: true,
|
||||
}}
|
||||
columns={columns}
|
||||
data={selectedStudents.map(sn => students[sn])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -477,7 +486,7 @@ const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
|
||||
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
|
||||
}
|
||||
|
||||
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
|
||||
const baseColumns = ['studentnumber-parent', 'allCredits', 'tags', 'updatedAt', 'option']
|
||||
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
|
||||
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
|
||||
import { orderBy, uniqBy, sortBy, isNumber } from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { useGetAuthorizedUserQuery } from 'redux/auth'
|
||||
import { getTextIn } from '../../common'
|
||||
@@ -145,30 +145,37 @@ const PopulationStudents = ({
|
||||
: []
|
||||
nameColumns.push(
|
||||
{
|
||||
key: 'studentnumber',
|
||||
title: verticalTitle('student number'),
|
||||
cellProps: { title: 'student number' },
|
||||
getRowVal: s => (s.total ? '*' : s.studentNumber),
|
||||
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
title: '',
|
||||
getRowVal: s =>
|
||||
!s.total && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'Mandatory courses table')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { collapsing: true, className: 'iconCell' },
|
||||
child: true,
|
||||
key: 'studentnumber-parent',
|
||||
mergeHeader: true,
|
||||
merge: true,
|
||||
title: 'Student Number',
|
||||
children: [
|
||||
{
|
||||
key: 'studentnumber',
|
||||
cellProps: { title: 'student number' },
|
||||
getRowVal: s => (s.total ? '*' : s.studentNumber),
|
||||
getRowContent: s => (s.total ? 'Summary:' : s.studentNumber),
|
||||
child: true,
|
||||
},
|
||||
{
|
||||
key: 'icon',
|
||||
export: false,
|
||||
getRowVal: s =>
|
||||
!s.total && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/students/${s.studentNumber}`}
|
||||
onClick={() => {
|
||||
sendAnalytics('Student details button clicked', 'Mandatory courses table')
|
||||
}}
|
||||
>
|
||||
<Icon name="level up alternate" />
|
||||
</Item>
|
||||
),
|
||||
cellProps: { className: 'iconCell' },
|
||||
child: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'totalpassed',
|
||||
@@ -224,6 +231,8 @@ const PopulationStudents = ({
|
||||
{ visibleLabels: new Set(), visibleCourseCodes: new Set() }
|
||||
)
|
||||
|
||||
const getTotalRowVal = (t, m) => t[m.code]
|
||||
|
||||
const labelColumns = []
|
||||
labelColumns.push(
|
||||
{
|
||||
@@ -231,6 +240,7 @@ const PopulationStudents = ({
|
||||
title: <b>Labels:</b>,
|
||||
parent: true,
|
||||
headerProps: { colSpan: nameColumns.length, style: { textAlign: 'right' } },
|
||||
children: nameColumns,
|
||||
},
|
||||
...sortedlabels
|
||||
.filter(({ code }) => visibleLabels.has(code))
|
||||
@@ -247,17 +257,7 @@ const PopulationStudents = ({
|
||||
title: e.label,
|
||||
ordernumber: e.orderNumber,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const getTotalRowVal = (t, m) => t[m.code]
|
||||
|
||||
const mandatoryCourseColumns = [
|
||||
...nameColumns,
|
||||
...labelColumns,
|
||||
...flatten(
|
||||
sortedlabels.map(e =>
|
||||
sortBy(labelToMandatoryCourses[e.label], [
|
||||
children: sortBy(labelToMandatoryCourses[e.label], [
|
||||
m => {
|
||||
const res = m.code.match(/\d+/)
|
||||
return res ? Number(res[0]) : Number.MAX_VALUE
|
||||
@@ -270,7 +270,8 @@ const PopulationStudents = ({
|
||||
title: verticalTitle(mandatoryTitle(m)),
|
||||
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
|
||||
getRowVal: s =>
|
||||
s.total ? getTotalRowVal(s, m) : JSON.stringify(hasPassedMandatory(s.studentNumber, m.code)),
|
||||
getRowContent: s => {
|
||||
if (s.total) return getTotalRowVal(s, m)
|
||||
return hasPassedMandatory(s.studentNumber, m.code) ? <Icon fitted name="check" color="green" /> : null
|
||||
@@ -278,10 +279,11 @@ const PopulationStudents = ({
|
||||
child: true,
|
||||
childOf: e.label,
|
||||
code: m.code,
|
||||
}))
|
||||
)
|
||||
),
|
||||
]
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
const mandatoryCourseColumns = [...labelColumns]
|
||||
|
||||
const totals = filteredStudents.reduce(
|
||||
(acc, s) => {
|
||||
@@ -317,9 +319,10 @@ const PopulationStudents = ({
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
<div style={{ maxHeight: '80vh', width: '100%' }}>
|
||||
{mandatoryCourses.length > 0 && (
|
||||
<SortableTable
|
||||
title={`Courses of population's students`}
|
||||
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
|
||||
tableProps={{
|
||||
celled: true,
|
||||
|
||||
@@ -28,6 +28,9 @@ const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = false, ...r
|
||||
if (rest.path.includes('students')) {
|
||||
return hasRequiredRoles || hasRequiredRights
|
||||
}
|
||||
if (rest.path.includes('custompopulation')) {
|
||||
return hasRequiredRoles || hasRequiredRights
|
||||
}
|
||||
return hasRequiredRoles && hasRequiredRights
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const retry = async (fn, retriesLeft = 3, interval = 500) =>
|
||||
})
|
||||
})
|
||||
|
||||
const FrontPage = React.lazy(() => retry(() => import('../FrontPage')))
|
||||
const FrontPage = React.lazy(() => retry(() => import('../Frontpage')))
|
||||
const Populations = React.lazy(() => retry(() => import('../PopulationStatistics')))
|
||||
const StudentStatistics = React.lazy(() => retry(() => import('../StudentStatistics')))
|
||||
const CourseStatistics = React.lazy(() => retry(() => import('../CourseStatistics')))
|
||||
@@ -83,7 +83,13 @@ const Routes = () => (
|
||||
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={Users} />
|
||||
<ProtectedRoute requiredRoles={['teachers']} exact path={routes.teachers} component={Teachers} />
|
||||
<ProtectedRoute requireUserHasRights exact path={routes.coursepopulation} component={CoursePopulation} />
|
||||
<ProtectedRoute requireUserHasRights exact path={routes.custompopulation} component={CustomPopulation} />
|
||||
<ProtectedRoute
|
||||
requiredRoles={['admin', 'studyGuidanceGroups']}
|
||||
requireUserHasRights
|
||||
exact
|
||||
path={routes.custompopulation}
|
||||
component={CustomPopulation}
|
||||
/>
|
||||
<ProtectedRoute requiredRoles={['admin']} requireUserHasRights exact path={routes.updater} component={Updater} />
|
||||
<ProtectedRoute
|
||||
requiredRoles={['studyGuidanceGroups']}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import xlsx from 'xlsx'
|
||||
import { getDataItemType, getColumnValue, DataItemType, DataVisitor } from './common'
|
||||
|
||||
const getExportColumns = columns => {
|
||||
const stack = _.cloneDeep(columns)
|
||||
|
||||
const exportColumns = []
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [column] = stack.splice(0, 1)
|
||||
|
||||
const parents = column.parents ?? []
|
||||
|
||||
if (column.export === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (column.children && column.children.length > 0) {
|
||||
stack.splice(0, 0, ...column.children.map(c => ({ ...c, parents: [...parents, column] })))
|
||||
continue
|
||||
}
|
||||
|
||||
exportColumns.push(column)
|
||||
}
|
||||
|
||||
return exportColumns
|
||||
}
|
||||
|
||||
const flattenData = data => {
|
||||
const stack = [...data]
|
||||
const flat = []
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [item] = stack.splice(0, 1)
|
||||
|
||||
if (getDataItemType(item) === DataItemType.Group) {
|
||||
stack.splice(0, 0, ...item.children)
|
||||
} else {
|
||||
flat.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return flat
|
||||
}
|
||||
|
||||
const getColumnTitle = column => {
|
||||
return [...(column.parents ?? []), column]
|
||||
.filter(c => c.title)
|
||||
.map(c => c.title)
|
||||
.join(' - ')
|
||||
}
|
||||
|
||||
class ExportVisitor extends DataVisitor {
|
||||
constructor(columns) {
|
||||
super()
|
||||
this.columns = columns
|
||||
this.rows = []
|
||||
}
|
||||
|
||||
visitRow(ctx) {
|
||||
const row = _.chain(this.columns)
|
||||
.map(column => [getColumnTitle(column), getColumnValue(ctx, column)])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
this.rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
class ValueSamplerVisitor extends DataVisitor {
|
||||
constructor(columns) {
|
||||
super()
|
||||
this.columns = columns
|
||||
this.values = _.fromPairs(_.map(columns, c => [c.key, new Set()]))
|
||||
}
|
||||
|
||||
visitRow(ctx) {
|
||||
this.columns.forEach(column => {
|
||||
const value = getColumnValue(ctx, column)
|
||||
this.values[column.key].add(value)
|
||||
})
|
||||
}
|
||||
|
||||
sample(n) {
|
||||
return _.chain(this.values)
|
||||
.mapValues(v => _.sampleSize([...v], n))
|
||||
.value()
|
||||
}
|
||||
}
|
||||
|
||||
const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
|
||||
const exportColumns = useMemo(() => getExportColumns(columns), [columns])
|
||||
const flatData = useMemo(() => flattenData(data), [data])
|
||||
const [selected, setSelected] = useState(_.uniq(_.map(exportColumns, 'key')))
|
||||
|
||||
const sampledValues = useMemo(() => ValueSamplerVisitor.visit(data, exportColumns).sample(10), [exportColumns, data])
|
||||
|
||||
const handleExport = () => {
|
||||
const columns = exportColumns.filter(ec => _.includes(selected, ec.key))
|
||||
const { rows } = ExportVisitor.visit(data, columns)
|
||||
const sheet = xlsx.utils.json_to_sheet(rows)
|
||||
const book = xlsx.utils.book_new()
|
||||
xlsx.utils.book_append_sheet(book, sheet)
|
||||
xlsx.writeFile(book, 'Oodikone_Export.xlsx')
|
||||
}
|
||||
|
||||
const toggleSelection = key => {
|
||||
setSelected(prev => {
|
||||
const i = prev.indexOf(key)
|
||||
|
||||
if (i > -1) {
|
||||
const n = [...prev]
|
||||
n.splice(i, 1)
|
||||
return n
|
||||
}
|
||||
return [...prev, key]
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onOpen={onOpen} onClose={onClose} open={open}>
|
||||
<Modal.Header>Export to Excel</Modal.Header>
|
||||
<Modal.Content>
|
||||
<p>
|
||||
Exporting {flatData.length} rows into an Excel (.xlsx) file. Choose which columns you want to include in the
|
||||
generated field from the list below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setSelected(exportColumns.map(c => c.key))}
|
||||
disabled={selected.length === exportColumns.length}
|
||||
>
|
||||
Include all
|
||||
</Button>
|
||||
<Button onClick={() => setSelected([])} disabled={selected.length === 0}>
|
||||
Exclude all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table celled striped>
|
||||
<Table.Header>
|
||||
<Table.HeaderCell />
|
||||
<Table.HeaderCell>Column</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sample Values</Table.HeaderCell>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{exportColumns.map(column => (
|
||||
<Table.Row key={column.key} onClick={() => toggleSelection(column.key)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Cell collapsing verticalAlign="middle">
|
||||
<Checkbox checked={_.includes(selected, column.key)} />
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>{getColumnTitle(column)}</Table.Cell>
|
||||
<Table.Cell style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ width: '0' }}>
|
||||
{sampledValues[column.key].map(value => (
|
||||
<span
|
||||
style={{
|
||||
border: '1px solid #dedede',
|
||||
borderRadius: '3px',
|
||||
padding: '0px 3px',
|
||||
backgroundColor: '#f9fafb',
|
||||
marginRight: '0.25em',
|
||||
color: '#555',
|
||||
}}
|
||||
>
|
||||
{column.formatValue ? column.formatValue(value) : `${value}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button content="Cancel" onClick={() => onClose()} />
|
||||
<Button primary content="Export" onClick={() => handleExport()} />
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportModal
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
const DataItemTypeKey = Symbol('DATA_ITEM_TYPE')
|
||||
|
||||
export const DataItemType = {
|
||||
Group: 'group',
|
||||
Row: 'row',
|
||||
}
|
||||
|
||||
export const getDataItemType = item => {
|
||||
if (item[DataItemTypeKey] === DataItemType.Group) {
|
||||
return DataItemType.Group
|
||||
}
|
||||
|
||||
return DataItemType.Row
|
||||
}
|
||||
|
||||
export const group = (definition, children) => {
|
||||
return {
|
||||
definition,
|
||||
children,
|
||||
[DataItemTypeKey]: DataItemType.Group,
|
||||
}
|
||||
}
|
||||
|
||||
export const getColumnValue = (ctx, column) => {
|
||||
if (column.getRowVal) {
|
||||
return column.getRowVal(ctx.item, ctx.isGroup, ctx.parents)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class DataVisitorContext {
|
||||
constructor(visitor, item, parents = [], isGroup = false) {
|
||||
this.visitor = visitor
|
||||
this.item = item
|
||||
this.parents = parents
|
||||
this.isGroup = isGroup
|
||||
}
|
||||
|
||||
visitItem(item) {
|
||||
return this.visitor.visitItem(item, this)
|
||||
}
|
||||
|
||||
withGroup(group) {
|
||||
return new DataVisitorContext(this.visitor, group, [group, ...this.parents], true)
|
||||
}
|
||||
|
||||
withRow(row) {
|
||||
return new DataVisitorContext(this.visitor, row, this.parents, false)
|
||||
}
|
||||
}
|
||||
|
||||
export class DataVisitor {
|
||||
visitRow() {}
|
||||
|
||||
visitGroup(ctx) {
|
||||
ctx.item.children.forEach(item => ctx.visitItem(item))
|
||||
}
|
||||
|
||||
visitItem(item, pCtx) {
|
||||
let ctx = pCtx
|
||||
|
||||
if (ctx === undefined) {
|
||||
ctx = new DataVisitorContext(this, null)
|
||||
}
|
||||
|
||||
const type = getDataItemType(item)
|
||||
|
||||
if (type === DataItemType.Group) {
|
||||
return this.visitGroup(ctx.withGroup(item))
|
||||
}
|
||||
return this.visitRow(ctx.withRow(item))
|
||||
}
|
||||
|
||||
static visit(items, ...args) {
|
||||
const visitor = new this(...args)
|
||||
items.forEach(item => visitor.visitItem(item))
|
||||
return visitor
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,34 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, bool } from 'prop-types'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Radio } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
|
||||
import TSA from '../../common/tsa'
|
||||
import { toggleStudentNameVisibility } from '../../redux/settings'
|
||||
|
||||
const StudentNameVisibilityToggle = ({ showNames, toggleStudentNameVisibility: toggle }) => {
|
||||
export const useStudentNameVisibility = () => {
|
||||
const visible = useSelector(state => state.settings.namesVisible)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
dispatch(toggleStudentNameVisibility())
|
||||
}, [dispatch])
|
||||
|
||||
return { visible, toggle }
|
||||
}
|
||||
|
||||
const StudentNameVisibilityToggle = ({ style = {} }) => {
|
||||
const { visible, toggle } = useStudentNameVisibility()
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', showNames ? 'hide' : 'show')
|
||||
TSA.Matomo.sendEvent('Common', 'Toggle student name visibility', visible ? 'hide' : 'show')
|
||||
toggle()
|
||||
}, [showNames, toggle])
|
||||
}, [visible, toggle])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 15, marginBottom: 10 }}>
|
||||
<Radio
|
||||
data-cy="toggleStudentNames"
|
||||
toggle
|
||||
label="Show student names"
|
||||
checked={showNames}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div style={{ marginTop: 15, marginBottom: 10, ...style }}>
|
||||
<Radio data-cy="toggleStudentNames" toggle label="Show student names" checked={visible} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
StudentNameVisibilityToggle.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
toggleStudentNameVisibility: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showNames: state.settings.namesVisible,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { toggleStudentNameVisibility })(withRouter(StudentNameVisibilityToggle))
|
||||
export default StudentNameVisibilityToggle
|
||||
|
||||
@@ -28,13 +28,11 @@ const TagsTable = ({ student, language }) => {
|
||||
key: 'PROGRAMME',
|
||||
title: 'Programme',
|
||||
getRowVal: t => getTextIn(t.programme.name, language),
|
||||
cellProps: { collapsing: true },
|
||||
},
|
||||
{
|
||||
key: 'CODE',
|
||||
title: 'Code',
|
||||
getRowVal: t => t.programme.code,
|
||||
cellProps: { collapsing: true },
|
||||
},
|
||||
{
|
||||
key: 'TAGS',
|
||||
|
||||
@@ -182,7 +182,18 @@ const SingleStudyGroupContent = ({ filteredStudents, population, group, language
|
||||
},
|
||||
]
|
||||
|
||||
return <Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={createPanels(filteredStudents)} />
|
||||
return (
|
||||
<div style={{ overflow: 'hidden', flexGrow: 1, padding: '1px' }}>
|
||||
<Accordion
|
||||
activeIndex={activeIndex}
|
||||
exclusive={false}
|
||||
styled
|
||||
fluid
|
||||
panels={createPanels(filteredStudents)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGroupFilterView = props => {
|
||||
|
||||
+10
-8
@@ -38,7 +38,7 @@ const prettifyCamelCase = str => {
|
||||
}
|
||||
|
||||
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
|
||||
const cellContent = { flex: '0 1 50%' }
|
||||
const cellContent = { flexGrow: 1 }
|
||||
|
||||
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
|
||||
@@ -99,7 +99,7 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={cellContent}>
|
||||
<div style={{ ...cellContent, flexGrow: 0 }}>
|
||||
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
|
||||
Add {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
@@ -134,7 +134,7 @@ const TagCell = ({ tagName, group, studyProgrammes }) => {
|
||||
return group.tags?.[tagName] && !showEdit ? (
|
||||
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
|
||||
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
|
||||
<div style={cellContent}>
|
||||
<div style={{ ...cellContent, flexGrow: 0 }}>
|
||||
<Button type="button" onClick={() => toggleEdit()}>
|
||||
Edit {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
@@ -152,7 +152,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
const headers = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: group => getTextIn(group.name, language),
|
||||
getRowContent: group => <LinkToGroup group={group} language={language} />,
|
||||
cellProps: {
|
||||
@@ -164,7 +164,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'students',
|
||||
title: 'Students',
|
||||
getRowVal: group => group.members?.length || 0,
|
||||
getRowContent: group => group.members?.length || 0,
|
||||
cellProps: {
|
||||
@@ -176,14 +176,16 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
},
|
||||
{
|
||||
key: 'studyProgramme',
|
||||
title: 'Study programme',
|
||||
title: 'Study Programme',
|
||||
getRowVal: group => group.tags?.studyProgramme,
|
||||
formatValue: value => studyProgrammes.find(p => p.value === value)?.text,
|
||||
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedyear',
|
||||
title: 'Associated starting academic year',
|
||||
title: 'Associated Starting Academic Year',
|
||||
getRowVal: group => group.tags?.year,
|
||||
formatValue: startYearToAcademicYear,
|
||||
getRowContent: group => <TagCell tagName="year" group={group} />,
|
||||
},
|
||||
]
|
||||
@@ -200,7 +202,7 @@ const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
lisäominaisuuksia.{' '}
|
||||
</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
<SortableTable figure={false} columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, Header, Grid, Segment } from 'semantic-ui-react'
|
||||
import { shape, any, arrayOf, bool, oneOfType, array } from 'prop-types'
|
||||
|
||||
const DIRECTIONS = {
|
||||
ASC: 'ascending', // true
|
||||
DESC: 'descending', // false
|
||||
}
|
||||
|
||||
function sortBy(column, data) {
|
||||
const sortByString = column === 'name' || column === 'code'
|
||||
return data.concat().sort((a, b) => {
|
||||
const keyA = column === 'name' ? a[column].fi : a[column]
|
||||
const keyB = column === 'name' ? b[column].fi : b[column]
|
||||
return sortByString ? keyB.localeCompare(keyA) : keyB - keyA
|
||||
})
|
||||
}
|
||||
|
||||
const BachelorsTable = ({ bachelors, loading }) => {
|
||||
const [bachelorsData, setBachelorsData] = useState({})
|
||||
const [sortColumn, setSortColumn] = useState(null)
|
||||
const [direction, setDirection] = useState(false)
|
||||
|
||||
useEffect(() => setBachelorsData(bachelors), [bachelors])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bachelorsData || !bachelorsData.data) return
|
||||
const data = sortBy(sortColumn, bachelorsData.data)
|
||||
if (direction) data.reverse()
|
||||
setBachelorsData({ ...bachelorsData, data })
|
||||
}, [direction, sortColumn])
|
||||
|
||||
const getSorted = column => {
|
||||
if (column !== sortColumn) return null
|
||||
return direction ? DIRECTIONS.ASC : DIRECTIONS.DESC
|
||||
}
|
||||
|
||||
const setSorted = column => {
|
||||
if (column === sortColumn) setDirection(!direction)
|
||||
else {
|
||||
setSortColumn(column)
|
||||
setDirection(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!loading && (!bachelorsData.data || !bachelorsData.data.length)) return null
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Grid columns={2}>
|
||||
<Grid.Row>
|
||||
<Grid.Column>Study programme options</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Header>
|
||||
{bachelorsData && bachelorsData.years && `Statistics from ${bachelorsData.years.length} years`}
|
||||
<Segment basic loading={loading} style={{ overflowX: 'auto' }}>
|
||||
<Table celled structured compact striped selectable sortable>
|
||||
{bachelorsData && bachelorsData.data ? (
|
||||
<>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell sorted={getSorted('name')} onClick={() => setSorted('name')}>
|
||||
Name
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell sorted={getSorted('code')} onClick={() => setSorted('code')}>
|
||||
Code
|
||||
</Table.HeaderCell>
|
||||
{bachelorsData.years.map(year => (
|
||||
<Table.HeaderCell sorted={getSorted(year)} onClick={() => setSorted(year)} key={year}>
|
||||
{year}
|
||||
</Table.HeaderCell>
|
||||
))}
|
||||
<Table.HeaderCell sorted={getSorted('total')} onClick={() => setSorted('total')}>
|
||||
Total
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{bachelorsData.data.map(item => (
|
||||
<Table.Row key={item.code}>
|
||||
<Table.Cell>{item.name.fi}</Table.Cell>
|
||||
<Table.Cell>{item.code}</Table.Cell>
|
||||
{bachelorsData.years.map(year => (
|
||||
<Table.Cell key={year}>{item[year]}</Table.Cell>
|
||||
))}
|
||||
<Table.Cell>{item.total}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</>
|
||||
) : null}
|
||||
</Table>
|
||||
</Segment>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
BachelorsTable.propTypes = {
|
||||
bachelors: oneOfType([
|
||||
shape({
|
||||
data: arrayOf(any),
|
||||
years: arrayOf(any),
|
||||
}),
|
||||
array,
|
||||
]),
|
||||
loading: bool.isRequired,
|
||||
}
|
||||
|
||||
BachelorsTable.defaultProps = {
|
||||
bachelors: null,
|
||||
}
|
||||
|
||||
export default BachelorsTable
|
||||
+2
-2
@@ -4,7 +4,7 @@ const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const BarChart = ({ data }) => {
|
||||
const BarChart = ({ cypress, data }) => {
|
||||
const dataWithColors = data?.graphStats?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const defaultConfig = {
|
||||
@@ -51,7 +51,7 @@ const BarChart = ({ data }) => {
|
||||
|
||||
if (!data) return <></>
|
||||
return (
|
||||
<div className="graph-container">
|
||||
<div className="graph-container" data-cy={`Graph-${cypress}`}>
|
||||
<ReactHighcharts config={defaultConfig} />
|
||||
</div>
|
||||
)
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
const DataTable = ({ data, titles }) => {
|
||||
const DataTable = ({ data, cypress, titles, wideTable }) => {
|
||||
if (!data || !titles) return null
|
||||
|
||||
return (
|
||||
<div className="table-container">
|
||||
<Table celled>
|
||||
<div className={`table-container${wideTable ? '-wide' : ''}`}>
|
||||
<Table data-cy={`Table-${cypress}`} celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{titles?.map(title => (
|
||||
+11
-7
@@ -2,8 +2,8 @@ import React from 'react'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const GaugeChart = ({ data, year, amount, studyprogramme }) => {
|
||||
if (!data) return null
|
||||
const GaugeChart = ({ cypress, data, year, graduationAmount, studyprogramme }) => {
|
||||
if (!data || !graduationAmount || !data.length) return null
|
||||
|
||||
const thresholdValues = studyprogramme.includes('KH') ? [36, 41] : [24, 27]
|
||||
|
||||
@@ -29,7 +29,7 @@ const GaugeChart = ({ data, year, amount, studyprogramme }) => {
|
||||
y: 60,
|
||||
},
|
||||
subtitle: {
|
||||
text: `${year}<br/> n = ${amount}`,
|
||||
text: `${year}<br/> n = ${graduationAmount}`,
|
||||
style: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: '15px',
|
||||
@@ -38,9 +38,6 @@ const GaugeChart = ({ data, year, amount, studyprogramme }) => {
|
||||
credits: {
|
||||
text: '',
|
||||
},
|
||||
tooltip: {
|
||||
pointFormat: '{series.name}: <b>{point.percentage:.1f}%</b>',
|
||||
},
|
||||
exporting: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -66,6 +63,9 @@ const GaugeChart = ({ data, year, amount, studyprogramme }) => {
|
||||
colors: getColor(data[0][1]),
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
@@ -76,7 +76,11 @@ const GaugeChart = ({ data, year, amount, studyprogramme }) => {
|
||||
],
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={defaultConfig} />
|
||||
return (
|
||||
<div data-cy={`Graph-${cypress}`}>
|
||||
<ReactHighcharts config={defaultConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GaugeChart
|
||||
+6
-3
@@ -7,8 +7,11 @@ require('highcharts-exporting')(ReactHighcharts.Highcharts)
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#E68825', '#333737', '#036415']
|
||||
|
||||
const LineGraph = ({ data }) => {
|
||||
const dataWithColors = data?.graphStats.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
const LineGraph = ({ cypress, data }) => {
|
||||
const dataWithColors = data?.graphStats.map((series, index) => ({
|
||||
...series,
|
||||
color: colors[index],
|
||||
}))
|
||||
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
@@ -46,7 +49,7 @@ const LineGraph = ({ data }) => {
|
||||
|
||||
if (!data) return <></>
|
||||
return (
|
||||
<div className="graph-container">
|
||||
<div className="graph-container" data-cy={`Graph-${cypress}`}>
|
||||
<ReactHighcharts config={defaultConfig} />
|
||||
</div>
|
||||
)
|
||||
+5
-5
@@ -2,10 +2,10 @@ import React from 'react'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
const colors = ['#003E65', '#1392c2', '#036415', '#bdc2c7', '#E68825', '#333737', '#54997b', '#195f8a']
|
||||
|
||||
const StackedBarChart = ({ data }) => {
|
||||
const dataWithColors = data?.graphStats?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
const StackedBarChart = ({ cypress, data, labels, wideTable }) => {
|
||||
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
@@ -16,7 +16,7 @@ const StackedBarChart = ({ data }) => {
|
||||
text: 'oodikone | TOSKA',
|
||||
},
|
||||
xAxis: {
|
||||
categories: data?.years,
|
||||
categories: labels,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
@@ -55,7 +55,7 @@ const StackedBarChart = ({ data }) => {
|
||||
|
||||
if (!data) return <></>
|
||||
return (
|
||||
<div className="graph-container">
|
||||
<div className={`graph-container${wideTable ? '-narrow' : ''}`} data-cy={`Graph-${cypress}`}>
|
||||
<ReactHighcharts config={defaultConfig} />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Loader, Message } from 'semantic-ui-react'
|
||||
|
||||
import { useGetBasicStatsQuery, useGetCreditStatsQuery, useGetGraduationStatsQuery } from 'redux/studyProgramme'
|
||||
import LineGraph from './LineGraph'
|
||||
import StackedBarChart from './StackedBarChart'
|
||||
import BarChart from './BarChart'
|
||||
import GaugeChart from './GaugeChart'
|
||||
import DataTable from './DataTable'
|
||||
import Toggle from '../Toggle'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
|
||||
import InfotoolTips from '../../../common/InfoToolTips'
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYear, setAcademicYear }) => {
|
||||
const [showMeanTime, setShowMeanTime] = useState(true)
|
||||
const toolTips = InfotoolTips.Studyprogramme
|
||||
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
|
||||
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
|
||||
const basics = useGetBasicStatsQuery({ id: studyprogramme, yearType, specialGroups: special })
|
||||
const credits = useGetCreditStatsQuery({ id: studyprogramme, yearType, specialGroups: special })
|
||||
const graduations = useGetGraduationStatsQuery({ id: studyprogramme, yearType, specialGroups: special })
|
||||
|
||||
const getDivider = (title, toolTipText) => (
|
||||
<>
|
||||
<div className="divider">
|
||||
<Divider data-cy={`Section-${toolTipText}`} horizontal>
|
||||
{title}
|
||||
</Divider>
|
||||
</div>
|
||||
<InfoBox content={toolTips[toolTipText]} />
|
||||
</>
|
||||
)
|
||||
|
||||
const isFetchingOrLoading =
|
||||
basics.isLoading ||
|
||||
credits.isLoading ||
|
||||
graduations.isLoading ||
|
||||
basics.isFetching ||
|
||||
credits.isFetching ||
|
||||
graduations.isFetching
|
||||
|
||||
const isError =
|
||||
(basics.isError && credits.isError && graduations.isError) ||
|
||||
(basics.isSuccess &&
|
||||
!basics.data &&
|
||||
credits.isSuccess &&
|
||||
!credits.data &&
|
||||
graduations.isSuccess &&
|
||||
!graduations.data)
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
|
||||
return (
|
||||
<div className="studyprogramme-overview">
|
||||
<div className="toggle-container">
|
||||
<Toggle
|
||||
cypress="YearToggle"
|
||||
toolTips={toolTips.YearToggle}
|
||||
firstLabel="Calendar year"
|
||||
secondLabel="Academic year"
|
||||
value={academicYear}
|
||||
setValue={setAcademicYear}
|
||||
/>
|
||||
<Toggle
|
||||
cypress="StudentToggle"
|
||||
toolTips={toolTips.StudentToggle}
|
||||
firstLabel="All studyrights"
|
||||
secondLabel="Special studyrights excluded"
|
||||
value={specialGroups}
|
||||
setValue={setSpecialGroups}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFetchingOrLoading ? (
|
||||
<Loader active style={{ marginTop: '10em' }} />
|
||||
) : (
|
||||
<>
|
||||
{!studyprogramme.includes('KH') && !studyprogramme.includes('MH') && (
|
||||
<Message>
|
||||
Please note, that the data is not complete only for current Master's and Bachelor's programmes
|
||||
</Message>
|
||||
)}
|
||||
{basics.isSuccess && basics.data && (
|
||||
<>
|
||||
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
|
||||
<div className="section-container">
|
||||
<LineGraph cypress="StudentsOfTheStudyprogramme" data={basics?.data} />
|
||||
<DataTable
|
||||
cypress="StudentsOfTheStudyprogramme"
|
||||
data={basics?.data?.tableStats}
|
||||
titles={basics?.data?.titles}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{credits.isSuccess && credits.data && (
|
||||
<>
|
||||
{getDivider('Credits produced by the studyprogramme', 'CreditsProducedByTheStudyprogramme')}
|
||||
<div className="section-container">
|
||||
<StackedBarChart
|
||||
cypress="CreditsProducedByTheStudyprogramme"
|
||||
data={credits?.data?.graphStats}
|
||||
labels={credits?.data?.years}
|
||||
/>
|
||||
<DataTable
|
||||
cypress="CreditsProducedByTheStudyprogramme"
|
||||
data={credits?.data?.tableStats}
|
||||
titles={credits?.data?.titles}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{graduations.isSuccess && graduations.data && (
|
||||
<>
|
||||
{getDivider('Graduated and thesis writers of the programme', 'GraduatedAndThesisWritersOfTheProgramme')}
|
||||
<div className="section-container">
|
||||
<BarChart cypress="GraduatedAndThesisWritersOfTheProgramme" data={graduations?.data} />
|
||||
<DataTable
|
||||
cypress="GraduatedAndThesisWritersOfTheProgramme"
|
||||
data={graduations?.data?.tableStats}
|
||||
titles={graduations?.data?.titles}
|
||||
/>
|
||||
</div>
|
||||
{getDivider('Average graduation times', 'AverageGraduationTimes')}
|
||||
<Toggle
|
||||
cypress="GraduationTimeToggle"
|
||||
firstLabel="Mean time"
|
||||
secondLabel="Median time"
|
||||
value={showMeanTime}
|
||||
setValue={setShowMeanTime}
|
||||
/>
|
||||
<div className="section-container-centered">
|
||||
{graduations?.data?.years.map(year => (
|
||||
<GaugeChart
|
||||
cypress={`${year}-AverageGraduationTimes`}
|
||||
key={year}
|
||||
year={year}
|
||||
data={
|
||||
showMeanTime
|
||||
? graduations?.data?.graduationMeanTime?.[year]
|
||||
: graduations?.data?.graduationMedianTime?.[year]
|
||||
}
|
||||
graduationAmount={graduations?.data?.graduationAmounts?.[year]}
|
||||
totalAmount={graduations?.data?.totalAmounts?.[year]}
|
||||
studyprogramme={studyprogramme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{getDivider(
|
||||
studyprogramme.includes('KH')
|
||||
? 'Primary master programme studies after this programme'
|
||||
: 'Primary bachelor programme studies before this programme',
|
||||
'ProgrammesBeforeOrAfter'
|
||||
)}
|
||||
<div className="section-container">
|
||||
<StackedBarChart
|
||||
cypress="ProgrammesBeforeOrAfter"
|
||||
wideTable
|
||||
data={graduations?.data?.programmesBeforeOrAfterGraphStats}
|
||||
labels={graduations?.data?.years}
|
||||
/>
|
||||
<DataTable
|
||||
wideTable
|
||||
data={graduations?.data?.programmesBeforeOrAfterTableStats}
|
||||
titles={graduations?.data?.programmesBeforeOrAfterTitles}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Overview
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Loader, Radio } from 'semantic-ui-react'
|
||||
|
||||
import { useGetBasicStatsQuery, useGetCreditStatsQuery, useGetGraduationStatsQuery } from 'redux/studyProgramme'
|
||||
import LineGraph from './LineGraph'
|
||||
import StackedBarChart from './StackedBarChart'
|
||||
import BarChart from './BarChart'
|
||||
import GaugeChart from './GaugeChart'
|
||||
import DataTable from './DataTable'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
|
||||
import InfotoolTips from '../../../common/InfoToolTips'
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const basicsTitles = ['', 'Started', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to']
|
||||
const creditsTitles = ['', 'Total', 'Major students credits', 'Non major students credits', 'Transferred credits']
|
||||
const graduationsTitles = ['', 'Graduated', 'Wrote thesis']
|
||||
|
||||
const getRadioButton = (firstLabel, secondLabel, value, setValue) => (
|
||||
<div className="radio-toggle">
|
||||
<label className="toggle-label">{firstLabel}</label>
|
||||
<Radio toggle checked={value} onChange={() => setValue(!value)} />
|
||||
<label className="toggle-label">{secondLabel}</label>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Overview = ({ studyprogramme }) => {
|
||||
const [academicYear, setAcademicYear] = useState(false)
|
||||
const [showMeanTime, setShowMeanTime] = useState(true)
|
||||
const toolTips = InfotoolTips.Studyprogramme
|
||||
const yearType = academicYear ? 'ACADEMIC_YEAR' : 'CALENDAR_YEAR'
|
||||
const basics = useGetBasicStatsQuery({ id: studyprogramme, yearType })
|
||||
const credits = useGetCreditStatsQuery({ id: studyprogramme, yearType })
|
||||
const graduations = useGetGraduationStatsQuery({ id: studyprogramme, yearType })
|
||||
|
||||
const getDivider = (title, toolTipText) => (
|
||||
<>
|
||||
<div className="divider">
|
||||
<Divider horizontal>{title}</Divider>
|
||||
</div>
|
||||
<InfoBox content={toolTips[toolTipText]} />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="studyprogramme-overview">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '10px' }}>
|
||||
<p style={{ color: 'red' }}>
|
||||
Please note that this view is still very much a work in progress. This view is only visible to some admins.
|
||||
</p>
|
||||
</div>
|
||||
{getRadioButton('kalenterivuosi', 'lukuvuosi', academicYear, setAcademicYear)}
|
||||
{basics.isLoading || credits.isLoading || graduations.isLoading ? (
|
||||
<Loader active={basics.isLoading || credits.isLoading || graduations.isLoading} />
|
||||
) : (
|
||||
<>
|
||||
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
|
||||
<div className="section-container">
|
||||
<LineGraph data={basics?.data} />
|
||||
<DataTable titles={basicsTitles} data={basics?.data?.tableStats} />
|
||||
</div>
|
||||
{getDivider('Credits produced by the studyprogramme', 'CreditsProducedByTheStudyprogramme')}
|
||||
<div className="section-container">
|
||||
<StackedBarChart data={credits?.data} />
|
||||
<DataTable titles={creditsTitles} data={credits?.data?.tableStats} />
|
||||
</div>
|
||||
{getDivider('Graduated and thesis writers of the programme', 'GraduatedAndThesisWritersOfTheProgramme')}
|
||||
<div className="section-container">
|
||||
<BarChart data={graduations?.data} />
|
||||
<DataTable titles={graduationsTitles} data={graduations?.data?.tableStats} />
|
||||
</div>
|
||||
{getDivider('Average graduation times', 'AverageGraduationTimes')}
|
||||
{getRadioButton('Mean time', 'Median time', showMeanTime, setShowMeanTime)}
|
||||
{showMeanTime ? (
|
||||
<div className="section-container">
|
||||
{graduations?.data?.years.map(year => (
|
||||
<GaugeChart
|
||||
key={year}
|
||||
year={year}
|
||||
data={graduations?.data?.graduationMedianTime[year]}
|
||||
amount={graduations?.data?.graduationAmounts[year]}
|
||||
studyprogramme={studyprogramme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-container">
|
||||
{graduations?.data?.years.map(year => (
|
||||
<GaugeChart
|
||||
key={year}
|
||||
year={year}
|
||||
data={graduations?.data?.graduationMeanTime[year]}
|
||||
amount={graduations?.data?.graduationAmounts[year]}
|
||||
studyprogramme={studyprogramme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Overview
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Icon, Table } from 'semantic-ui-react'
|
||||
|
||||
const getKey = year => `${year}-${Math.random()}`
|
||||
|
||||
const getFirstCell = (yearlyData, year, show, singleTrack) => {
|
||||
if (yearlyData.length === 1 || singleTrack) {
|
||||
return <Table.Cell key={getKey(year)}>{year}</Table.Cell>
|
||||
}
|
||||
return (
|
||||
<Table.Cell key={getKey(year)}>
|
||||
<Icon name={`${show ? 'angle down' : 'angle right'}`} />
|
||||
{year}
|
||||
</Table.Cell>
|
||||
)
|
||||
}
|
||||
|
||||
const getRow = ({ yearlyData, array, show, setShow, singleTrack }) => {
|
||||
if (array[0].includes('20') && !singleTrack) {
|
||||
return (
|
||||
<Table.Row key={getKey(array[0])} className="header-row" onClick={() => setShow(!show)}>
|
||||
{array.map((value, index) =>
|
||||
index === 0 ? (
|
||||
getFirstCell(yearlyData, array[0], show, singleTrack)
|
||||
) : (
|
||||
<Table.Cell textAlign="left">{value}</Table.Cell>
|
||||
)
|
||||
)}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
if (show || singleTrack) {
|
||||
return (
|
||||
<Table.Row key={getKey(array[0])} className="regular-row">
|
||||
{array.map((value, index) =>
|
||||
index === 0 && !singleTrack ? (
|
||||
<Table.Cell textAlign="left" style={{ paddingLeft: '50px' }} key={getKey(array[0])}>
|
||||
{value}
|
||||
</Table.Cell>
|
||||
) : (
|
||||
<Table.Cell textAlign="left" key={getKey(array[0])}>
|
||||
{value}
|
||||
</Table.Cell>
|
||||
)
|
||||
)}
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const StudytrackDataTable = ({ dataOfAllTracks, dataOfSingleTrack, titles }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
if (!dataOfAllTracks && !dataOfSingleTrack) return null
|
||||
|
||||
const sortedMainStats = []
|
||||
Object.values(dataOfAllTracks).forEach(arrays => {
|
||||
if (arrays.length) {
|
||||
const copy = [...arrays]
|
||||
const sortedYear = copy.sort((a, b) => {
|
||||
if (a[0] < b[0]) return 1
|
||||
if (a[0] > b[0]) return -1
|
||||
return 0
|
||||
})
|
||||
sortedMainStats.push(sortedYear.reverse())
|
||||
}
|
||||
})
|
||||
|
||||
sortedMainStats.reverse()
|
||||
|
||||
return (
|
||||
<div className="datatable">
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{titles.map((title, index) => (
|
||||
<Table.HeaderCell
|
||||
key={title}
|
||||
colSpan={index === 0 ? 1 : 2}
|
||||
textAlign="left"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{title}
|
||||
</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{dataOfSingleTrack
|
||||
? dataOfSingleTrack.map(array =>
|
||||
getRow({ yearlyData: dataOfSingleTrack, array, show, setShow, singleTrack: true })
|
||||
)
|
||||
: sortedMainStats?.map(yearlyData => yearlyData.map(array => getRow({ yearlyData, array, show, setShow })))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudytrackDataTable
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user