Compare commits

...
95 Commits
Author SHA1 Message Date
Petri Palmu 16df458433 added attainment date population of course 2022-03-08 13:51:38 +02:00
Petri Palmu 2c84a0f73d test for unify toggle 2022-03-08 10:35:51 +02:00
Petri Palmu 0152d0c549 unify open courses radio buttons disabled if no data 2022-03-08 10:35:51 +02:00
Petri Palmu fe03fa89c6 make course stats comparizon view horizontal again 2022-03-08 10:35:51 +02:00
Mitja Karhusaari 891cabfbd7 Move name columns to the left-most edge of the student search results table 2022-03-08 09:45:43 +02:00
Mitja Karhusaari b7d2b7f804 Display pointer cursor when hovering sortable column header 2022-03-08 09:45:42 +02:00
Mitja Karhusaari b7740c3b9b Unoverkillify the student search results table
Resolves #3461
2022-03-08 09:45:42 +02:00
Mitja Karhusaari 620c6859ae Limit width of the course name column in Courses of Population tables
Resolves #3459
2022-03-08 09:45:42 +02:00
Saara SatokangasandGitHub 5e30c279ed Merge pull request #3389 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/nanoid-3.2.0
Bump nanoid from 3.1.23 to 3.2.0 in /services/frontend
2022-03-07 16:39:26 +02:00
Saara Satokangas 637f23a96f Remove ThesisCourses-table
closes #3334
2022-03-07 15:57:01 +02:00
Saara Satokangas 9b8db2096d Remove thesisCourse-reducer and backend-logic
These are redundant after the new studyprogramme overview
2022-03-07 15:43:45 +02:00
Saara Satokangas 6d174d0275 Adjust the updates to match the new options 2022-03-07 12:51:01 +02:00
Saara Satokangas 2e882ff8a0 Clean up the options for studyprogramme overviews 2022-03-07 11:03:19 +02:00
Saara Satokangas f8a6f89199 Clean up the way years are defined for studyprogramme stats 2022-03-07 10:36:33 +02:00
Saara Satokangas b272f9e94a Clean up the toggle container 2022-03-07 09:21:05 +02:00
Saara Satokangas 4cc73d5fd8 Clean up settings for studytrack-overview 2022-03-07 09:12:31 +02:00
Saara Satokangas c19810fca8 Add "years combined"-selection to front and redis 2022-03-07 08:35:22 +02:00
Saara Satokangas 298c9e9b4b Add possibility to choose all students in the studytrack-view 2022-03-07 08:11:43 +02:00
Mitja Karhusaari 58ed0ac417 Fix course filter toggle button in Courses of Population
Resolves #3458
2022-03-06 19:12:31 +02:00
Mitja Karhusaari cb40fd970c Fix reseting table filters 2022-03-04 10:55:00 +02:00
Mitja Karhusaari cdc7331667 Fix rest of the tests 2022-03-04 10:17:45 +02:00
Mitja Karhusaari 2ffc222bcf Fix at at least some of the tests 2022-03-04 09:46:50 +02:00
Mitja Karhusaari 2fcb5fd91d Remove dead files 2022-03-03 08:43:19 +02:00
Mitja Karhusaari f4be37a3b8 Use "fully qualified" column name in exports to avoid name conflicts 2022-03-03 08:41:43 +02:00
Mitja Karhusaari 67d7b05cc6 Remove use of collapsing-property in SortableTable cells 2022-03-03 08:36:06 +02:00
Mitja Karhusaari 88ad8db7bb UI fix 2022-03-03 08:32:50 +02:00
Mitja Karhusaari dda612f284 Handle property not being present 2022-03-03 08:30:49 +02:00
Mitja Karhusaari 6a9cf26f4b Use the new SortableTable in the flat versions of PopulationCourseStats tables 2022-03-03 08:29:26 +02:00
Mitja Karhusaari 2d7b679b04 Add options to fold/unfold all groups 2022-03-03 08:28:18 +02:00
Mitja Karhusaari 5aa4c5d146 Misc. UI/UX improvements to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari ff9aaa1119 Add course filter toggles and links to all Population Courses tables 2022-03-03 07:09:18 +02:00
Mitja Karhusaari f443f3b7db Add exclude/include all buttons to export modal 2022-03-03 07:09:18 +02:00
Mitja Karhusaari a6a47e5ec8 Add Excel export to SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari b414fd3501 Go through and tweak components using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 88ce7bd2a5 Implement Courses of Population tables using SortableTable 2022-03-03 07:09:18 +02:00
Mitja Karhusaari eb35ea503d Polish the row grouping functionality 2022-03-03 07:09:18 +02:00
Mitja Karhusaari 20de79a26f Implement row grouping in sortable table 2022-03-03 07:09:18 +02:00
Mitja Karhusaari dab21ab390 Revamp sortable table 2022-03-03 07:09:18 +02:00
Leo Varis 6069dbc328 fix broken grade distribution (#3456) 2022-03-02 13:40:02 +02:00
Saara SatokangasandGitHub 7cb8041ca0 Update Pate-url 2022-03-02 09:13:14 +02:00
Roni Lindholm 729192cddc Fix enrollment updater when missing education 2022-03-01 15:14:27 +02:00
SNurmivaara 4b3b10d033 Teacher leaderboard tests go to the shadow realm 2022-03-01 14:48:50 +02:00
SNurmivaara 9c6812d7cf Return the magic wait and reload to teacher leaderboard test 2022-03-01 13:56:08 +02:00
SNurmivaara 58ce626d97 Acually fix teacher leadearboard tests 2022-03-01 13:44:58 +02:00
SNurmivaara 0834b33778 Fix teacher leaderboard tests 2022-03-01 12:54:13 +02:00
SNurmivaara cbce1d7dbd Remove statistics update button from teacher leaderboards
The button is obsolete, the update is run daily as a cronjob
2022-03-01 12:34:27 +02:00
SNurmivaara b8e214f8cd Filter faculties out of top teachers leaderboard 2022-03-01 12:00:37 +02:00
SNurmivaara 69d920015c Added required props to teacherStatisticsTable in teacher leaderboards 2022-03-01 12:00:37 +02:00
Roni Lindholm cf64249d4f Add enrollments to updater 2022-02-28 17:13:33 +02:00
Leo VarisandGitHub f372e4f362 Change required version 2022-02-28 13:57:37 +02:00
Saara SatokangasandGitHub dd619d375d Merge pull request #3421 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/updater/sis-updater-scheduler/follow-redirects-1.14.8
Bump follow-redirects from 1.14.3 to 1.14.8 in /updater/sis-updater-scheduler
2022-02-28 12:31:15 +02:00
Saara SatokangasandGitHub 0888eaf547 Merge pull request #3445 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/url-parse-1.5.10
Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
2022-02-28 12:15:19 +02:00
Saara Satokangas 14275a3e6f Fix faculty statistics 2022-02-28 11:54:13 +02:00
Saara Satokangas 956b9302d2 Rename newStudyprogramme.js to studyprogramme.js 2022-02-28 11:12:00 +02:00
dependabot[bot]andGitHub 51f462ce7b Bump url-parse from 1.5.3 to 1.5.10 in /services/frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-27 20:54:50 +00:00
Saara Satokangas a38bd1a3b0 Enable custom populations for studyguidance-group users too 2022-02-24 16:01:50 +02:00
Saara Satokangas 316f3511c2 Fix to toolTip-texts 2022-02-24 15:00:03 +02:00
Saara Satokangas e36c7aa6ba Fix semesters showing invalid dates 2022-02-24 14:35:14 +02:00
Saara Satokangas ffda9af909 Use common component also for percentage-toggle 2022-02-24 14:33:49 +02:00
Saara Satokangas 7b56978e7c Improve the appearance of toggles and move them into one file 2022-02-24 14:29:37 +02:00
Saara Satokangas b95d439836 Fix coursepopulation bugging with academic terms 2022-02-24 13:15:58 +02:00
SNurmivaara 1f7573a0ba Added oodikone architecture picture to README. Closes issue #3110 2022-02-24 08:55:12 +02:00
Petri Palmu 0204b4890f little fix to course stats tests 2022-02-23 14:20:54 +02:00
Petri Palmu 5a19e48ff9 Moved provider organization in own line because not resoponsive 2022-02-22 10:19:53 +02:00
Petri Palmu 790386a394 more course statistics course provider organization test 2022-02-22 10:19:53 +02:00
Petri Palmu e86045e8e6 few more chenges to course stats tests 2022-02-22 10:19:53 +02:00
Petri Palmu b1fb0a7cc7 fix to the Population of course header 2022-02-22 10:19:53 +02:00
Petri Palmu 0086ae69d3 removed or changed few proptypes 2022-02-22 10:19:53 +02:00
Petri Palmu 10d9731fab removed unifyOpenCourses from redux and refactored SearchForm to use redux hooks 2022-02-22 10:19:53 +02:00
Petri Palmu 83ffd5389e fixed course statistics test and cleaned console logs from the code 2022-02-22 10:19:53 +02:00
Petri Palmu fcb4328dba different course stats datas seems to work 2022-02-22 10:19:53 +02:00
Petri Palmu f196a0939d change open/unify radio renders correct data 2022-02-22 10:19:53 +02:00
Petri Palmu 52a480d6fb unify radio value is in redux state 2022-02-22 10:19:53 +02:00
Petri Palmu 0618a17830 unify radio added to the course stats 2022-02-22 10:19:53 +02:00
Saara Satokangas d54b3c0782 Fix one test 2022-02-22 08:53:16 +02:00
Saara Satokangas 28fa4e5310 Increase the viewport-size for cypress tests to see what's wrong 2022-02-22 08:37:00 +02:00
Saara Satokangas df402c4823 Try fixing some of the tests 2022-02-22 08:20:03 +02:00
Saara Satokangas 9136d41bdd Adjust tooltips to match the new table 2022-02-21 14:39:21 +02:00
Saara Satokangas e1f63c2441 Filter our transfers also from master's 2022-02-21 14:34:15 +02:00
Saara Satokangas d797b766b1 Refactor and clean-up 2022-02-21 14:30:41 +02:00
Saara Satokangas 03bed43077 Unify following masters programme -table with the bachelor one 2022-02-21 13:53:22 +02:00
Saara Satokangas 6763905b2a Adjust texts in frontend and variable names to match the new table 2022-02-21 13:23:40 +02:00
Saara Satokangas 994101732d Define studystartdate in the programme similarly as in populations 2022-02-21 13:04:19 +02:00
Saara Satokangas b1c071a507 Add table for bachelor's programmes before the chosen masters 2022-02-21 13:03:55 +02:00
Mitja Karhusaari 0e2bdc8d2d Potential fix for problems with Courses of Population -components 2022-02-16 13:26:42 +02:00
dependabot[bot]andGitHub be0f14326a Bump follow-redirects in /updater/sis-updater-scheduler
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.3 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.3...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-16 07:49:06 +00:00
Saara SatokangasandGitHub 1cd6e2f435 Merge pull request #3425 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/backend/follow-redirects-1.14.8
Bump follow-redirects from 1.14.3 to 1.14.8 in /services/backend
2022-02-16 09:48:12 +02:00
Saara Satokangas 0790003ae8 Fix trends-updating erroring at night 2022-02-16 08:25:30 +02:00
Saara Satokangas a77bf8b6a9 Remove old logic studyprogramme-service 2022-02-15 15:50:58 +02:00
dependabot[bot]andGitHub 00cd57dc13 Bump follow-redirects from 1.14.3 to 1.14.8 in /services/backend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.3 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.3...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-15 12:35:10 +00:00
Saara SatokangasandGitHub 44a8f39cc1 Merge pull request #3426 from UniversityOfHelsinkiCS/dependabot/npm_and_yarn/services/frontend/follow-redirects-1.14.8
Bump follow-redirects from 1.14.1 to 1.14.8 in /services/frontend
2022-02-15 14:34:17 +02:00
Saara Satokangas 4ecdb3237d Clarify the test-structure 2022-02-15 13:37:37 +02:00
Saara Satokangas 6b544179ef Add more tests for studytrack-overview 2022-02-15 13:16:26 +02:00
dependabot[bot]andGitHub a9100b78ec Bump follow-redirects from 1.14.1 to 1.14.8 in /services/frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.1 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.1...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 20:34:45 +00:00
dependabot[bot]andGitHub fbb17894cb Bump nanoid from 3.1.23 to 3.2.0 in /services/frontend
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-22 08:37:54 +00:00
103 changed files with 3967 additions and 3180 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+5 -1
View File
@@ -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
![Oodikone architecture](Oodikone.png)
### 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**.
+77 -30
View File
@@ -96,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')
@@ -105,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()
@@ -159,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()
@@ -174,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')
})
@@ -189,10 +181,11 @@ 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()
cy.contains('td', 'finnish').siblings().contains('107')
cy.contains('td', 'finnish').siblings().contains('122')
cy.contains('td', 'english').siblings().contains('5')
})
@@ -200,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()
@@ -215,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],
@@ -271,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']")
@@ -300,9 +338,18 @@ describe('Course Statistics tests', () => {
cy.contains('Show population').should('be.enabled')
})
})
it('If no data available, unify toggle is disabled', () => {
cy.get("input[placeholder='Search by a course code']").type('AYCSM14111')
cy.contains('AYCSM14111').click()
cy.contains('Fetch statistics').should('be.enabled').click()
cy.contains('AYCSM14111 Avoin yo: Full Stack -websovelluskehitys: React Native')
cy.get('[data-cy=unify_radio_reqular]').find('input').should('be.disabled')
cy.get('[data-cy=unify_radio_unify]').find('input').should('not.be.disabled')
})
})
it.only('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
it('Some features of Course Statistics are hidden for courseStatistics-users without other rights', () => {
cy.init('/coursestatistics', 'onlycoursestatistics')
cy.get('[data-cy=navbar-courseStatistics]').click()
cy.get('[data-cy=course-code-input]').type('TKT20003')
@@ -316,10 +363,10 @@ describe('Course Statistics tests', () => {
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 %'],
+3 -3
View File
@@ -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", () => {
+4 -4
View File
@@ -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')
+65 -31
View File
@@ -1,10 +1,10 @@
/// <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 => {
@@ -21,41 +21,41 @@ const getEmptyYears = isAcademicYear => {
}
describe('Studyprogramme overview', () => {
describe('when opening programme page with basic user', () => {
/* Basic information overview -tests*/
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.viewport(1536, 960)
})
/* Basic information overview -tests*/
// 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('Tietojenkäsittelytieteen kandiohjelma').click()
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-ProgrammesAfterGraduation]')
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
it('Basic information contains correct students', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
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, 10, 36, 16, 3, 2],
[2019, 86, 17, 14, 0, 1],
[2018, 162, 1, 3, 0, 0],
[2017, 173, 0, 0, 0, 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],
]
cy.checkTableStats(tableContents, 'StudentsOfTheStudyprogramme')
})
it('Basic information contains correct credits', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Total, Major credits, Non-major credits, Transferred credits]
@@ -71,7 +71,7 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct thesis writers and graduates', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
@@ -86,8 +86,8 @@ describe('Studyprogramme overview', () => {
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
})
it('Special studyrights can be excluded and basic data changes accordingly', () => {
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
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 = [
@@ -125,10 +125,11 @@ describe('Studyprogramme overview', () => {
]
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
cy.get('[data-cy=StudentToggle]').click()
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.contains('Tietojenkäsittelytieteen maisteriohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=YearToggle]').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
@@ -155,10 +156,11 @@ describe('Studyprogramme overview', () => {
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
cy.get('[data-cy=YearToggle]').click()
})
it('Basic information graphs render', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
.should('contain', 'Started')
@@ -182,21 +184,30 @@ describe('Studyprogramme overview', () => {
.should('contain', 36)
.should('contain', 35)
cy.get('[data-cy=Graph-ProgrammesAfterGraduation')
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', 'Kielellisen diversiteetin ja digitaalisten ihmistieteiden maisteriohjelma')
.should('contain', 1)
.should('contain', 4)
.should('contain', 13)
})
})
/* Studytrack overview -tests*/
/* Studytrack overview -tests*/
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
})
// 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('Tietojenkäsittelytieteen kandiohjelma').click()
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]')
@@ -204,7 +215,7 @@ describe('Studyprogramme overview', () => {
})
it('Students of the studyprogramme are shown correctly', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
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]
@@ -298,7 +309,7 @@ describe('Studyprogramme overview', () => {
})
it('Student progress data is shown correctly', () => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
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],
@@ -310,11 +321,34 @@ describe('Studyprogramme overview', () => {
cy.checkTableStats(tableContents, 'StudytrackProgress')
})
/* Tag-tests*/
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')
@@ -326,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')
@@ -342,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')
@@ -350,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()
@@ -359,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')
@@ -369,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')
})
})
+3 -11
View File
@@ -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 })
})
})
+1 -3
View File
@@ -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)
+3 -3
View File
@@ -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",
@@ -0,0 +1,6 @@
module.exports = {
up: async queryInterface => {
await queryInterface.dropTable('thesis_courses')
},
down: async () => {},
}
@@ -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,6 +1,5 @@
const router = require('express').Router()
const MandatoryCourses = require('../services/mandatoryCourses')
const { findProgrammeTheses, createThesisCourse, deleteThesisCourse } = require('../services/thesis')
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
router.get('/elementdetails/all', async (req, res) => {
@@ -22,35 +21,4 @@ router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
}
})
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
+23 -9
View File
@@ -32,8 +32,10 @@ router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
try {
const result = await getBasicStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setBasicStats(result, yearType, specialGroups)
} catch (e) {
@@ -62,8 +64,10 @@ router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
try {
const result = await getCreditStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setCreditStats(result, yearType, specialGroups)
} catch (e) {
@@ -92,8 +96,10 @@ router.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
try {
const result = await getGraduationStatsForStudytrack({
studyprogramme: req.params.id,
yearType,
specialGroups,
settings: {
isAcademicYear: yearType === 'ACADEMIC_YEAR',
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
},
})
data = await setGraduationStats(result, yearType, specialGroups)
} catch (e) {
@@ -110,18 +116,26 @@ 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, graduated, specialGroups)
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: code, specialGroups, graduated })
data = await setStudytrackStats(result, graduated, specialGroups)
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}`)
}
@@ -5,8 +5,8 @@ const createRedisKeyForBasicStats = (id, yearType, specialGroups) => `BASIC_STAT
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) =>
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
const createRedisKeyForStudytrackStats = (id, graduated, specialGroups, yearsCombined) =>
`STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}_${yearsCombined}`
const getBasicStats = async (id, yearType, specialGroups) => {
const redisKey = createRedisKeyForBasicStats(id, yearType, specialGroups)
@@ -68,16 +68,16 @@ const setGraduationStats = async (data, yearType, specialGroups) => {
return dataToRedis
}
const getStudytrackStats = async (id, graduated, specialGroups) => {
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
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, graduated, specialGroups) => {
const setStudytrackStats = async (data, graduated, specialGroups, yearsCombined) => {
const { id } = data
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups, yearsCombined)
const dataToRedis = {
...data,
status: 'DONE',
+21 -13
View File
@@ -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 => {
+1 -1
View File
@@ -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,402 +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, formatTransfer } = require('./studyprogrammeHelpers')
const { getCurrentSemester } = require('./semesters')
const whereStudents = studentnumbers => {
return studentnumbers ? studentnumbers : { [Op.not]: null }
}
const sinceDate = since => {
return since ? { [Op.gte]: since } : { [Op.not]: null }
}
const studytrackStudents = async studentnumbers =>
(
await Student.findAll({
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
).map(formatStudent)
const enrolledStudents = async (studytrack, since, studentnumbers) => {
const currentSemester = await getCurrentSemester()
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: {
semestercode: currentSemester.semestercode,
},
},
],
where: {
enrollmenttype: 1,
},
},
],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
return students.filter(s => s.semester_enrollments?.length)
}
const absentStudents = async (studytrack, studentnumbers) => {
const currentSemester = await getCurrentSemester()
const students = await Student.findAll({
attributes: ['studentnumber'],
include: [
{
model: Studyright,
include: [
{
model: StudyrightElement,
required: true,
where: {
code: studytrack,
},
},
],
attributes: ['studyrightid'],
},
{
model: SemesterEnrollment,
attributes: ['semestercode'],
where: {
enrollmenttype: 2,
semestercode: currentSemester.semestercode,
},
},
],
where: {
studentnumber: {
[Op.in]: studentnumbers,
},
},
})
return students
}
const allStudyrights = 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: {
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
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,
},
},
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
graduated: 1,
enddate: sinceDate(since),
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const followingStudyrights = async (since, studentnumbers) =>
(
await Studyright.findAll({
include: [
{
model: StudyrightElement,
required: true,
include: [
{
model: ElementDetail,
attributes: ['name'],
},
],
attributes: ['code'],
},
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
studystartdate: {
[Op.gte]: since,
},
extentcode: 2,
student_studentnumber: whereStudents(studentnumbers),
},
})
).map(formatStudyright)
const transfersAway = async (studytrack, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
sourcecode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
const transfersTo = async (studytrack, since) =>
(
await Transfer.findAll({
where: {
transferdate: {
[Op.gte]: since,
},
targetcode: studytrack,
},
distinct: true,
col: 'studentnumber',
})
).map(formatTransfer)
const allTransfers = async (studytrack, since) => {
const transferredTo = await transfersTo(studytrack, since)
const transferredAway = await transfersAway(studytrack, since)
return [...transferredTo, ...transferredAway]
}
const getProgrammesStudyrights = 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'],
include: {
model: Course,
attributes: ['code'],
required: true,
include: {
model: Organization,
required: true,
where: {
code: provider,
},
},
},
where: {
credittypecode: {
[Op.eq]: [9],
},
isStudyModule: {
[Op.not]: true,
},
attainment_date: {
[Op.gte]: since,
},
},
})
const getThesisCredits = async (provider, since, thesisType, studentnumbers) =>
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,
},
student_studentnumber: whereStudents(studentnumbers),
},
})
module.exports = {
studytrackStudents,
enrolledStudents,
absentStudents,
allStudyrights,
startedStudyrights,
graduatedStudyRights,
followingStudyrights,
transfersAway,
transfersTo,
allTransfers,
getProgrammesStudyrights,
getCreditsForStudyProgramme,
getTransferredCredits,
getThesisCredits,
}
+3 -2
View File
@@ -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
@@ -13,30 +13,9 @@ const {
startedStudyrights,
transfersAway,
transfersTo,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
const { getYearStartAndEndDates } = require('../util/semester')
// const getAllStats = async ({ studyprogramme, 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 all = await allStudyrights(studyprogramme, null, studentnumbersOfTheYear)
// graphStats[indexOf(years, year)] = all.length
// tableStats[year] += all.length
// })
// )
// return { graphStats, tableStats }
// }
const getStartedStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const { graphStats, tableStats } = getStatsBasis(years)
@@ -126,9 +105,8 @@ const getTransferredToStats = async ({ studyprogramme, since, years, isAcademicY
return { graphStats, tableStats }
}
const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getBasicStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { includeAllSpecials, isAcademicYear } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
@@ -140,7 +118,7 @@ const getBasicStatsForStudytrack = async ({ studyprogramme, yearType, specialGro
const transferredTo = await getTransferredToStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['basics'][specialGroups]
const titles = tableTitles['basics'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const tableStats = reversedYears.map(year =>
includeAllSpecials
? [
@@ -15,7 +15,7 @@ const {
getCreditsForStudyProgramme,
getTransferredCredits,
allTransfers,
} = require('./newStudyprogramme')
} = 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
@@ -71,9 +71,8 @@ const getTransferredCreditStats = async ({ studyprogramme, since, years, isAcade
// 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, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
const getCreditStatsForStudytrack = async ({ studyprogramme, settings }) => {
const { isAcademicYear, includeAllSpecials } = settings
const since = getStartDate(studyprogramme, isAcademicYear)
const years = getYearsArray(since.getFullYear(), isAcademicYear)
@@ -82,7 +81,7 @@ const getCreditStatsForStudytrack = async ({ studyprogramme, yearType, specialGr
const transferred = await getTransferredCreditStats(queryParameters)
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse()
const titles = tableTitles['credits'][specialGroups]
const titles = tableTitles['credits'][includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED']
const tableStats = reversedYears.map(year =>
includeAllSpecials
@@ -16,12 +16,14 @@ const {
} = require('./studyprogrammeHelpers')
const {
graduatedStudyRights,
transfersAway,
transfersTo,
getThesisCredits,
followingStudyrights,
} = require('./newStudyprogramme')
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)
@@ -76,9 +78,9 @@ const getThesisStats = async ({ studyprogramme, since, years, isAcademicYear, in
}
const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
let graduationAmounts = getYearsObject(years)
let graduationTimes = getYearsObject(years, true)
let totalAmounts = isAcademicYear ? getAcademicYearsObject(years) : getYearsObject(years)
let graduationAmounts = getYearsObject({ years })
let graduationTimes = getYearsObject({ years, emptyArrays: true })
let totalAmounts = isAcademicYear ? getAcademicYearsObject({ years }) : getYearsObject({ years })
await Promise.all(
years.map(async year => {
@@ -104,8 +106,8 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
// 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, true)
const means = getYearsObject(years, true)
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 => {
@@ -123,43 +125,101 @@ const getGraduationTimeStats = async ({ studyprogramme, since, years, isAcademic
return { medians, means, graduationAmounts, totalAmounts }
}
const getProgrammesAfterGraduation = async ({ studyprogramme, since, years, isAcademicYear, includeAllSpecials }) => {
const graduated = await graduatedStudyRights(studyprogramme, since)
const graduatedStudents = graduated.map(s => s.studentnumber)
let studyrightsAfterThisProgramme = await followingStudyrights(since, 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) }))
if (!includeAllSpecials) {
const transferredAway = await transfersAway(studyprogramme, since)
const transferredTo = await transfersTo(studyprogramme, since)
const transfers = [...transferredAway, ...transferredTo].map(s => s.studyrightid)
studyrightsAfterThisProgramme.filter(s => !transfers.includes(s.studyrightid) && !s.canceldate)
}
studyrightsAfterThisProgramme.forEach(({ studystartdate, code }) => {
const startYear = defineYear(studystartdate, isAcademicYear)
if (years.includes(startYear) && code) {
stats[code][startYear] += 1
}
})
// 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])])
.filter(p => p[0].includes('MH'))
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]) }))
.filter(p => p.code.includes('MH'))
return { tableStats, graphStats }
}
const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, specialGroups }) => {
const isAcademicYear = yearType === 'ACADEMIC_YEAR'
const includeAllSpecials = specialGroups === 'SPECIAL_INCLUDED'
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)
@@ -167,12 +227,14 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, speci
const thesis = await getThesisStats(queryParameters)
const graduated = await getGraduatedStats(queryParameters)
const graduationTimeStats = await getGraduationTimeStats(queryParameters)
const programmesAfterGraduation = await getProgrammesAfterGraduation(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 programmesAfterTitles = ['Code', 'Programme', ...years]
const programmesBeforeOrAfterTitles = ['Code', 'Programme', ...years]
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
return {
@@ -194,9 +256,9 @@ const getGraduationStatsForStudytrack = async ({ studyprogramme, yearType, speci
graduationMeanTime: graduationTimeStats.means,
graduationAmounts: graduationTimeStats.graduationAmounts,
totalAmounts: graduationTimeStats.totalAmounts,
programmesAfterTableStats: programmesAfterGraduation.tableStats,
programmesAfterGraphStats: programmesAfterGraduation.graphStats,
programmesAfterTitles,
programmesBeforeOrAfterTableStats: programmesBeforeOrAfter.tableStats,
programmesBeforeOrAfterGraphStats: programmesBeforeOrAfter.graphStats,
programmesBeforeOrAfterTitles,
}
}
@@ -28,11 +28,19 @@ const getCorrectStudentnumbers = async ({ codes, startDate, endDate, includeAllS
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,
givendate,
canceldate,
graduated,
prioritycode,
@@ -43,8 +51,9 @@ const formatStudyright = studyright => {
return {
studyrightid,
studystartdate,
studystartdate: defineStartDate(studyright_elements, studystartdate),
enddate,
givendate,
canceldate,
graduated,
prioritycode,
@@ -69,17 +78,18 @@ const formatStudent = student => {
}
const formatTransfer = transfer => {
const { sourcecode, transfercode, transferdate, studyrightid } = transfer
const { sourcecode, targetcode, transferdate, studyrightid } = transfer
return {
sourcecode,
transfercode,
targetcode,
transferdate,
studyrightid,
}
}
const getYearsArray = (since, isAcademicYear) => {
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)
@@ -87,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 }
@@ -95,7 +105,7 @@ const getYearsObject = (years, emptyArrays = false) => {
return yearsObject
}
const getAcademicYearsObject = (years, emptyArrays = false) => {
const getAcademicYearsObject = ({ years, emptyArrays = false }) => {
let yearsObject = {}
for (const year of years) {
yearsObject = { ...yearsObject, [`${year} - ${year + 1}`]: emptyArrays ? [] : 0 }
@@ -106,7 +116,7 @@ const getAcademicYearsObject = (years, emptyArrays = false) => {
const getStatsBasis = years => {
return {
graphStats: new Array(years.length).fill(0),
tableStats: getYearsObject(years),
tableStats: getYearsObject({ years }),
}
}
@@ -8,72 +8,132 @@ const {
setGraduationStats,
setStudytrackStats,
} = require('../services/analyticsService')
const logger = require('../util/logger')
const updateBasicView = async code => {
const yearTypes = ['ACADEMIC_YEAR', 'CALENDAR_YEAR']
const special = ['SPECIAL_INCLUDED', 'SPECIAL_EXCLUDED']
const specialCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_INCLUDED',
}
const specialExcludedCalendar = {
yearType: 'CALENDAR_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
yearTypes.forEach(async yearType => {
special.forEach(async specialGroups => {
const specialExcludedAcademic = {
yearType: 'ACADEMIC_YEAR',
specialGroups: 'SPECIAL_EXCLUDED',
}
const options = [specialCalendar, specialAcademic, specialExcludedCalendar, specialExcludedAcademic]
for (const option of options) {
try {
const basicStats = await getBasicStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setBasicStats(basicStats, yearType, specialGroups)
await setBasicStats(basicStats, option.yearType, option.specialGroups)
const creditStats = await getCreditStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setCreditStats(creditStats, yearType, specialGroups)
await setCreditStats(creditStats, option.yearType, option.specialGroups)
const graduationStats = await getGraduationStatsForStudytrack({
studyprogramme: code,
yearType,
specialGroups,
settings: {
isAcademicYear: option.yearType === 'ACADEMIC_YEAR',
includeAllSpecials: option.specialGroups === 'SPECIAL_INCLUDED',
},
})
await setGraduationStats(graduationStats, yearType, specialGroups)
})
})
return 'OK'
await setGraduationStats(graduationStats, option.yearType, option.specialGroups)
} catch (e) {
logger.error(e)
}
return 'OK'
}
}
const updateStudytrackView = async code => {
const specialIncluded = 'SPECIAL_INCLUDED'
const specialExcluded = 'SPECIAL_EXCLUDED'
const graduatedIncluded = 'GRADUATED_INCLUDED'
const graduatedExcluded = 'GRADUATED_EXCLUDED'
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 statsSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsSpecialAndGraduated, graduatedIncluded, specialIncluded)
const options = [
specialGraduatedCombined,
specialExcludedGraduatedCombined,
specialGraduatedExcludedCombined,
specialExcludedGraduatedExcludedCombined,
specialGraduatedSeparated,
specialExcludedGraduatedSeparated,
specialGraduatedExcludedSeparated,
specialExcludedGraduatedExcludedSeparated,
]
const statsSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialIncluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsSpecialNotGraduated, graduatedExcluded, specialIncluded)
const statsNotSpecialAndGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedIncluded,
})
await setStudytrackStats(statsNotSpecialAndGraduated, graduatedIncluded, specialExcluded)
const statsNotSpecialNotGraduated = await getStudytrackStatsForStudyprogramme({
studyprogramme: code,
specialGroups: specialExcluded,
graduated: graduatedExcluded,
})
await setStudytrackStats(statsNotSpecialNotGraduated, graduatedExcluded, specialExcluded)
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'
}
@@ -9,7 +9,6 @@ const {
getYearsArray,
getPercentage,
getYearsObject,
getAcademicYearsObject,
getCorrectStudentnumbers,
getCreditThresholds,
getCreditGraphStats,
@@ -22,7 +21,7 @@ const {
enrolledStudents,
absentStudents,
graduatedStudyRights,
} = require('./newStudyprogramme')
} = require('./studyprogramme')
const { getAcademicYearDates } = require('../util/semester')
const getStudentData = (students, thresholdKeys, thresholdAmounts) => {
@@ -79,8 +78,7 @@ const getGraduationTimeStats = async ({
// 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,
special,
includeGraduated,
settings,
studytracks,
studytrackNames,
year,
@@ -89,6 +87,8 @@ const getStudytrackDataForTheYear = async ({
creditThresholdKeys,
creditThresholdAmounts,
}) => {
const { specialGroups: includeAllSpecials, graduated: includeGraduated, yearsCombined } = settings
const { startDate, endDate } = getAcademicYearDates(year, yearsCombined)
const {
mainStatsByYear,
mainStatsByTrack,
@@ -100,7 +100,6 @@ const getStudytrackDataForTheYear = async ({
totalAmounts,
emptyTracks,
} = data
const { startDate, endDate } = getAcademicYearDates(year)
await Promise.all(
studytracks.map(async track => {
@@ -110,7 +109,7 @@ const getStudytrackDataForTheYear = async ({
codes,
startDate,
endDate,
includeAllSpecials: special,
includeAllSpecials,
includeGraduated,
})
@@ -122,7 +121,7 @@ const getStudytrackDataForTheYear = async ({
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, null, studentnumbers)
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 (all.length === 0) {
@@ -137,14 +136,14 @@ const getStudytrackDataForTheYear = async ({
// Count stats for the credit progress table for the year per track
creditTableStats[track] = [
...creditTableStats[track],
[`${year} - ${year + 1}`, all.length, ...creditThresholdKeys.map(threshold => studentData[threshold])],
[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,
@@ -170,9 +169,7 @@ const getStudytrackDataForTheYear = async ({
mainStatsByYear[year] = [
...mainStatsByYear[year],
[
studytrackNames[track]?.name['fi']
? `${studytrackNames[track]?.name['fi']}, ${track}`
: `${year} - ${year + 1}`,
studytrackNames[track]?.name['fi'] ? `${studytrackNames[track]?.name['fi']}, ${track}` : year,
all.length,
getPercentage(all.length, all.length),
started.length,
@@ -195,9 +192,9 @@ const getStudytrackDataForTheYear = async ({
]
// Count stats for the graduation time charts grouped by year
totalAmounts[track][`${year} - ${year + 1}`] = await all.length
totalAmounts[track][year] = await all.length
await getGraduationTimeStats({
year: `${year} - ${year + 1}`,
year,
graduated,
studyprogramme,
track,
@@ -224,7 +221,7 @@ 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, studyprogramme) => {
const mainStatsByYear = getYearsObject(years, true)
const mainStatsByYear = getYearsObject({ years, emptyArrays: true })
const mainStatsByTrack = {}
const creditGraphStats = {}
const creditTableStats = {}
@@ -238,10 +235,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
mainStatsByTrack[track] = []
creditGraphStats[track] = getCreditGraphStats(studyprogramme, years)
creditTableStats[track] = []
graduationMedianTime[track] = getAcademicYearsObject(years, true)
graduationMeanTime[track] = getAcademicYearsObject(years, true)
graduationAmounts[track] = getAcademicYearsObject(years)
totalAmounts[track] = getAcademicYearsObject(years)
graduationMedianTime[track] = getYearsObject({ years, emptyArrays: true })
graduationMeanTime[track] = getYearsObject({ years, emptyArrays: true })
graduationAmounts[track] = getYearsObject({ years })
totalAmounts[track] = getYearsObject({ years })
})
return {
@@ -258,12 +255,10 @@ const getEmptyStatsObjects = (years, studytracks, studyprogramme) => {
}
// Combines all the data for the Populations and Studytracks -view
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated, specialGroups }) => {
const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, settings }) => {
const isAcademicYear = true
const special = specialGroups === 'SPECIAL_INCLUDED'
const includeGraduated = graduated === 'GRADUATED_INCLUDED'
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]
@@ -279,8 +274,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
years.map(async year => {
return await getStudytrackDataForTheYear({
studyprogramme,
special,
includeGraduated,
settings,
studytracks,
studytrackNames,
year,
@@ -296,7 +290,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
return {
id: studyprogramme,
years: getYearsArray(since.getFullYear(), isAcademicYear),
years: getYearsArray(since.getFullYear(), isAcademicYear, settings.yearsCombined),
mainStatsByTrack: data.mainStatsByTrack,
mainStatsByYear: data.mainStatsByYear,
creditTableStats: data.creditTableStats,
@@ -306,7 +300,7 @@ const getStudytrackStatsForStudyprogramme = async ({ studyprogramme, graduated,
graduationAmounts: data.graduationAmounts,
totalAmounts: data.totalAmounts,
studytrackOptions,
includeGraduated,
includeGraduated: settings.graduated,
populationTitles: tableTitles['studytracks'],
creditTableTitles: tableTitles['creditProgress'][studyprogramme.includes('KH') ? 'bachelor' : 'master'],
}
-29
View File
@@ -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,
}
+3 -1
View File
@@ -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 }
+5 -3
View File
@@ -29,10 +29,12 @@ 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']}`,
}
}
+9 -9
View File
@@ -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",
@@ -10412,9 +10412,9 @@
"optional": true
},
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz",
"integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA=="
},
"nanomatch": {
"version": "1.2.13",
@@ -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"
@@ -4,11 +4,11 @@ export default {
Calendar year: Näyttää tilastot kalenterivuosille jaoteltuna, esimerkiksi 1.1.2019-31.12.2019
`,
StudentToggle: `
All studyrights included: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat, opintonsa keskeyttäneet opiskelijat, ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
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.
`,
GradToggle: `
GraduatedToggle: `
Graduated included: Valinnassa ovat mukana ohjelmasta jo valmistuneet opiskelijat.
Graduated excluded: Valinnasta on poistettu ohjelmasta jo valmistuneet opiskelijat.
@@ -48,10 +48,12 @@ export default {
**Mean time**: Näyttää kyseisenä vuonna valmistuneiden opiskelijoiden opintojen keston **keskiarvon** kuukausina. Luku sisältää mahdolliset poissaolokuukaudet.
n = **valmistuneet opiskelijat / kaikki ohjelmassa kyseisenä vuonna alkaneet opinto-oikeudet**. Kandiohjelmissa tavoiteaika on 36 kk, maisteriohjelmissa 24 kk`,
ProgrammesAfterGraduation: `
Sisältää maisteriohjelmat, joissa tämän kandiohjelman opiskelijat ovat aloittaneet valmistuttuaan kandeiksi. Vuosijaottelu on tehty maisteriohjelmassa aloittamisen perusteella.
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**.
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.
+16
View File
@@ -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
@@ -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}
@@ -70,9 +70,8 @@ const getGradeColumns = (notThesisGrades, addHTAndTT) => {
const AttemptsTable = ({
data: { stats, name },
settings: { showGrades },
settings: { showGrades, separate },
alternatives,
separate,
isRelative,
userHasAccessToAllStats,
headerVisible = false,
@@ -144,9 +143,8 @@ const AttemptsTable = ({
</Header>
)}
<SortableTable
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, style: { width: 'auto' } }}
title={`Yearly attempt statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
columns={columns}
data={data}
/>
@@ -4,7 +4,7 @@ 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'
@@ -28,6 +28,7 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
key: 'TIME',
title: 'Time',
getRowVal: s => s.code,
getRowExportVal: s => s.name,
getRowContent: s => (
<div style={{ whiteSpace: 'nowrap' }}>
{s.name}
@@ -92,9 +93,8 @@ const getColumns = (showDetails, userHasAccessToAllStats, alternatives, separate
const StudentTable = ({
data: { name, stats },
settings: { showDetails },
settings: { showDetails, separate },
alternatives,
separate,
userHasAccessToAllStats,
headerVisible = false,
}) => {
@@ -111,9 +111,11 @@ const StudentTable = ({
</Header>
)}
<SortableTable
title={`Student statistics for group ${name}`}
defaultSort={['TIME', 'desc']}
defaultdescending
getRowKey={s => s.code}
tableProps={{ celled: true, fixed: true, style: { width: 'auto' } }}
tableProps={{ celled: true, fixed: true }}
columns={columns}
data={stats}
/>
@@ -125,12 +127,10 @@ const StudentTable = ({
}
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 = {
@@ -5,56 +5,61 @@ import { viewModeNames } from './util'
import HelpButton from '../HelpButton'
import StudentTable from './Tables/student'
import AttemptsTable from './Tables/attempts'
import selectors from '../../../../selectors/courseStats'
import UnifyRadioButtons from '../UnifyRadioButtons'
export const TablesSettings = ({ value, onChange }) => {
export const TablesSettings = ({ value, onChange, availableStats }) => {
const { viewMode, showDetails, showGrades } = value
return (
<Menu secondary style={{ marginBottom: 0 }}>
{Object.entries(viewModeNames).map(([key, name]) => (
<Menu.Item
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>
<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>
<UnifyRadioButtons availableStats={availableStats} />
</div>
)
}
export const Tables = props => {
const alternatives = useSelector(state => state.courseStats.data[state.singleCourseStats.selectedCourse].alternatives)
const alternatives = useSelector(selectors.getCourseAlternatives)
const viewModes = { ATTEMPTS: AttemptsTable, STUDENT: StudentTable }
const Content = viewModes[props.settings.viewMode]
return <Content {...props} alternatives={alternatives} />
@@ -0,0 +1,60 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Menu, Radio, Form } from 'semantic-ui-react'
import { toggleOpenAndReqularCourses } from '../../../redux/coursesearch'
const UnifyRadioButtons = ({ availableStats }) => {
const dispatch = useDispatch()
const openOrReqular = useSelector(state => state.courseSearch.openOrReqular)
const toggleUnifyRadioValue = (event, { value }) => {
dispatch(toggleOpenAndReqularCourses(value))
}
return (
<Menu secondary style={{ marginLeft: '0.5rem' }}>
<Form>
<Form.Group>
<Form.Field>
<b>Provider organization:</b>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.university ? 'university' : 'university (no data)'}
name="radioGroup"
value="reqularStats"
checked={openOrReqular === 'reqularStats'}
disabled={!availableStats.university}
onChange={toggleUnifyRadioValue}
data-cy="unify_radio_reqular"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.open ? 'open uni' : 'open uni (no data)'}
name="radioGroup"
value="openStats"
checked={openOrReqular === 'openStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.open}
data-cy="unify_radio_open"
/>
</Form.Field>
<Form.Field>
<Radio
label={availableStats.unify ? 'unified' : 'unified (no data)'}
name="radioGroup"
value="unifyStats"
checked={openOrReqular === 'unifyStats'}
onChange={toggleUnifyRadioValue}
disabled={!availableStats.unify}
data-cy="unify_radio_unify"
/>
</Form.Field>
</Form.Group>
</Form>
</Menu>
)
}
export default UnifyRadioButtons
@@ -12,15 +12,22 @@ import TSA from '../../../common/tsa'
const ANALYTICS_CATEGORY = 'Course Statistics'
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
const PaneContent = ({ component: Component, settings: SettingsComponent, initialSettings, datasets, ...rest }) => {
const PaneContent = ({
component: Component,
settings: SettingsComponent,
initialSettings,
datasets,
availableStats,
...rest
}) => {
const [settings, setSettings] = useState(initialSettings)
const [splitDirection, setSplitDirection] = useState('column')
const [splitDirection, setSplitDirection] = useState('row')
return (
<Tab.Pane>
<Segment basic>
<div style={{ display: 'flex', marginBottom: '2em' }}>
<SettingsComponent value={settings} onChange={setSettings} />
<SettingsComponent value={settings} onChange={setSettings} availableStats={availableStats} />
<div style={{ flexGrow: 1 }} />
{datasets.filter(i => i).length > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1em' }}>
@@ -51,137 +58,29 @@ const PaneContent = ({ component: Component, settings: SettingsComponent, initia
)
}
const ResultTabs = ({ primary, comparison, history }) => {
const ResultTabs = ({ primary, comparison, history, separate, availableStats }) => {
const [tab, setTab] = useTabs('cs_tab', 0, history)
// const [viewMode, setViewMode] = useState(viewModeNames.STUDENT)
// const [selectedView, setSelectedView] = useState(false)
// const [isRelative, setIsRelative] = useState(false)
// const [showGrades, setShowGrades] = useState(false)
const { userHasAccessToAllStats } = primary
/* const handleModeChange = newViewMode => {
sendAnalytics(`Current view mode '${newViewMode}'`, 'Course statistics')
setViewMode(newViewMode)
} */
/* useEffect(() => {
const newViewMode = selectedView ? viewModeNames.ATTEMPTS : viewModeNames.STUDENT
handleModeChange(newViewMode)
}, [selectedView]) */
const handleTabChange = (...params) => {
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 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 || 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 = () => {
return [
{
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
render: () => (
<Tables
separate={separate}
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative}
showGrades={showGrades}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
{
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
render: () => (
<PassRate
comparison={comparison}
primary={primary}
viewMode={viewMode}
isRelative={isRelative && !!comparison}
userHasAccessToAllStats={userHasAccessToAllStats}
/>
),
},
{
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
render: () => (
<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 paneTypes = [
{
label: 'Tables',
icon: 'table',
initialSettings: { showDetails: false, viewMode: 'STUDENT' },
initialSettings: { showDetails: false, viewMode: 'STUDENT', separate },
settings: TablesSettings,
component: Tables,
},
{
label: 'Pass rate chart',
icon: 'balance',
initialSettings: { viewMode: 'STUDENT' },
initialSettings: { viewMode: 'STUDENT', separate },
settings: PassRateSettings,
component: PassRate,
},
@@ -204,6 +103,7 @@ const ResultTabs = ({ primary, comparison, history }) => {
userHasAccessToAllStats={userHasAccessToAllStats}
initialSettings={initialSettings}
datasets={[primary, comparison]}
availableStats={availableStats}
/>
),
})
@@ -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
} */
@@ -34,6 +34,7 @@ const countFilteredStudents = (stat, filter) =>
const SingleCourseStats = ({
stats,
availableStats,
setSelectedCourse,
clearSelectedCourse,
history,
@@ -247,7 +248,6 @@ const SingleCourseStats = ({
const filteredProgrammeStatistics = () => {
const filter = p => validProgCode(p)
const excludedProgrammes = getExcluded()
const primaryProgrammes = primary
@@ -324,6 +324,8 @@ const SingleCourseStats = ({
return { ...e, students: [...students], size: students.size }
})
.filter(e => e.size > 0)
// console.log('stats.statistics: ', stats.statistics)
if (stats.statistics.length < 1) return <Segment>No data for selected course</Segment>
const options = filteredProgrammes
@@ -389,7 +391,12 @@ const SingleCourseStats = ({
</Form>
</Segment>
)}
<ResultTabs separate={separate} primary={statistics.primary} comparison={statistics.comparison} />
<ResultTabs
separate={separate}
primary={statistics.primary}
comparison={statistics.comparison}
availableStats={availableStats}
/>
</div>
)
}
@@ -1,21 +1,30 @@
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 availableStats = useSelector(selectors.getAvailableStats)
const courses = useSelector(selectors.getCourses).map(({ code, name }) => ({
key: code,
value: code,
text: <Header content={<>{name}</>} />,
content: name,
}))
useEffect(() => {
setSelection(selected)
}, [selected])
if (!stats[selection]) return null
return (
<div>
<Segment>
@@ -44,26 +53,20 @@ const SingleCourseTab = ({ selected, stats, courses, userHasAccessToAllStats })
</Label.Group>
</Form>
</Segment>
{selection && <SingleCourseStats stats={stats[selection]} userHasAccessToAllStats={userHasAccessToAllStats} />}
{selection && (
<SingleCourseStats
stats={stats[selection]}
userHasAccessToAllStats={userHasAccessToAllStats}
availableStats={availableStats[selected]}
/>
)}
</div>
)
}
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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -1,49 +1,146 @@
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),
cellProps: {
style: {
maxWidth: '20em',
whiteSpace: 'normal',
overflow: 'hidden',
},
},
},
{
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,222 @@
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),
cellProps: {
style: {
maxWidth: '20em',
whiteSpace: 'normal',
overflow: 'hidden',
},
},
},
{
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
@@ -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 = {
@@ -1,43 +1,155 @@
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),
cellProps: {
style: {
maxWidth: '20em',
whiteSpace: 'normal',
overflow: 'hidden',
},
},
},
{
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
@@ -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;
}
@@ -34,6 +34,7 @@ const GeneralTab = ({
if (!populationStatistics || !populationStatistics.elementdetails) return null
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const students = Object.fromEntries(filteredStudents.map(stu => [stu.studentNumber, stu]))
const queryStudyrights = query ? Object.values(query.studyRights) : []
const cleanedQueryStudyrights = queryStudyrights.filter(sr => !!sr)
@@ -212,32 +213,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 +312,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')
@@ -334,6 +343,12 @@ const GeneralTab = ({
title: 'Study Programme',
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
},
passDate: {
key: 'passDate',
title: 'Attainment date',
getRowVal: s => s.courses.find(c => coursecode.includes(c.course_code)).date,
formatValue: value => reformatDate(new Date(value), 'YYYY-MM-DD'),
},
language: {
key: 'language',
title: 'Language',
@@ -393,12 +408,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 +434,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])}
/>
)
}
@@ -462,7 +478,7 @@ const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
const columnsByVariant = {
customPopulation: ['programme', 'startYear'],
coursePopulation: ['gradeForSingleCourse', 'programme', 'language', 'startYear'],
coursePopulation: ['gradeForSingleCourse', 'programme', 'passDate', 'language', 'startYear'],
population: [
'creditsSinceStart',
'transferredFrom',
@@ -477,7 +493,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
}
@@ -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',
@@ -171,58 +171,63 @@ const StudentSearch = ({
]
if (showNames) {
columns.push({
key: 'lastname',
title: 'Last names',
getRowVal: s => s.lastname,
getRowContent: s => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/students/${s.studentNumber}`}
>
{s.lastname}
</Link>
),
cellProps: {
style: {
padding: '0',
const nameColumns = [
{
key: 'lastname',
title: 'Last names',
getRowVal: s => s.lastname,
getRowContent: s => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/students/${s.studentNumber}`}
>
{s.lastname}
</Link>
),
cellProps: {
style: {
padding: '0',
},
},
},
})
columns.push({
key: 'firstnames',
title: 'Given names',
getRowVal: s => s.firstnames,
getRowContent: s => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/students/${s.studentNumber}`}
>
{s.firstnames}
</Link>
),
cellProps: {
style: {
padding: '0',
{
key: 'firstnames',
title: 'Given names',
getRowVal: s => s.firstnames,
getRowContent: s => (
<Link
style={{
color: 'black',
display: 'inline-block',
width: '100%',
height: '100%',
padding: '.78571429em .78571429em',
}}
to={`/students/${s.studentNumber}`}
>
{s.firstnames}
</Link>
),
cellProps: {
style: {
padding: '0',
},
},
},
})
]
columns.splice(0, 0, ...nameColumns)
}
return (
<SortableTable
figure={false}
getRowKey={s => s.studentNumber}
tableProps={{ celled: false }}
columns={columns}
@@ -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 => {
@@ -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} />
</>
)
}
@@ -76,7 +76,11 @@ const GaugeChart = ({ cypress, data, year, graduationAmount, studyprogramme }) =
],
}
return <ReactHighcharts data-cy={`Graph-${cypress}`} config={defaultConfig} />
return (
<div data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default GaugeChart
@@ -1,32 +1,18 @@
import React, { useState } from 'react'
import { Divider, Loader, Message, Radio } from 'semantic-ui-react'
import { Divider, Loader, Message } from 'semantic-ui-react'
import { useGetBasicStatsQuery, useGetCreditStatsQuery, useGetGraduationStatsQuery } from 'redux/studyProgramme'
import WithHelpTooltip from '../../Info/InfoWithHelpTooltip'
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 getRadioButton = (cypress, toolTip, firstLabel, secondLabel, value, setValue) => (
<div className="radio-toggle">
<label className="toggle-label">{firstLabel}</label>
<Radio data-cy={cypress} toggle checked={value} onChange={() => setValue(!value)} />
{toolTip ? (
<WithHelpTooltip tooltip={{ short: toolTip }}>
<label className="toggle-label">{secondLabel}</label>
</WithHelpTooltip>
) : (
<label className="toggle-label">{secondLabel}</label>
)}
</div>
)
const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYear, setAcademicYear }) => {
const [showMeanTime, setShowMeanTime] = useState(true)
const toolTips = InfotoolTips.Studyprogramme
@@ -69,22 +55,22 @@ const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYea
return (
<div className="studyprogramme-overview">
<div className="toggle-container">
{getRadioButton(
'YearToggle',
toolTips.YearToggle,
'Calendar year ',
'Academic year',
academicYear,
setAcademicYear
)}
{getRadioButton(
'StudentToggle',
toolTips.StudentToggle,
'All studyrights included',
'Special studyrights excluded',
specialGroups,
setSpecialGroups
)}
<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 ? (
@@ -138,41 +124,51 @@ const Overview = ({ studyprogramme, specialGroups, setSpecialGroups, academicYea
/>
</div>
{getDivider('Average graduation times', 'AverageGraduationTimes')}
{getRadioButton('GraduationTimeToggle', null, 'Mean time', 'Median time', showMeanTime, setShowMeanTime)}
<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]
? graduations?.data?.graduationMeanTime?.[year]
: graduations?.data?.graduationMedianTime?.[year]
}
graduationAmount={graduations?.data?.graduationAmounts[year]}
totalAmount={graduations?.data?.totalAmounts[year]}
graduationAmount={graduations?.data?.graduationAmounts?.[year]}
totalAmount={graduations?.data?.totalAmounts?.[year]}
studyprogramme={studyprogramme}
/>
))}
</div>
{studyprogramme.includes('KH') && (
<>
{getDivider('Primary master programme studies after this programme', 'ProgrammesAfterGraduation')}
<div className="section-container">
<StackedBarChart
cypress="ProgrammesAfterGraduation"
wideTable
data={graduations?.data?.programmesAfterGraphStats}
labels={graduations?.data?.years}
/>
<DataTable
wideTable
data={graduations?.data?.programmesAfterTableStats}
titles={graduations?.data?.programmesAfterTitles}
/>
</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>
</>
</>
)}
</>
@@ -117,20 +117,35 @@ const StudyProgrammeSelector = ({ studyprogrammes, selected, language }) => {
{bachelorProgrammes.length > 0 ? (
<>
<Header>Bachelor programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={bachelorProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={bachelorProgrammes}
/>
</>
) : null}
{masterProgrammes.length > 0 ? (
<>
<Header>Master programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={masterProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={masterProgrammes}
/>
</>
) : null}
{otherProgrammes.length > 0 ? (
<>
<Header>Doctoral programmes and old programmes</Header>
<SortableTable columns={headers} getRowKey={programme => programme.code} data={otherProgrammes} />
<SortableTable
figure={false}
columns={headers}
getRowKey={programme => programme.code}
data={otherProgrammes}
/>
</>
) : null}
</>
@@ -5,7 +5,7 @@ const ReactHighcharts = require('react-highcharts')
const colors = ['#333737', '#bdc2c7', '#1392c2', '#195f8a', '#54997b', '#036415']
const BarChart = ({ data, track }) => {
const BarChart = ({ cypress, data, track }) => {
if (!data || !data.creditGraphStats || !data.creditGraphStats[track])
return <NoDataMessage message="No progress data for the studytrack found. Try with another studytrack" />
@@ -64,7 +64,7 @@ const BarChart = ({ data, track }) => {
}
return (
<div className="graph-container">
<div className="graph-container" data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
@@ -2,7 +2,7 @@ import React from 'react'
const ReactHighcharts = require('react-highcharts')
const GaugeChart = ({ data, year, graduationAmount, totalAmount, studyprogramme }) => {
const GaugeChart = ({ cypress, data, year, graduationAmount, totalAmount, studyprogramme }) => {
if (!data || !graduationAmount || !data.length) return null
const thresholdValues = studyprogramme.includes('KH') ? [36, 41] : [24, 27]
@@ -76,7 +76,11 @@ const GaugeChart = ({ data, year, graduationAmount, totalAmount, studyprogramme
],
}
return <ReactHighcharts config={defaultConfig} />
return (
<div data-cy={`Graph-${cypress}`}>
<ReactHighcharts config={defaultConfig} />
</div>
)
}
export default GaugeChart
@@ -1,8 +1,9 @@
import React, { useState } from 'react'
import { Icon, Radio, Table } from 'semantic-ui-react'
import { Icon, Table } from 'semantic-ui-react'
import * as _ from 'lodash'
import PopulationLink from './PopulationLink'
import Toggle from '../Toggle'
const getKey = year => `${year}-${Math.random()}`
@@ -124,14 +125,6 @@ const sortMainDataByYear = data => {
return sortedData
}
const getRadioButton = (toolTip, 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 StudytrackDataTable = ({
studyprogramme,
dataOfAllTracks,
@@ -151,7 +144,12 @@ const StudytrackDataTable = ({
return (
<div className="datatable">
{getRadioButton(null, 'Show percentages', 'Hide percentages', hidePercentages, setHidePercentages)}
<Toggle
firstLabel="Show percentages"
secondLabel="Hide percentages"
value={hidePercentages}
setValue={setHidePercentages}
/>
<Table data-cy="Table-StudytrackOverview" celled>
<Table.Header>
<Table.Row>
@@ -1,35 +1,39 @@
import React, { useEffect, useState } from 'react'
import { Divider, Loader, Radio } from 'semantic-ui-react'
import { Divider, Loader } from 'semantic-ui-react'
import { useGetStudytrackStatsQuery } from 'redux/studyProgramme'
import WithHelpTooltip from '../../Info/InfoWithHelpTooltip'
import InfoBox from '../../Info/InfoBox'
import BarChart from './BarChart'
import BasicDataTable from './BasicDataTable'
import GaugeChart from './GaugeChart'
import StudytrackDataTable from './StudytrackDataTable'
import StudytrackSelector from './StudytrackSelector'
import Toggle from '../Toggle'
import InfotoolTips from '../../../common/InfoToolTips'
import '../studyprogramme.css'
const getRadioButton = (toolTip, firstLabel, secondLabel, value, setValue) => (
<div className="radio-toggle">
<label className="toggle-label">{firstLabel}</label>
<Radio toggle checked={value} onChange={() => setValue(!value)} />
<WithHelpTooltip tooltip={{ short: toolTip }}>
<label className="toggle-label">{secondLabel}</label>
</WithHelpTooltip>
</div>
)
const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, graduated, setGraduated }) => {
const StudytrackOverview = ({
studyprogramme,
specialGroups,
setSpecialGroups,
graduated,
setGraduated,
combined,
setCombined,
}) => {
const toolTips = InfotoolTips.Studyprogramme
const [showMeanTime, setShowMeanTime] = useState(true)
const [track, setTrack] = useState(studyprogramme)
const special = specialGroups ? 'SPECIAL_EXCLUDED' : 'SPECIAL_INCLUDED'
const grad = graduated ? 'GRADUATED_EXCLUDED' : 'GRADUATED_INCLUDED'
const stats = useGetStudytrackStatsQuery({ id: studyprogramme, specialGroups: special, graduated: grad })
const yearsCombined = combined ? 'YEARS_COMBINED' : 'YEARS_SEPARATED'
const stats = useGetStudytrackStatsQuery({
id: studyprogramme,
specialGroups: special,
graduated: grad,
yearsCombined,
})
useEffect(() => {
if (!track && stats?.data?.mainStatsByTrack[studyprogramme]) {
@@ -60,14 +64,30 @@ const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, g
<>
<StudytrackSelector track={track} setTrack={setTrack} studytracks={stats?.data?.studytrackOptions} />
<div className="toggle-container">
{getRadioButton(
toolTips.StudentToggle,
'All studyrights included',
'Special studyrights excluded',
specialGroups,
setSpecialGroups
)}
{getRadioButton(toolTips.GradToggle, 'Graduated included', 'Graduated excluded', graduated, setGraduated)}
<Toggle
cypress="StudentToggle"
toolTips={toolTips.StudentToggle}
firstLabel="All studyrights"
secondLabel="Special studyrights excluded"
value={specialGroups}
setValue={setSpecialGroups}
/>
<Toggle
cypress="GraduatedToggle"
toolTips={toolTips.GraduatedToggle}
firstLabel="Graduated included"
secondLabel="Graduated excluded"
value={graduated}
setValue={setGraduated}
/>
<Toggle
cypress="AllStudentsToggles"
toolTips={toolTips.GraduatedToggle}
firstLabel="Show by year"
secondLabel="Show all years combined"
value={combined}
setValue={setCombined}
/>
</div>
{getDivider(
`Students of ${
@@ -95,7 +115,7 @@ const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, g
'StudytrackProgress'
)}
<div className="section-container">
<BarChart data={stats?.data} track={track || studyprogramme} />
<BarChart cypress="StudytrackProgress" data={stats?.data} track={track || studyprogramme} />
<BasicDataTable
cypress="StudytrackProgress"
data={stats?.data?.creditTableStats}
@@ -106,10 +126,16 @@ const StudytrackOverview = ({ studyprogramme, specialGroups, setSpecialGroups, g
{stats?.isSuccess && stats?.data?.includeGraduated && (
<>
{getDivider('Average graduation times', 'AverageGraduationTimes')}
{getRadioButton(null, 'Mean time', 'Median time', showMeanTime, setShowMeanTime)}
<Toggle
firstLabel="Mean time"
secondLabel="Median time"
value={showMeanTime}
setValue={setShowMeanTime}
/>
<div className="section-container-centered">
{stats?.data?.years.map(year => (
<GaugeChart
cypress={`${year.slice(0, 4)}-AverageGraduationTimes`}
key={year}
year={year}
data={stats?.data?.graduationMedianTime[track][year]}
@@ -0,0 +1,20 @@
import React from 'react'
import { Radio } from 'semantic-ui-react'
import WithHelpTooltip from '../Info/InfoWithHelpTooltip'
const Toggle = ({ cypress, toolTips, firstLabel, secondLabel, value, setValue }) => (
<div className="radio-toggle">
<label className={`toggle-label${value ? '-checked' : ''}`}>{firstLabel}</label>
<Radio data-cy={cypress} toggle checked={value} onChange={() => setValue(!value)} />
{toolTips ? (
<WithHelpTooltip tooltip={{ short: toolTips }}>
<label className={`toggle-label${value ? '' : '-checked'}`}>{secondLabel}</label>
</WithHelpTooltip>
) : (
<label className={`toggle-label${value ? '' : '-checked'}`}>{secondLabel}</label>
)}
</div>
)
export default Toggle
@@ -28,6 +28,7 @@ const StudyProgramme = props => {
const [academicYear, setAcademicYear] = useState(false)
const [specialGroups, setSpecialGroups] = useState(false)
const [graduated, setGraduated] = useState(false)
const [combined, setCombined] = useState(false)
useTitle('Study programmes')
@@ -62,6 +63,8 @@ const StudyProgramme = props => {
setSpecialGroups={setSpecialGroups}
graduated={graduated}
setGraduated={setGraduated}
combined={combined}
setCombined={setCombined}
/>
),
})
@@ -16,6 +16,7 @@
.studyprogramme-overview .toggle-container {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 1em;
}
@@ -39,6 +40,22 @@
font-weight: bold;
}
.studytrack-overview .toggle-label-checked,
.studyprogramme-overview .toggle-label-checked {
cursor: pointer;
padding-left: 0.5em;
padding-right: 0.5em;
font-weight: bold;
opacity: 0.5;
}
.studytrack-overview .ui.toggle.checkbox input:checked ~ label::before,
.studyprogramme-overview .ui.toggle.checkbox input:checked ~ label::before,
.studytrack-overview .ui.toggle.checkbox input:focus:checked ~ label::before,
.studyprogramme-overview .ui.toggle.checkbox input:focus:checked ~ label::before {
background-color: rgba(0, 0, 0, 0.05) !important;
}
.studyprogramme-overview .divider {
margin-top: 4em;
}
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Segment, Message, Button, Popup } from 'semantic-ui-react'
import { Segment, Message } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import { func, arrayOf, bool, shape, any, string } from 'prop-types'
@@ -8,7 +8,6 @@ import { getTopTeachersCategories } from '../../../redux/teachersTopCategories'
import { getTopTeachers } from '../../../redux/teachersTop'
import TeacherStatisticsTable from '../TeacherStatisticsTable'
import LeaderForm from './LeaderForm'
import { callApi } from '../../../apiConnection'
const TeacherLeaderBoard = ({
getTopTeachersCategories,
@@ -22,31 +21,12 @@ const TeacherLeaderBoard = ({
}) => {
const [selectedyear, setSelectedyear] = useState(null)
const [selectedcategory, setSelectedcategory] = useState(null)
const [recalculating, setRecalculating] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
getTopTeachersCategories()
}, [])
const handleOpen = () => {
setIsOpen(true)
setRecalculating(true)
setTimeout(() => {
setIsOpen(false)
}, 5000)
}
const handleClose = () => {
setIsOpen(false)
}
const refresh = () => {
callApi('/teachers/top', 'post', { startyearcode: selectedyear, endyearcode: selectedyear + 1 }, null)
}
const updateAndSubmitForm = args => {
setRecalculating(false)
const year = args.selectedyear || selectedyear
const category = args.selectedcategory || selectedcategory
getTopTeachers(year, category)
@@ -96,24 +76,13 @@ const TeacherLeaderBoard = ({
/>
<Segment>
<Message>{`Last updated: ${updated}`}</Message>
<Popup
trigger={
<Button
disabled={recalculating}
content="Recalculate this year"
onClick={() => {
refresh()
}}
/>
}
content="Recalculation started. Recalculation might take multiple minutes. Refresh page to see the results"
on="click"
open={isOpen}
onClose={handleClose}
onOpen={handleOpen}
/>
<TeacherStatisticsTable statistics={statistics} onClickFn={id => history.push(`/teachers/${id}`)} />
<TeacherStatisticsTable
statistics={statistics}
onClickFn={id => history.push(`/teachers/${id}`)}
unifyOpenUniCourses={false}
renderLink={false}
/>
</Segment>
</div>
)}
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import { Search, Segment, Icon } from 'semantic-ui-react'
import { func, arrayOf, object, string } from 'prop-types'
import { withRouter } from 'react-router-dom'
@@ -51,15 +51,34 @@ const TeacherSearch = ({ icon, teachers, onClick, setTimeout, clearTimeout, find
}
}
const columns = [
{ key: 'name', title: 'Name', getRowVal: s => s.name, headerProps: { onClick: null, sorted: null, colSpan: 2 } },
{
key: 'icon',
getRowContent: () => <Icon name={icon} />,
cellProps: { collapsing: true },
headerProps: { onClick: null, sorted: null },
},
]
const columns = useMemo(
() => [
{
key: 'name-parent',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Name',
getRowVal: s => s.name,
cellProps: row => ({
onClick: () => onClick(row),
}),
},
{
key: 'icon',
getRowContent: () => <Icon name={icon} />,
export: false,
cellProps: row => ({
onClick: () => onClick(row),
}),
},
],
},
],
[onClick]
)
return (
<div>
@@ -1,111 +1,87 @@
import React, { useState } from 'react'
import React, { useCallback, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Table, Segment, Icon, Item } from 'semantic-ui-react'
import { shape, arrayOf, any, string, number, func, bool } from 'prop-types'
import { sortBy } from 'lodash'
import { Segment, Icon, Item } from 'semantic-ui-react'
import { connect } from 'react-redux'
import SortableTable from 'components/SortableTable'
import { getCourseStats } from '../../../redux/coursestats'
const calculatePassrate = (pass, fail) => (100 * pass) / (pass + fail)
const TeacherStatisticsTable = ({ statistics, onClickFn, getCourseStats, unifyOpenUniCourses, renderLink }) => {
const [selected, setSelected] = useState('credits')
const [direction, setDirection] = useState('descending')
const fetchCourseStats = useCallback(
id => getCourseStats({ courseCodes: [id], separate: false, unifyOpenUniCourses }, null),
[unifyOpenUniCourses]
)
const handleSort = column => () => {
if (selected === column) {
const newDirection = direction === 'ascending' ? 'descending' : 'ascending'
setDirection(newDirection)
} else {
setSelected(column)
setDirection('descending')
}
}
const columns = useMemo(
() => [
{
key: 'teacher',
mergeHeader: true,
merge: true,
children: [
{
key: 'name',
title: 'Teacher',
getRowVal: row => row.name,
cellProps: row => ({
style: { cursor: 'pointer' },
onClick: () => onClickFn(row.id),
}),
},
{
key: 'link',
getRowContent: row => {
if (!renderLink) return null
const query = `courseCodes=["${row.id}"]&separate=false&unifyOpenUniCourses=${unifyOpenUniCourses}`
return (
<Item as={Link} to={`/coursestatistics?${query}`} onClick={() => fetchCourseStats(row.id)}>
<Icon name="level up alternate" />
</Item>
)
},
},
],
},
{
key: 'credits',
title: 'Credits',
getRowVal: row => row.credits,
formatValue: value => value.toFixed(2),
},
{
key: 'credits-transferred',
title: 'Credits Transferred',
getRowVal: row => row.transferred,
},
{
key: 'passrate',
title: 'Passed',
getRowVal: row => parseFloat(row.passrate),
formatValue: value => value.toFixed(2),
},
],
[renderLink, unifyOpenUniCourses, fetchCourseStats]
)
const sortStatistics = statistics => {
const formatted = statistics.map(stat => ({
...stat,
passrate: calculatePassrate(stat.passed, stat.failed),
}))
const sorted = sortBy(formatted, selected)
return direction === 'ascending' ? sorted : sorted.reverse()
}
const roundStatisticCredits = statistics =>
statistics.map(s => ({
...s,
credits: s.credits.toFixed(2),
}))
const sortDirection = name => (selected === name ? direction : null)
const fetchCourseStats = id => getCourseStats({ courseCodes: [id], separate: false, unifyOpenUniCourses }, null)
const renderCourseStatsLink = id => {
if (!renderLink) return null
const query = `courseCodes=["${id}"]&separate=false&unifyOpenUniCourses=${unifyOpenUniCourses}`
return (
<Item as={Link} to={`/coursestatistics?${query}`} onClick={() => fetchCourseStats(id)}>
<Icon name="level up alternate" />
</Item>
)
}
const data = useMemo(
() =>
statistics.map(stat => ({
...stat,
passrate: calculatePassrate(stat.passed, stat.failed),
})),
[statistics]
)
return statistics.length === 0 ? (
<Segment basic content="No statistics found for the given query." />
) : (
<Table structured celled textAlign="center" sortable selectable className="fixed-header">
<Table.Header>
<Table.Row>
<Table.HeaderCell
content="Name"
textAlign="left"
onClick={handleSort('name')}
sorted={sortDirection('name')}
/>
<Table.HeaderCell content="Credits" onClick={handleSort('credits')} sorted={sortDirection('credits')} />
<Table.HeaderCell
content="Credits transferred"
onClick={handleSort('transferred')}
sorted={sortDirection('transferred')}
/>
<Table.HeaderCell content="Passed" onClick={handleSort('passrate')} sorted={sortDirection('passrate')} />
</Table.Row>
</Table.Header>
<Table.Body>
{roundStatisticCredits(sortStatistics(statistics)).map(({ id, name, credits, passrate, transferred }) => (
<Table.Row key={id} onClick={() => onClickFn(id)} style={{ cursor: 'pointer' }}>
<Table.Cell textAlign="left">
{name}
{renderCourseStatsLink(id)}
</Table.Cell>
<Table.Cell content={credits} width={2} />
<Table.Cell content={transferred} width={2} />
<Table.Cell content={`${parseFloat(passrate).toFixed(2)} %`} width={2} />
</Table.Row>
))}
</Table.Body>
</Table>
<>
<SortableTable title="Teacher statistics" columns={columns} data={data} />
</>
)
}
TeacherStatisticsTable.propTypes = {
statistics: arrayOf(
shape({
id: any,
name: string,
transferred: number,
credits: any,
failed: number,
passed: number,
})
).isRequired,
onClickFn: func.isRequired,
getCourseStats: func.isRequired,
unifyOpenUniCourses: bool.isRequired,
renderLink: bool.isRequired,
}
const mapStateToProps = state => {
const { unifyOpenUniCourses } = state.courseSearch
return { unifyOpenUniCourses }
@@ -15,6 +15,9 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
return error ? null : (
<div>
<SortableTable
stretch
singleLine={false}
figure={false}
getRowKey={user => user.id}
tableProps={{ celled: true, structured: true }}
columns={[
@@ -51,6 +54,19 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
key: 'PROGRAMMES',
title: 'Programmes',
getRowVal: user => {
if (!user.elementdetails || user.elementdetails.length === 0) {
return []
}
const nameInLanguage = code => {
const elem = elementdetails.find(e => e.code === code)
if (!elem) return null
return getTextIn(elem.name, language)
}
return user.elementdetails.map(element => nameInLanguage(element))
},
getRowContent: user => {
const nameInLanguage = code => {
const elem = elementdetails.find(e => e.code === code)
if (!elem) return null
@@ -70,6 +86,7 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
key: 'OODIACCESS',
title: 'Has access',
getRowVal: user => user.is_enabled,
formatValue: value => (value ? 'Has access' : 'No access'),
getRowContent: user => (
<Icon
style={{ margin: 'auto' }}
@@ -92,6 +109,8 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
{
key: 'SHOWAS',
title: 'Show as user',
filterable: false,
sortable: false,
getRowVal: user => (
<Button circular size="tiny" basic icon="spy" onClick={() => showAsUser(user.username)} />
),
@@ -100,9 +119,17 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
{
key: 'EDIT',
title: '',
filterable: false,
sortable: false,
getRowVal: user => (
<Button.Group compact widths={2}>
<Button basic size="mini" as={Link} to={`users/${user.id}`}>
<Button
basic
size="mini"
as={Link}
to={`users/${user.id}`}
data-cy={`user-edit-button-${user.username}`}
>
Edit
</Button>
</Button.Group>
+12 -10
View File
@@ -1,11 +1,7 @@
// import { actions } from './common/itemreducer'
import { callController } from '../apiConnection/index'
const prefix = 'GET_COURSE_SEARCH_RESULT_'
// const storeActions = actions(prefix)
// export const clearCourses = storeActions.clear
export const clearCourses = () => ({ type: 'CLEAR_SEARCH_RESULTS' })
export const findCourses = ({ name, type }, language = 'fi') => {
@@ -20,11 +16,14 @@ export const findCoursesV2 = ({ name, code }) => {
return callController(route, prefix, [], 'get', params, params)
}
export const toggleUnifyOpenUniCourses = () => ({
type: 'TOGGLE_UNIFY_OPEN_UNI_COURSES',
})
export const toggleOpenAndReqularCourses = showType => {
return {
type: 'TOGGLE_OPEN_AND_REQULAR_COURSES',
showType,
}
}
const reducer = (state = { data: {}, pending: false, unifyOpenUniCourses: false }, action) => {
const reducer = (state = { data: {}, pending: false, openOrReqular: 'unifyStats' }, action) => {
switch (action.type) {
case 'GET_COURSE_SEARCH_RESULT_ATTEMPT':
return {
@@ -48,16 +47,19 @@ const reducer = (state = { data: {}, pending: false, unifyOpenUniCourses: false
? action.response
: state.data,
}
case 'TOGGLE_UNIFY_OPEN_UNI_COURSES':
case 'TOGGLE_OPEN_AND_REQULAR_COURSES':
return {
...state,
unifyOpenUniCourses: !state.unifyOpenUniCourses,
openOrReqular: action.showType,
}
case 'CLEAR_SEARCH_RESULTS':
return {
...state,
data: {},
}
default:
return state
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const getCourseStats = ({ courseCodes, separate, unifyOpenUniCourses }, o
const responseToObj = coursestats => {
const data = {}
coursestats.forEach(stat => {
data[stat.coursecode] = stat
data[stat.unifyStats.coursecode] = stat
})
return data
}
-2
View File
@@ -27,7 +27,6 @@ import teachersTopCategories from './teachersTopCategories'
import courseSearch from './coursesearch'
import courseStats from './coursestats'
import courseSummaryForm from './coursesSummaryForm'
import thesisCourses from './thesisCourses'
import accessGroups from './accessGroups'
import feedback from './feedback'
import mandatoryCourseLabels from './mandatoryCourseLabels'
@@ -67,7 +66,6 @@ const store = configureStore({
courseSearch,
courseStats,
courseSummaryForm,
thesisCourses,
accessGroups,
elementdetails,
feedback,
@@ -15,8 +15,8 @@ const studyprogrammeApi = RTKApi.injectEndpoints({
`/v2/studyprogrammes/${id}/graduationstats?year_type=${yearType}&special_groups=${specialGroups}`,
}),
getStudytrackStats: builder.query({
query: ({ id, graduated, specialGroups }) =>
`/v2/studyprogrammes/${id}/studytrackstats?graduated=${graduated}&special_groups=${specialGroups}`,
query: ({ id, graduated, specialGroups, yearsCombined }) =>
`/v2/studyprogrammes/${id}/studytrackstats?graduated=${graduated}&special_groups=${specialGroups}&years_combined=${yearsCombined}`,
}),
updateBasicView: builder.query({
query: ({ id }) => `/v2/studyprogrammes/${id}/update_basicview`,
@@ -1,110 +0,0 @@
import { callController, actionTypes } from '../apiConnection/index'
const additionalTypes = prefix => ({
reset: `${prefix}_CLEAR`,
})
const createReducer = (
prefix,
{
reduceGET = (_, action) => action.response,
reducePOST = (data, action) => data.concat(action.response),
reduceDELETE = (data, action) => data.filter(d => d.id === action.query.id),
defaults = undefined,
}
) => {
const prefixGET = `${prefix}GET_`
const prefixPOST = `${prefix}POST_`
const prefixDELETE = `${prefix}DELETE_`
const read = actionTypes(prefixGET)
const create = actionTypes(prefixPOST)
const destroy = actionTypes(prefixDELETE)
const other = additionalTypes(prefix)
const initialState = {
pending: false,
error: false,
data: [],
...defaults,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case destroy.attempt:
case create.attempt:
case read.attempt:
return {
...state,
error: false,
pending: true,
}
case destroy.failure:
case create.failure:
case read.failure:
return {
...state,
error: true,
pending: false,
}
case read.success:
return {
...state,
data: reduceGET(state.data, action),
error: false,
pending: false,
}
case create.success:
return {
...state,
data: reducePOST(state.data, action),
error: false,
pending: false,
}
case destroy.success:
return {
...state,
data: reduceDELETE(state.data, action),
error: false,
pending: false,
}
case other.reset: {
return {
...initialState,
}
}
default:
return state
}
}
return {
reducer,
prefixGET,
prefixPOST,
prefixDELETE,
}
}
const prefix = 'PROGRAMME_THESIS_'
const options = {
defaults: { data: [] },
reduceDELETE: (data, action) => data.filter(c => c.courseCode !== action.query.course),
}
const { reducer, prefixGET, prefixPOST, prefixDELETE } = createReducer(prefix, options)
export const getThesisCourses = studyprogramme => {
const route = `/v2/studyprogrammes/${studyprogramme}/thesis`
return callController(route, prefixGET, [], 'get')
}
export const createNewThesisCourse = (studyprogramme, course, thesisType) => {
const route = `/v2/studyprogrammes/${studyprogramme}/thesis`
const data = { course, thesisType }
return callController(route, prefixPOST, data, 'post')
}
export const deleteThesisCourse = (studyprogramme, course) => {
const route = `/v2/studyprogrammes/${studyprogramme}/thesis/${course}`
return callController(route, prefixDELETE, [], 'delete', { studyprogramme, course })
}
export default reducer
+29 -2
View File
@@ -2,11 +2,15 @@ import { createSelector } from '@reduxjs/toolkit'
import { sortBy, flatten } from 'lodash'
const courseStatsSelector = state => state.courseStats.data
const openOrReqularSelector = state => state.courseSearch.openOrReqular
const singleCourseStatsSelector = state => state.singleCourseStats
const getCourseStats = createSelector([courseStatsSelector], courseStats => {
const getCourseStats = createSelector([courseStatsSelector, openOrReqularSelector], (courseStats, openOrReqular) => {
const stats = {}
Object.entries(courseStats).forEach(entry => {
const [coursecode, data] = entry
const [coursecode] = entry
const data = entry[1][openOrReqular]
const { statistics } = data
stats[coursecode] = {
...data,
@@ -16,6 +20,27 @@ const getCourseStats = createSelector([courseStatsSelector], courseStats => {
return stats
})
const getCourseAlternatives = createSelector(
[courseStatsSelector, openOrReqularSelector, singleCourseStatsSelector],
(courseStats, openOrReqular, singleCourseStats) => {
return courseStats[singleCourseStats.selectedCourse][openOrReqular].alternatives
}
)
const getAvailableStats = createSelector([courseStatsSelector], courseStats => {
const availableStats = {}
Object.entries(courseStats).forEach(entry => {
const [coursecode] = entry
availableStats[coursecode] = {
unify: entry[1].unifyStats.statistics.length > 0,
open: entry[1].openStats.statistics.length > 0,
university: entry[1].reqularStats.statistics.length > 0,
}
})
return availableStats
})
const selectedCourseSelector = state => state.singleCourseStats.selectedCourse
const getQueryInfo = createSelector([getCourseStats], stats => {
@@ -183,8 +208,10 @@ const getCourses = createSelector(getCourseStats, stats =>
)
export default {
getAvailableStats,
getCourseStats,
getCourses,
getCourseAlternatives,
getAllStudyProgrammes,
summaryStatistics,
ALL,
+71 -9
View File
@@ -1,9 +1,73 @@
import { createSelector } from '@reduxjs/toolkit'
import { orderBy } from 'lodash'
import { byDateDesc, reformatDate } from '../common'
const getData = obj => obj
const sortAlternatives = alternatives => {
const result = orderBy(
alternatives,
[
a => {
if (a.code.match(/^A/)) return 4 // open university codes come last
if (a.code.match(/^\d/)) return 2 // old numeric codes come second
if (a.code.match(/^[A-Za-z]/)) return 1 // new letter based codes come first
return 3 // unknown, comes before open uni?
},
a => a.latestInstanceDate || new Date(),
'code',
],
['asc', 'desc', 'desc']
)
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 mergedCourses = {}
const sortedCourses = sortAlternatives(courses)
sortedCourses.forEach(course => {
const groupId = /* isAvoin(course) && !unifyOpenUniCourses ? course.code : */ course.subsId
if (!(course.max_attainment_date && course.min_attainment_date)) {
return
}
if (!mergedCourses[groupId]) {
mergedCourses[groupId] = {
...course,
alternatives: [{ code: course.code, latestInstanceDate: new Date(course.latest_instance_date) }],
min_attainment_date: new Date(course.min_attainment_date),
max_attainment_date: new Date(course.max_attainment_date),
}
} else {
const mergedCourse = mergedCourses[groupId]
mergedCourse.min_attainment_date = new Date(
Math.min(mergedCourse.min_attainment_date, new Date(course.min_attainment_date))
)
mergedCourse.max_attainment_date = new Date(
Math.max(mergedCourse.max_attainment_date, new Date(course.max_attainment_date))
)
mergedCourse.alternatives.push({
code: course.code,
latestInstanceDate: new Date(course.latest_instance_date),
})
}
})
const result = Object.values(mergedCourses).map(course => ({
...course,
alternatives: getAlternatives(course),
}))
return result
}
const getData = obj => obj
const getInstanceData = obj => obj.data
const getCourseSearchSelector = state => state.courseSearch.data.courses
export const sortInstances = courseInstances => {
if (courseInstances) {
@@ -31,11 +95,9 @@ export const makeSortCourseInstances = () => createSelector(getInstanceData, sor
export const makeSortCourses = () => createSelector(getData, sortCourses)
export const getCourseSearchResults = state =>
state.courseSearch.data.courses
? {
courses: state.courseSearch.data.courses,
}
: {
courses: [],
}
export const getCourseSearchResults = createSelector(getCourseSearchSelector, courses => {
if (!courses) {
return []
}
return filterCourseSearchResults(courses)
})
+6 -6
View File
@@ -1250,9 +1250,9 @@
}
},
"node_modules/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==",
"funding": [
{
"type": "individual",
@@ -9371,9 +9371,9 @@
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q=="
},
"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",
+13 -1
View File
@@ -2,7 +2,7 @@ const { chunk } = require('lodash')
const { eachLimit } = require('async')
const { dbConnections } = require('./connection')
const { logger } = require('../utils/logger')
const { getLatestSnapshot, isActive } = require('../utils')
const { getLatestSnapshot, isActive, getActiveSnapshot } = require('../utils')
const selectFromByIds = async (table, ids, col = 'id') => dbConnections.knex(table).whereIn(col, ids)
@@ -39,6 +39,17 @@ const selectFromSnapshotsByIds = async (table, ids, col = 'id') =>
.filter(s => !!s)
.filter(isActive)
const selectFromActiveSnapshotsByIds = async (table, ids, col = 'id') =>
(
await dbConnections.knex
.select(dbConnections.knex.raw(`array_agg(to_json(${table}.*)) as data`))
.from(table)
.whereIn(col, ids)
.groupBy('id')
)
.map(({ data }) => getActiveSnapshot(data))
.filter(s => !!s)
const getColumnsToUpdate = (model, keys) => Object.keys(model.rawAttributes).filter(a => !keys.includes(a))
const bulkCreate = async (model, entities, transaction = null, properties = ['id']) => {
@@ -70,4 +81,5 @@ module.exports = {
selectColumnsFrom,
selectWithoutNull,
getCourseUnitsByCodes,
selectFromActiveSnapshotsByIds,
}
@@ -0,0 +1,61 @@
const { STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.createTable('enrollment', {
id: {
primaryKey: true,
type: STRING,
},
studentnumber: {
type: STRING,
references: {
model: 'student',
key: 'studentnumber',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semestercode: {
type: INTEGER,
},
course_code: {
type: STRING,
},
state: {
type: STRING,
},
enrollment_date_time: {
type: DATE,
},
course_id: {
type: STRING,
references: {
model: 'course',
key: 'id',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semester_composite: {
type: STRING,
references: {
model: 'semesters',
key: 'composite',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
createdAt: {
type: DATE,
},
updatedAt: {
type: DATE,
},
is_open: {
type: BOOLEAN,
},
})
},
down: async () => {},
}
@@ -0,0 +1,69 @@
const { Model, DATE, INTEGER, STRING, BOOLEAN } = require('sequelize')
const { dbConnections } = require('../connection')
class Enrollment extends Model {}
Enrollment.init(
{
id: {
primaryKey: true,
type: STRING,
},
studentnumber: {
type: STRING,
references: {
model: 'student',
key: 'studentnumber',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
course_code: {
type: STRING,
},
state: {
type: STRING,
},
enrollment_date_time: {
type: DATE,
},
course_id: {
type: STRING,
references: {
model: 'course',
key: 'id',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semester_composite: {
type: STRING,
references: {
model: 'semesters',
key: 'composite',
},
onUpdate: 'cascade',
onDelete: 'cascade',
},
semestercode: {
type: INTEGER,
},
createdAt: {
type: DATE,
},
updatedAt: {
type: DATE,
},
is_open: {
type: BOOLEAN,
},
},
{
underscored: false,
sequelize: dbConnections.sequelize,
modelName: 'enrollment',
tableName: 'enrollment',
}
)
module.exports = Enrollment
@@ -16,6 +16,7 @@ const StudyrightElement = require('./studyrightElement')
const Transfer = require('./transfer')
const ProgrammeModule = require('./programmeModule')
const ProgrammeModuleChild = require('./programmeModuleChild')
const Enrollment = require('./enrollment')
CourseType.hasMany(Course, { foreignKey: 'coursetypecode', sourceKey: 'coursetypecode' })
Course.belongsTo(CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
@@ -101,6 +102,9 @@ ProgrammeModule.belongsToMany(ProgrammeModule, {
Organization.hasMany(Organization, { foreignKey: 'parent_id', as: 'children' })
Enrollment.belongsTo(Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
Student.hasMany(Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
module.exports = {
Organization,
Course,
@@ -120,4 +124,5 @@ module.exports = {
Transfer,
ProgrammeModule,
ProgrammeModuleChild,
Enrollment,
}

Some files were not shown because too many files have changed in this diff Show More