Compare commits

...
52 Commits
Author SHA1 Message Date
outisa 0f73e5025b [Open uni] Add small modifications 2022-11-18 17:44:06 +02:00
outisa 69f0b4cb75 [Updater] Map unknown marketing allowance as null 2022-11-18 17:18:03 +02:00
Laura Sinikallio 2a96a72151 Fix faculty credit sorting bug 2022-11-16 17:16:52 +02:00
outisa 30c2cfbfa0 [Open uni] Add search into url 2022-11-16 17:15:15 +02:00
outisa e8c3a77ba6 [Open uni] Fix indentation 2022-11-16 14:13:58 +02:00
outisa e15894c2f5 [Course stats] Fix bug faculties not shown in credits gains 2022-11-16 14:12:26 +02:00
outisa 566fb861b0 [Open uni] Add UI betterments 2022-11-15 15:23:40 +02:00
outisa 80873a3227 [Open uni] Add possibility to save, update and delete searches 2022-11-12 17:43:33 +02:00
outisa 809c63ada9 [Open uni] Send usage data to grafana 2022-11-11 12:38:24 +02:00
outisa f0bf56f6c8 [Open uni] Add new columns 2022-11-10 18:01:04 +02:00
outisa e4214ef871 [Updater] Add open uni dissemination of information allowed field for student 2022-11-10 13:28:36 +02:00
outisa 88e6939347 [Open uni] Add possibility to choose dates 2022-11-10 00:16:25 +02:00
outisa dc73aabbd3 [Open uni] Add small adjustments 2022-11-04 12:49:09 +02:00
outisa 1dd41b0b9b Fix cypress tests 2022-11-03 14:42:35 +02:00
Laura Sinikallio 328437ced2 Remove 'only' from tests 2022-11-03 13:42:06 +02:00
Laura Sinikallio f79bd9f2f9 Add faculty graduation times tests 2022-11-03 13:40:01 +02:00
outisa 84bc0434fd Remove role if access no longer exists 2022-11-03 13:26:29 +02:00
outisa 96045d0e17 [Open Uni] Take course codes starting with AY into account 2022-11-03 13:06:56 +02:00
outisa 129c608108 [Navigation bar] Fix typo in role check 2022-11-02 15:45:23 +02:00
outisa d73aa95684 Add new iam to relevant iams 2022-11-02 09:22:45 +02:00
Laura Sinikallio fe9e9d2527 Fix updater looking at ALL term registrations of students-to-update instead of the individual student's registrations 2022-11-01 13:16:17 +02:00
outisa e5a52f3db6 Fix test 2022-11-01 12:37:47 +02:00
outisa 84bb527e5e Modify navigation bar visibility 2022-11-01 11:44:02 +02:00
outisa bd7bf5c712 Cypress please 2022-10-31 19:00:06 +02:00
outisa b6d3106ee9 Fix months calculation 2022-10-31 18:34:06 +02:00
outisa d7bc7d5d2e [faculty] Fix dynamic months limit for master 2022-10-31 18:30:04 +02:00
outisa 8eb3d58658 Change import for uuid 2022-10-31 16:27:58 +02:00
outisa 8c85467bcd [OpenUni] Add studyright filtering betterments 2022-10-31 16:18:02 +02:00
outisa f59f41cc3e Fix (tried at least) fetching courses when list is empty 2022-10-31 15:08:34 +02:00
outisa 678c90cbda [CSV] Change button's place and remove 'not passed' 2022-10-31 14:04:34 +02:00
outisa 7cee60596c Try to fix cypress tests 2022-10-28 18:20:57 +03:00
outisa 979d24d375 Fix some unique key errors 2022-10-28 14:43:24 +03:00
outisa cdf912db9b [Open uni] Add small modifications 2022-10-28 13:11:30 +03:00
outisa 51867d840b [Open uni] Create open uni logic 2022-10-27 22:22:18 +03:00
outisa 51137fa66f Add group for open uni people 2022-10-26 17:40:48 +03:00
outisa a394928e83 [Updater] Add secondary email to mapper to acually get it 2022-10-26 17:39:05 +03:00
Laura Sinikallio c7edc70ccb Replace to-be-deprecated commands 2022-10-25 17:30:36 +03:00
Laura Sinikallio 2aa2c9419c Update to-be-deprecated step 2022-10-25 17:08:28 +03:00
outisa 5b9728dc1d [Faculty] Fix broken link in students stats table 2022-10-21 15:02:20 +03:00
outisa 8c73a32631 Add secondary email to models 2022-10-20 22:00:47 +03:00
outisa 459c214164 [Updater] Add secondary email column for the student 2022-10-20 21:48:08 +03:00
Laura Sinikallio 96235ed242 Update info text 2022-10-20 14:57:28 +03:00
Laura Sinikallio 45ce3a70c9 Class statistics: hide buggy options, improve word choices 2022-10-20 14:42:52 +03:00
Laura Sinikallio c101efb961 Courses of class: fold/unfold all affects all tabs 2022-10-20 14:28:53 +03:00
Laura Sinikallio 0d47ad6cc7 Courses of class: keep module lists open when changing tabs 2022-10-20 13:56:08 +03:00
Luukkainen 9a338b79d5 Merge branch 'master' of github.com:UniversityOfHelsinkiCS/oodikone 2022-10-20 13:43:42 +03:00
Luukkainen 0087226de1 lintignore 2022-10-20 13:43:19 +03:00
Laura Sinikallio 8276359359 Remove duplicate pass/fail tab from courses of class 2022-10-20 12:47:44 +03:00
Laura Sinikallio 53214e822e Slight layout changes to class statistics view 2022-10-19 18:40:51 +03:00
Laura Sinikallio 20c24f2722 Update test 2022-10-19 18:30:08 +03:00
Laura Sinikallio 9b88f6c008 Update tests 2022-10-19 18:03:21 +03:00
Laura Sinikallio c1bab2fb63 Population statistics: move key info to header 2022-10-19 17:42:00 +03:00
58 changed files with 1678 additions and 196 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
# This steps extracts release version from GITHUB_REF env var
# See: https://github.community/t/how-to-get-just-the-tag-name/16241/10
id: extract_version
run: echo ::set-output name=VERSION::"${GITHUB_REF#refs/tags/}"
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: 'Build and push'
uses: docker/build-push-action@v3
with:
@@ -50,13 +50,13 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 'Get current time'
id: get_current_time
run: echo ::set-output name=NOW::"$(date --iso-8601=seconds)"
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: 'Extract release version'
# Github context (e.g. github.ref) only has ref formatted as /refs/tags/vX.X.X
# This steps extracts release version from GITHUB_REF env var
# See: https://github.community/t/how-to-get-just-the-tag-name/16241/10
id: extract_version
run: echo ::set-output name=VERSION::"${GITHUB_REF#refs/tags/}"
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: 'Build and push'
uses: docker/build-push-action@v3
with:
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
- uses: docker/setup-buildx-action@v2
- name: 'Get current time'
id: get_current_time
run: echo ::set-output name=NOW::"$(date --iso-8601=seconds)"
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: 'Build image and export it'
uses: docker/build-push-action@v3
with:
+85
View File
@@ -135,4 +135,89 @@ describe('Faculty overview', () => {
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
})
})
describe('Average graduation times', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('td', 'H60').click()
cy.contains('Graduation times').click()
})
it('User can view graduation graphs', () => {
cy.get('[data-cy="Section-AverageGraduationTimes"]').should('be.visible')
cy.get('[data-cy="Section-bachelor"]').should('be.visible')
cy.get('[data-cy="Section-master"]').should('be.visible')
cy.get('[data-cy="Section-master"]').within(() => {
cy.contains('Graduation year').should('be.visible')
cy.contains('.message', "Click a bar to view that year's programme level breakdown").should('be.visible')
})
})
it('Graphs display data', () => {
cy.get('[data-cy="Section-bachelor"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('1 graduated').should('have.length', 1)
cy.contains('1 graduated').trigger('mouseover')
cy.contains('1 students graduated in year 2019')
cy.contains('median study time: 40 months')
cy.contains('0 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('1 graduated').click()
cy.contains('Year 2019 by graduation year')
cy.get('div[class="programmes-graph"]').should('be.visible')
cy.get('div[class="programmes-graph"]').within(() => {
cy.contains('EDUK')
cy.contains('1 graduated').trigger('mouseover')
cy.contains('Kasvatustieteiden kandiohjelma')
cy.contains('KH60_001')
})
})
cy.get('[data-cy="Section-master"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('1 graduated').should('have.length', 1)
cy.contains('1 graduated').trigger('mouseover')
cy.contains('1 students graduated in year 2020')
cy.contains('median study time: 25 months')
cy.contains('0 graduated over year late')
cy.contains('1 graduated max year overtime')
cy.contains('1 graduated').click()
cy.contains('Year 2020 by graduation year')
cy.get('div[class="programmes-graph"]').should('be.visible')
cy.get('div[class="programmes-graph"]').within(() => {
cy.contains('EDUM')
cy.contains('1 graduated').trigger('mouseover')
cy.contains('Kasvatustieteiden maisteriohjelma')
cy.contains('MH60_001')
})
})
})
it('Graduation times grouping and time types can be toggled', () => {
cy.get('[data-cy="GroupByToggle"]').click()
cy.get('[data-cy="Section-bachelor"]').should('not.exist')
cy.get('[data-cy="Section-master"]').should('be.visible')
cy.get('[data-cy="Section-master"]').within(() => {
cy.get('div[class="faculty-graph"]')
cy.contains('1 graduated (100 % of class)').should('have.length', 1)
cy.contains('1 graduated').trigger('mouseover')
cy.contains('From class of 2018, 1/undefined students have graduated')
})
cy.get('[data-cy="GraduationTimeToggle"]').click()
cy.get('[data-cy="Section-master"]').within(() => {
cy.contains('1 graduated (100 % of class)').click()
cy.get('div[class="programmes-graph"]').should('be.visible')
cy.get('div[class="programmes-graph"]').within(() => {
cy.contains('Year 2018 by start year')
cy.contains('1 graduated').trigger('mouseover')
cy.contains('From class of 2018, 1/1 students have graduated')
cy.contains('mean study time: 25 months')
})
})
})
})
})
+11 -9
View File
@@ -1,4 +1,5 @@
/// <reference types="Cypress" />
// Now "Class statistics" in UI
const moment = require('moment')
const _ = require('lodash')
@@ -58,7 +59,7 @@ describe('Population Statistics tests', () => {
.contains('Tietojenkäsittelytieteen kandiohjelma')
.click()
cy.contains('See class').click()
cy.contains('Class statistics')
cy.contains('class size')
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
})
@@ -68,10 +69,11 @@ describe('Population Statistics tests', () => {
// This advanced settings does not work correctly
// see https://github.com/UniversityOfHelsinkiCS/oodikone/issues/3552
// setPopStatsUntil('toukokuu 2020')
cy.get('.card').within(() => {
cy.get('.header').within(() => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Sample size: 170 students')
cy.contains('class size 170 students')
})
cy.get('.card').within(() => {
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
@@ -136,9 +138,8 @@ describe('Population Statistics tests', () => {
cy.contains('Credit statistics').click()
cy.contains('Credits Gained').click()
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
const today = moment().endOf('month')
const months = Math.round(moment.duration(moment(today).diff(moment('2017-08-01'))).asMonths())
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
@@ -227,9 +228,10 @@ describe('Population Statistics tests', () => {
it('Population statistics is visible', () => {
cy.reload()
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('class size 170 students')
cy.get('.card').within(() => {
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('Sample size: 170 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
+1 -1
View File
@@ -93,8 +93,8 @@ describe('Student Statistics tests', () => {
cy.get('.prompt').type(student.studentnumber)
cy.contains('td a', student.studentnumber).click()
cy.get('i.level.up.alternate.icon').eq(0).click()
cy.contains('Class statistics')
cy.contains('Tietojenkäsittelytieteen kandiohjelma')
cy.contains('class size')
})
})
+38 -46
View File
@@ -25,12 +25,14 @@ describe('Studyprogramme overview', () => {
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.viewport(1536, 960)
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click({ force: true })
// cy.viewport(1536, 960)
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information -tab loads', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
@@ -39,7 +41,6 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct students', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Started, Graduated, Transferred Away, Transferred to]
@@ -55,23 +56,21 @@ describe('Studyprogramme overview', () => {
})
it('Basic information contains correct credits', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Total, Major credits, Non-major credits, Non-degree credits, Transferred credits]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2021, 0, 0, 0, 0, 0],
[2020, 5860, 5591, 102, 0, 167],
[2019, 8696, 7737, 587, 23, 349],
[2018, 7818, 6584, 857, 105, 272],
[2017, 3085, 2492, 158, 47, 388],
[2019, 8696, 7737, 577, 33, 349],
[2018, 7818, 6584, 852, 110, 272],
[2017, 3085, 2492, 153, 52, 388],
]
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
})
it('Basic information contains correct thesis writers and graduates', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
@@ -87,7 +86,6 @@ describe('Studyprogramme overview', () => {
})
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 = [
@@ -129,39 +127,39 @@ describe('Studyprogramme overview', () => {
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen maisteriohjelma').click()
cy.get('[data-cy=YearToggle]').click()
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.get('[data-cy=YearToggle]').click({ force: true })
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started, Graduated, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0],
['2020 - 2021', 5, 0, 0, 0],
['2019 - 2020', 27, 0, 0, 0],
['2018 - 2019', 4, 0, 0, 0],
['2017 - 2018', 0, 0, 0, 0],
['2020 - 2021', 12, 6, 3, 2],
['2019 - 2020', 86, 41, 1, 0],
['2018 - 2019', 162, 5, 0, 1],
['2017 - 2018', 171, 1, 0, 0],
]
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
const creditTableContents = [
// [Year, Total, Major students credits, Non-major students credits, Non-degree credits, Transferred credits]
...years.map(year => [year, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0],
['2020 - 2021', 0, 0, 0, 0],
['2019 - 2020', 338.5, 177, 154, 0, 7.5],
['2018 - 2019', 173, 0, 173, 0, 0],
['2017 - 2018', 42, 0, 42, 0, 0],
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2021 - 2022', 0, 0, 0, 0, 0],
['2020 - 2021', 566, 486, 20, 0, 60],
['2019 - 2020', 8595, 8271, 106, 0, 218],
['2018 - 2019', 9992, 8855, 781, 53, 303],
['2017 - 2018', 6055, 4792, 777, 142, 344],
]
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
//cy.wait(10000)
cy.get('[data-cy=YearToggle]').click()
})
it('Basic information graphs render', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
.should('contain', 'Started')
.should('contain', 'Graduated')
@@ -171,8 +169,8 @@ describe('Studyprogramme overview', () => {
.should('contain', 'Major students credits')
.should('contain', 'Non-major students credits')
.should('contain', 'Transferred credits')
.should('contain', 857)
.should('contain', 587)
.should('contain', 852)
.should('contain', 577)
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
.should('contain', 'Graduated students')
@@ -202,20 +200,18 @@ describe('Studyprogramme overview', () => {
describe('Studytrack overview works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Studytracks and student populations -tab loads', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
cy.get('[data-cy=Section-StudytrackOverview]')
cy.get('[data-cy=Section-StudytrackProgress]')
cy.get('[data-cy=Section-AverageGraduationTimes]')
})
it('Students of the studyprogramme are shown correctly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
// [Year, All, Started studying, Currently enrolled, Absent, Cancelled, Graduated, Men, Women, Finnish]
['2020 - 2021', 12, 10, 0, 0, 0, 0, 10, 2, 12],
@@ -229,8 +225,6 @@ describe('Studyprogramme overview', () => {
})
it('Student progress data is shown correctly', () => {
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Studytracks and student populations').click()
const tableContents = [
['2020 - 2021', 12, 12, 0, 0, 0, 0, 0, 0, 0],
['2019 - 2020', 86, 11, 15, 35, 20, 5, 0, 0, 0],
@@ -243,9 +237,6 @@ describe('Studyprogramme overview', () => {
})
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 15 credits')
.should('contain', '30-59 credits')
@@ -285,16 +276,21 @@ describe('Studyprogramme overview', () => {
})
it('calendar year -> academic year toggle works', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
cy.get('[data-cy=fromYear]').click().contains('2018').click()
cy.get('[data-cy=toYear').click().contains('2019').click()
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(20000)
cy.get('[data-cy=CoursesSortableTable]').within(() => {
cy.get('tr').eq(1).contains('684')
})
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(20000)
cy.get('[data-cy=calendarAcademicYearToggle]').first().click()
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(20000)
cy.get('[data-cy=CoursesSortableTable]').within(() => {
cy.get('tr').eq(1).contains('772')
})
@@ -336,12 +332,12 @@ describe('Studyprogramme overview', () => {
describe('Basic information -view works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Tietojenkäsittelytieteen kandiohjelma').click()
cy.get('.attached').contains('Tags').click()
})
it('can create and delete tags for population', () => {
const name = `tag-${new Date().getTime()}`
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')
cy.contains('Create new tag').click()
@@ -352,8 +348,6 @@ describe('Studyprogramme overview', () => {
it('can create personal tags', () => {
const name = `tag-${new Date().getTime()}`
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')
@@ -367,9 +361,6 @@ describe('Studyprogramme overview', () => {
const name = `tag-${new Date().getTime()}`
const student = '010113437'
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')
cy.contains('Create new tag').click()
@@ -390,7 +381,8 @@ describe('Studyprogramme overview', () => {
cy.go('back')
cy.go('back')
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(10000)
deleteTag(name)
cy.contains('Student statistics').click()
+3
View File
@@ -339,6 +339,8 @@ const doctoralSchoolMap = {
const opetusVaradekaani = 'hy-varadekaanit-opetus'
const universityServices = 'hy-ypa-opa-dojo'
const dekaaniFacultyMap = {
'hy-ttdk-dekanaatti': 'teologinen',
'hy-oiktdk-dekanaatti': 'oikeustieteellinen',
@@ -411,6 +413,7 @@ const relevantIAMs = []
.concat(Object.keys(kosuFacultyMap))
.concat(Object.keys(dekaaniFacultyMap))
.concat(opetusVaradekaani)
.concat(universityServices)
.concat(doctoralIams)
.concat(Object.keys(doctoralSchoolMap))
.concat(universityWideGroups)
@@ -0,0 +1,26 @@
const { BIGINT, STRING, DATE, ARRAY } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.createTable('open_uni_population_searches', {
id: {
type: BIGINT,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: BIGINT,
},
name: {
type: STRING,
allowNull: false,
},
course_codes: {
type: ARRAY(STRING),
},
created_at: DATE,
updated_at: DATE,
})
},
down: () => {},
}
@@ -0,0 +1,17 @@
module.exports = {
up: queryInterface => {
return queryInterface.bulkInsert('access_groups', [
{
group_code: 'openUniSearch',
group_info: 'grants access to custom open uni course searches',
},
])
},
down: queryInterface => {
return queryInterface.bulkDelete('access_groups', [
{
group_code: 'openUniSearch',
},
])
},
}
@@ -121,6 +121,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
},
})
const OpenUniPopulationSearch = sequelizeKone.define(
'open_uni_population_searches',
{
id: {
primaryKey: true,
type: Sequelize.BIGINT,
autoIncrement: true,
},
userId: {
type: Sequelize.BIGINT,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
courseCodes: {
type: Sequelize.ARRAY(Sequelize.STRING),
},
},
{ underscored: true, timestamps: true }
)
const StudyGuidanceGroupTag = sequelizeKone.define(
'study_guidance_group_tags',
{
@@ -162,6 +184,7 @@ module.exports = {
ThesisTypeEnums,
MandatoryCourseLabels,
CustomPopulationSearch,
OpenUniPopulationSearch,
sequelizeKone,
ExcludedCourse,
StudyGuidanceGroupTag,
+3 -1
View File
@@ -1,4 +1,4 @@
const { Model, STRING, DATE, INTEGER } = require('sequelize')
const { Model, STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
const { dbConnections } = require('../database/connection')
class Student extends Model {}
@@ -16,6 +16,7 @@ Student.init(
creditcount: { type: INTEGER },
dateofuniversityenrollment: { type: DATE },
email: { type: STRING },
secondary_email: { type: STRING },
national_student_number: { type: STRING },
country_fi: { type: STRING },
country_sv: { type: STRING },
@@ -25,6 +26,7 @@ Student.init(
home_country_en: { type: STRING },
gender_code: { type: INTEGER },
sis_person_id: { type: STRING },
dissemination_info_allowed: { type: BOOLEAN },
createdAt: {
type: DATE,
},
+2
View File
@@ -30,6 +30,7 @@ const trends = require('./routes/trends')
const programmeModules = require('./routes/programmeModules')
const studyGuidanceGroups = require('./routes/studyGuidanceGroups')
const studyProgramme = require('./routes/studyProgramme')
const customOpenUniSearch = require('./routes/customOpenUniSearch')
const initializeSentry = require('./util/sentry')
const errorMiddleware = require('./middleware/errorMiddleware')
@@ -57,6 +58,7 @@ module.exports = (app, url) => {
app.use(url, tags)
app.use(url, programmeModules)
app.use(url, studyProgramme)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/faculties`, auth.roles(['facultyStatistics']), faculties)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
+6
View File
@@ -1,6 +1,7 @@
const router = require('express').Router()
const crypto = require('crypto')
const Course = require('../services/courses')
const { faculties } = require('../services/organisations')
const { validateParamLength } = require('../util')
router.get('/v2/coursesmulti', async (req, res) => {
@@ -43,4 +44,9 @@ router.get('/v3/courseyearlystats', async (req, res) => {
}
})
router.get('/faculties-only', async (_req, res) => {
const facultyList = await faculties()
res.json(facultyList)
})
module.exports = router
@@ -0,0 +1,74 @@
const router = require('express').Router()
var moment = require('moment-timezone')
const { getCustomOpenUniCourses } = require('../services/openUni/openUniStats')
const {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
} = require('../services/openUni/openUniManageSearches')
router.get('/', async (req, res) => {
const courseCodes = JSON.parse(req.query?.courselist) || []
const startdate = req.query?.startdate ? req.query?.startdate : moment('01-08-2017 00:00:00', 'DD-MM-YYYY')
const enddate = req.query?.enddate ? req.query?.enddate : moment().endOf('day')
if (!Array.isArray(courseCodes)) return res.status(400).json({ error: 'Courses must be of type array' })
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startdate, enddate)
return res.json(customOpenUniSearches)
})
router.get('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
router.post('/searches', async (req, res) => {
const courseCodes = req.body?.courselist || []
const userId = req.user.id
const name = req.body?.name
if (!name) return res.status(400).json({ error: 'Name missing' })
if (courseCodes && !Array.isArray(courseCodes))
return res.status(400).json({ error: 'Course codes must be type of array' })
const createdSearch = await createNewSearch(userId, name, courseCodes)
if (!createdSearch) return res.status(400).json({ error: '' })
return res.status(201).json({
id: createdSearch.id,
userId: createdSearch.userId,
name: createdSearch.name,
courseList: createdSearch.courseCodes,
updatedAt: createdSearch.updatedAt,
})
})
router.put('/searches/:id', async (req, res) => {
const id = req.params?.id
const courseCodes = req.body?.courselist || []
const userId = req.user.id
if (!id || !userId) return res.status(422).end()
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) return res.status(404).json({ error: `Open uni search could not be found` })
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
name: updatedSearch.name,
courseList: updatedSearch.courseCodes,
updatedAt: updatedSearch.updatedAt,
})
})
router.delete('/searches/:id', async (req, res) => {
const id = req.params?.id
const userId = req.user.id
if (!id || !userId) return res.status(422).end()
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) return res.status(404).json({ error: `Open uni search could not be found` })
return res.json(id)
})
module.exports = router
+4 -1
View File
@@ -94,7 +94,10 @@ router.get('/:id/creditstats', async (req, res) => {
const programmes = await getProgrammes(code, programmeFilter)
if (!programmes) return res.status(422).end()
let updatedStats = await combineFacultyCredits(code, programmes.data, yearType, specialGroups)
// list of all programmes is also needed in classification of credits
const allProgrammes = programmeFilter === 'ALL_PROGRAMMES' ? programmes : await getProgrammes(code, 'ALL_PROGRAMMES')
let updatedStats = await combineFacultyCredits(code, programmes.data, allProgrammes.data, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setCreditStats(updatedStats, yearType, programmeFilter, specialGroups)
}
@@ -95,6 +95,7 @@ const getFacultyRegularCreditStats = async ({
nonDegree.tableStats[attainmentYear] += credits || 0
}
})
if (
majors.graphStats.every(year => year === 0) &&
facultyNonMajors.graphStats.every(year => year === 0) &&
@@ -191,7 +192,7 @@ const getProgrammeCredits = async (code, yearType, specialGroups, facultyProgram
return updatedStats
}
const combineFacultyCredits = async (faculty, programmes, yearType, specialGroups) => {
const combineFacultyCredits = async (faculty, programmes, allProgrammes, yearType, specialGroups) => {
let counts = {}
let years = []
const titles =
@@ -216,7 +217,7 @@ const combineFacultyCredits = async (faculty, programmes, yearType, specialGroup
titles,
}
const progCodes = programmes.map(p => p.code)
const progCodes = allProgrammes.map(p => p.code)
for (const prog of programmes) {
const data = await getProgrammeCredits(prog.code, yearType, specialGroups, progCodes)
if (data) {
@@ -237,6 +238,7 @@ const combineFacultyCredits = async (faculty, programmes, yearType, specialGroup
})
}
}
// save table stats and graph stats
years.forEach(year => {
allCredits.tableStats.push([year, ...counts[year]])
@@ -115,12 +115,12 @@ const combineFacultyStudentProgress = async (faculty, programmes, specialGroups,
[0, null],
]
}
if (months >= 20) {
if (months >= 24) {
masterlimits = [
[Math.ceil(20 * (60 / 12)), null],
[Math.ceil(20 * (45 / 12)), Math.ceil(20 * (60 / 12))],
[Math.ceil(20 * (30 / 12)), Math.ceil(20 * (45 / 12))],
[Math.ceil(20 * (15 / 12)), Math.ceil(20 * (30 / 12))],
[Math.ceil(24 * (60 / 12)), null],
[Math.ceil(24 * (45 / 12)), Math.ceil(24 * (60 / 12))],
[Math.ceil(24 * (30 / 12)), Math.ceil(24 * (45 / 12))],
[Math.ceil(24 * (15 / 12)), Math.ceil(24 * (30 / 12))],
[1, Math.ceil(20 * (15 / 12))],
[0, null],
]
@@ -0,0 +1,47 @@
const mapOpenCredits = credit => {
const { course_code, attainment_date, student_studentnumber } = credit
return {
course_code,
attainment_date,
student_studentnumber,
}
}
const mapStundentInfo = student => {
const { email, secondary_email, studentnumber, dissemination_info_allowed } = student
return {
studentnumber,
email,
secondary_email,
dissemination_info_allowed,
}
}
const mapOpenEnrollments = enrollment => {
const { studentnumber, course_code, state, enrollment_date_time } = enrollment
return {
enrollmentStudentnumber: studentnumber,
course_code,
state,
enrollment_date_time,
}
}
const mapStudyRights = studyright => {
const { enddate, startdate, student } = studyright
return {
startdate,
enddate,
studyrightStudentnumber: student.studentnumber,
}
}
const mapCourseInfo = course => {
const { code, name } = course
return {
label: code,
name,
}
}
module.exports = { mapOpenCredits, mapOpenEnrollments, mapStundentInfo, mapStudyRights, mapCourseInfo }
@@ -0,0 +1,65 @@
const logger = require('../../util/logger')
const {
getOpenUniSearchesByUser,
createOpenUniPopulationSearch,
updateOpenUniPopulationSearch,
deleteOpenUniSearch,
} = require('./openUniSearches')
const getOpenUniSearches = async userId => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches === undefined) return []
const foundSearches = savedSearches.reduce(
(res, search) => [
...res,
{
id: search.id,
userId: search.userId,
name: search.name,
courseList: search.courseCodes,
updatedAt: search.updatedAt,
},
],
[]
)
return foundSearches
}
const createNewSearch = async (userId, name, courseCodes) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches !== undefined) {
const searchNames = savedSearches.map(search => search.name)
if (searchNames.includes(name)) return null
}
try {
const created = await createOpenUniPopulationSearch(userId, name, courseCodes)
if (!created) return null
return created
} catch (e) {
logger.error(`Couldn't create new open uni search instance: ${e}`)
return null
}
}
const deleteSearch = async (userId, id) => {
try {
const deleted = await deleteOpenUniSearch(userId, id)
if (!deleted) return null
return deleted
} catch (e) {
logger.error(`Couldn't delete open uni search instance: ${e}`)
return null
}
}
const updateSearch = async (userId, id, courseCodes) => {
try {
const updated = await updateOpenUniPopulationSearch(userId, id, courseCodes)
if (!updated) return null
return updated
} catch (e) {
logger.error(`Couldn't update open uni search instance: ${e}`)
}
}
module.exports = { getOpenUniSearches, createNewSearch, deleteSearch, updateSearch }
@@ -0,0 +1,137 @@
const { Credit, Enrollment, Studyright, Student, Course } = require('../../models')
const { OpenUniPopulationSearch } = require('../../models/models_kone')
const {
mapOpenCredits,
mapOpenEnrollments,
mapStundentInfo,
mapStudyRights,
mapCourseInfo,
} = require('./openUniHelpers')
const { Op } = require('sequelize')
// 1. iteration: time is hardcoded. Check hyväksytty grades
const getCredits = async (courseCodes, startdate) =>
(
await Credit.findAll({
where: {
course_code: {
[Op.in]: courseCodes,
},
is_open: true,
grade: {
[Op.in]: ['1', '2', '3', '4', '5', 'Hyv.', 'hyv', 'HT', 'TT'],
},
attainment_date: {
[Op.gte]: startdate,
},
},
})
).map(mapOpenCredits)
const getStudentInfo = async students =>
(
await Student.findAll({
where: {
studentnumber: {
[Op.in]: students,
},
},
})
).map(mapStundentInfo)
const getEnrollments = async (courseCodes, startdate, enddate) =>
(
await Enrollment.findAll({
where: {
course_code: {
[Op.in]: courseCodes,
},
is_open: true,
enrollment_date_time: {
[Op.and]: {
[Op.lte]: enddate,
[Op.gte]: startdate,
},
},
},
})
).map(mapOpenEnrollments)
const getCourseNames = async courseCodes =>
(
await Course.findAll({
attributes: ['code', 'name'],
where: {
code: {
[Op.in]: courseCodes,
},
},
})
).map(mapCourseInfo)
const getStudyRights = async students =>
(
await Studyright.findAll({
include: [
{
model: Student,
attributes: ['studentnumber'],
required: true,
},
],
where: {
student_studentnumber: {
[Op.in]: students.length > 0 ? students : { [Op.not]: null },
},
},
})
).map(mapStudyRights)
const getOpenUniSearchesByUser = async userId => {
return await OpenUniPopulationSearch.findAll({
where: {
userId,
},
})
}
const createOpenUniPopulationSearch = async (userId, name, courseCodes) => {
return await OpenUniPopulationSearch.create({
userId,
name,
courseCodes,
})
}
const updateOpenUniPopulationSearch = async (userId, id, courseCodes) => {
const searchToUpdate = await OpenUniPopulationSearch.findOne({
where: {
userId: userId,
id: id,
},
})
if (!searchToUpdate) return null
return await searchToUpdate.update({ courseCodes })
}
const deleteOpenUniSearch = async (userId, id) => {
return await OpenUniPopulationSearch.destroy({
where: {
userId: userId,
id: id,
},
})
}
module.exports = {
getCredits,
getEnrollments,
getStudyRights,
getStudentInfo,
getCourseNames,
getOpenUniSearchesByUser,
createOpenUniPopulationSearch,
updateOpenUniPopulationSearch,
deleteOpenUniSearch,
}
@@ -0,0 +1,79 @@
const moment = require('moment')
const { getCredits, getStudyRights, getEnrollments, getStudentInfo, getCourseNames } = require('./openUniSearches')
const uniq = objects => [...new Set(objects)]
const getCustomOpenUniCourses = async (courseCodes, startdate, enddate) => {
const ayCourseCodes = courseCodes.map(courseCode => 'AY' + courseCode)
const allCourseCodes = courseCodes.concat(ayCourseCodes)
const allCredits = await getCredits(allCourseCodes, startdate)
const allEnrollments = await getEnrollments(allCourseCodes, startdate, enddate)
const students = uniq(allEnrollments.map(enrollment => enrollment.enrollmentStudentnumber))
const courses = await getCourseNames(courseCodes)
const allStudyrights = await getStudyRights(students)
const studentInfo = await getStudentInfo(students)
// Filter out current studyrights:
// Case 1: Both startdate and enddate are outside of the given interval
// Case 2: Startdate is inside of the given interval and enddate is outside
// Case 3: Startdate is before the interval start and the enddate is within the interval
const studentsWithCurrentStudyRight = allStudyrights
.filter(
right =>
moment(right.startdate).isBetween(startdate, moment()) ||
(moment(right.startdate).isSameOrBefore(startdate) && moment(right.enddate).isSameOrAfter(moment())) ||
moment(right.enddate).isSameOrBefore(moment())
)
.map(right => right.studyrightStudentnumber)
const uniqueStudentsWithCurrentStudyRight = uniq(studentsWithCurrentStudyRight)
const studentStats = {}
const getEmptyCourseInfo = () => {
return {
enrolledPassed: null,
enrolledNotPassed: [],
}
}
for (const { studentnumber, email, secondary_email, dissemination_info_allowed } of studentInfo) {
// Check if the student has existing studyright: if yes, then stop here
if (!uniqueStudentsWithCurrentStudyRight.includes(studentnumber)) {
if (!(studentnumber in studentStats)) {
studentStats[studentnumber] = {
courseInfo: courseCodes.reduce((acc, code) => ({ ...acc, [code]: getEmptyCourseInfo() }), {}),
email: email,
secondaryEmail: secondary_email,
disseminationInfoAllowed: dissemination_info_allowed,
passedTotal: 0,
enrolledTotal: 0,
}
}
const passedCourses = []
const unfinishedCourses = []
for (const { course_code, attainment_date, student_studentnumber } of allCredits) {
if (student_studentnumber === studentnumber) {
const courseCode = course_code.replace('AY', '')
studentStats[studentnumber].courseInfo[courseCode].enrolledPassed = attainment_date
if (!passedCourses.includes(courseCode)) {
passedCourses.push(courseCode)
}
}
}
for (const { enrollmentStudentnumber, course_code, enrollment_date_time } of allEnrollments) {
if (enrollmentStudentnumber === studentnumber) {
const courseCode = course_code.replace('AY', '')
if (!studentStats[studentnumber].courseInfo[courseCode].enrolledPassed && studentnumber === studentnumber) {
studentStats[studentnumber].courseInfo[courseCode].enrolledNotPassed.push(enrollment_date_time)
if (!unfinishedCourses.includes(courseCode)) unfinishedCourses.push(courseCode)
}
}
}
studentStats[studentnumber].passedTotal = passedCourses.length
studentStats[studentnumber].enrolledTotal = unfinishedCourses.length
}
}
const openUniStats = { students: studentStats, courses: courses }
return openUniStats
}
module.exports = { getCustomOpenUniCourses }
+8 -1
View File
@@ -13,6 +13,7 @@ const courseStatisticsGroup = 'grp-oodikone-basic-users'
const facultyStatisticsGroup = 'grp-oodikone-users'
const hyOneGroup = 'hy-one'
const toskaGroup = 'grp-toska'
const openUniGroup = 'hy-ypa-opa-dojo'
// Max 25 users can be stored in the cache
const userDataCache = new LRU({
@@ -272,7 +273,13 @@ const getUser = async ({ username, name, email, iamGroups, sisId }) => {
newAccessGroups.push('facultyStatistics')
if (iamGroups.includes(hyOneGroup) || currentAccessGroups.includes('teachers')) newAccessGroups.push('teachers')
if (iamGroups.includes(toskaGroup) || currentAccessGroups.includes('admin')) newAccessGroups.push('admin')
if (_.difference(newAccessGroups, currentAccessGroups).length > 0) {
if (iamGroups.includes(openUniGroup)) newAccessGroups.push('openUniSearch')
// In the difference method "the order and references of result values are determined by the first array." https://lodash.com/docs/4.17.15#difference
// Both directions needs to be checked in order to update roles when the access should no longer exists.
if (
_.difference(newAccessGroups, currentAccessGroups).length > 0 ||
_.difference(currentAccessGroups, newAccessGroups).length > 0
) {
userFromDb.setAccessgroup(
(
await AccessGroup.findAll({
@@ -0,0 +1,190 @@
/* eslint-disable */
const { Credit, Enrollment, Studyright } = require('../models')
const moment = require('moment')
const { Op } = require('sequelize')
const hasValidStudyright = (studyrights, date) => {
const within = (right, date) => {
const first = moment(right.startdate)
const last = moment(right.enddate)
return first.isSameOrBefore(date) && last.isSameOrAfter(date)
}
return studyrights.find(r => within(r, date))
}
const studyrightsOf = async student => {
const rights = await Studyright.findAll({
where: {
student_studentnumber: student,
},
})
return rights
}
const uniq = objects => [...new Set(objects)]
const courseInfo = async course_code => {
const credits = await Credit.findAll({
where: {
course_code,
is_open: true,
grade: {
[Op.in]: ['1', '2', '3', '4', '5', 'Hyv.'],
},
attainment_date: {
[Op.gte]: moment('8-1-2020', 'MM-DD-YYYY').toDate(),
},
},
})
const enrollments = await Enrollment.findAll({
where: {
course_code,
is_open: true,
enrollment_date_time: {
[Op.lte]: moment('8-1-2022', 'MM-DD-YYYY').toDate(),
[Op.gte]: moment('8-1-2020', 'MM-DD-YYYY').toDate(),
},
},
})
let avoin_credited = []
for (let credit of credits) {
const { attainment_date, student_studentnumber: studentnumber } = credit
const rights = await studyrightsOf(studentnumber)
if (!hasValidStudyright(rights, attainment_date)) {
avoin_credited.push(studentnumber)
}
}
let avoin_enrolled = []
for (let enrollment of enrollments) {
const { enrollment_date_time, studentnumber } = enrollment
const rights = await studyrightsOf(studentnumber)
if (!hasValidStudyright(rights, enrollment_date_time)) {
avoin_enrolled.push(studentnumber)
}
}
avoin_credited = uniq(avoin_credited)
avoin_enrolled = uniq(avoin_enrolled)
return {
credits,
enrollments,
avoin_credited,
avoin_enrolled,
}
}
const unifiedCourseInfo = async course_code => {
const a = await courseInfo(course_code)
const b = await courseInfo('AY' + course_code)
avoin_credited = uniq(a.avoin_credited.concat(b.avoin_credited))
avoin_enrolled = uniq(a.avoin_enrolled.concat(b.avoin_enrolled))
return {
credits: uniq(a.credits.concat(b.credits)),
enrollments: uniq(a.enrollments.concat(b.enrollments)),
avoin_credited,
avoin_enrolled,
only_enrolled: avoin_enrolled.filter(e => !avoin_credited.includes(e)),
}
}
const statsOf = async courses => {
console.log(Object.keys(courses).join('\n'))
console.log()
let students = []
for (let course_code of Object.keys(courses)) {
const { credits, enrollments, avoin_enrolled, avoin_credited, only_enrolled } = await unifiedCourseInfo(course_code)
courses[course_code] = { credits, enrollments, avoin_enrolled, avoin_credited, only_enrolled }
students = uniq(students.concat(avoin_enrolled).concat(avoin_credited))
}
const rows = []
for (let s of students) {
let string = s + ' '
let score = 0
for (let course_code of Object.keys(courses)) {
const { enrollments, avoin_credited, only_enrolled } = courses[course_code]
let course_info = '-'
if (avoin_credited.includes(s)) {
course_info = 'X'
score += 10
}
if (only_enrolled.includes(s)) {
const studentsEnrolments = enrollments.filter(e => e.studentnumber === s)
course_info = '' + studentsEnrolments.length
score += 1
}
string += course_info + ' '
}
const rights = await studyrightsOf(s)
const stRight = hasValidStudyright(rights, new Date('2022-8-1')) ? 'yes' : 'no'
string += stRight
rows.push({ string, score })
}
const byScore = (a, b) => b.score - a.score
console.log(
rows
.sort(byScore)
.map(r => r.string)
.join('\n')
)
}
const main = async () => {
const courses_psyk_a = {
'PSYK-111': null,
'PSYK-121': null,
'PSYK-131': null,
'PSYK-141': null,
'PSYK-181': null,
}
const courses_psyk_p = {
'PSYK-251': null,
'PSYK-221': null,
'PSYK-232': null,
'PSYK-226': null,
'PSYK-241': null,
'PSYK-212': null,
}
const courses_hist = {
'HISK-110': null,
'HISK-120': null,
'HISK-131A': null,
'HISK-131': null,
'HISK-141': null,
'HISK-142': null,
}
await statsOf(courses_psyk_a)
console.log()
console.log()
await statsOf(courses_psyk_p)
console.log()
console.log()
await statsOf(courses_hist)
process.exit()
}
main()
@@ -1,7 +1,8 @@
export default {
Advanced: `**Semesters:** Jos haluaa tarkastella vain syyslukukaudella tai kevätlukukaudella ilmoittautuneita, haluttu ajanjakso valitaan tästä.
Advanced: `**Semesters:** Jos haluaa tarkastella vain syyslukukaudella tai kevätlukukaudella aloittaneita, haluttu ajanjakso valitaan tästä.
**Include:** Koulutusohjelmapopulaatiosta on oletusarvoisesti suodatettu pois vaihto-opiskelijat ja erillisopinto-oikeudella opiskelevat. Näitä ryhmiä voi sisällyttää mukaan lisäämällä ruksin haluamaansa kohtaan.`,
**Include:** Koulutusohjelmapopulaatiosta on oletusarvoisesti suodatettu pois ohjelmasta poissiirtyneet opiskelijat. Heidät voi sisällyttää mukaan lisäämällä ruksin ja noutamalla tulokset uudestaan (Fetch class with new settings).`,
// Koulutusohjelmapopulaatiosta on oletusarvoisesti suodatettu pois vaihto-opiskelijat ja erillisopinto-oikeudella opiskelevat. Näitä ryhmiä voi sisällyttää mukaan lisäämällä ruksin haluamaansa kohtaan.
QueryCard: `**Valitun koulutusohjelmavuosikurssin perustiedot.**
**Sample size** luku sisältää läsnä- ja poissaolevaksi ilmoittautuneet opiskelijat, muttei "excludes"-ryhmiä, jotka voi määritellä "advanced settings" -kohdasta.
@@ -0,0 +1,230 @@
import moment from 'moment'
import React, { useEffect, useState } from 'react'
import Datetime from 'react-datetime'
import { Modal, Form, Button, TextArea } from 'semantic-ui-react'
import qs from 'query-string'
import SearchHistory from 'components/SearchHistory'
import {
useCreateOpenUniCourseSearchMutation,
useDeleteOpenUniCourseSearchMutation,
useUpdateOpenUniCourseSearchMutation,
} from 'redux/openUniPopulations'
const CustomOpenUniSearch = ({ setValues, savedSearches, location, history }) => {
const [modal, setModal] = useState(false)
const [input, setInput] = useState('')
const [searchList, setSearches] = useState(savedSearches)
const [name, setName] = useState('')
const [startdate, setStartdate] = useState(moment('01-08-2017 00:00:00', 'DD-MM-YYYY'))
const [enddate, setEnddate] = useState(moment().endOf('day'))
const [selectedSearchId, setSelectedSearchId] = useState('')
const [updateOpenUniCourseSearch, { isLoading: updateIsLoading, data: updatedData }] =
useUpdateOpenUniCourseSearchMutation()
const [createOpenUniCourseSearch, { isLoading: createIsLoading, data: createdData }] =
useCreateOpenUniCourseSearchMutation()
const [deleteOpenUniCourseSearch, { isLoading: deleteIsLoading, data: deletedData }] =
useDeleteOpenUniCourseSearchMutation()
const parseQueryFromUrl = () => {
const { courseCode, startdate, enddate } = qs.parse(location.search)
let courseCodes = courseCode
if (!Array.isArray(courseCode)) courseCodes = [courseCode]
const query = {
courseList: courseCodes,
startdate: moment(startdate, 'DD-MM-YYYY').toISOString(),
enddate: moment(enddate, 'DD-MM-YYYY').toISOString(),
}
return query
}
useEffect(() => {
if (updatedData) {
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
setSearches(updatedSearches)
}
}, [updatedData])
useEffect(() => {
if (createdData && !createdData.error) {
const newList = searchList.concat(createdData)
setSearches(newList)
}
}, [createdData])
useEffect(() => {
if (deletedData) {
const filteredsearches = searchList.filter(s => s.id !== deletedData)
setSearches(filteredsearches)
}
}, [deletedData])
useEffect(() => {
if (!location.search) {
setValues({})
} else {
const query = parseQueryFromUrl()
setValues(query)
}
}, [location.search])
const pushQueryToUrl = query => {
setImmediate(() => {
const searchString = qs.stringify(query)
history.push({ search: searchString })
})
}
const clearForm = () => {
setInput('')
setName('')
setStartdate(moment('01-08-2017 00-00-00', 'DD-MM-YYYY'))
setEnddate(moment().endOf('day'))
setSelectedSearchId('')
}
const handleClose = () => {
setModal(false)
clearForm()
}
const onClicker = e => {
e.preventDefault()
const courseList = input
.split(',')
.map(code => code.trim().toUpperCase())
.filter(code => code.length > 0)
const query = {
courseCode: courseList,
startdate: moment(startdate).format('DD-MM-YYYY'),
enddate: moment(enddate).format('DD-MM-YYYY'),
}
pushQueryToUrl(query)
handleClose()
}
const onDelete = () => {
deleteOpenUniCourseSearch({ id: selectedSearchId })
clearForm()
}
const onSelectSearch = selectedId => {
if (!selectedId) {
clearForm()
return
}
const { id: selectedSearchId } = selectedId
const selectedSearch = searchList.find(search => search.id === selectedSearchId)
if (selectedSearch) {
setInput(selectedSearch.courseList.join(', '))
setName(selectedSearch.name)
setSelectedSearchId(selectedSearch.id)
}
}
const onSave = () => {
const courseList = input
.split(',')
.map(code => code.trim().toUpperCase())
.filter(code => code.length > 0)
if (selectedSearchId !== '') {
updateOpenUniCourseSearch({ id: selectedSearchId, courseList })
} else {
createOpenUniCourseSearch({ courseList, name })
}
}
return (
<Modal
trigger={
<Button size="small" color="blue" onClick={() => setModal(true)} data-cy="custom-pop-search-button">
Fetch Open Uni Students
</Button>
}
open={modal}
onClose={handleClose}
size="small"
>
<Modal.Content>
<Form>
<h2> Fetch open uni course population</h2>
<Form.Field>
<Form.Field>
<em> Insert name for this population if you wish to save it </em>
<Form.Input
disabled={selectedSearchId !== ''}
value={name}
placeholder="name"
onChange={e => setName(e.target.value)}
/>
</Form.Field>
<em>Insert course code(s)</em>
<TextArea
value={input}
placeholder="TKT12345, PSYK-123"
onChange={e => setInput(e.target.value)}
data-cy="s-no-input"
/>
</Form.Field>
<SearchHistory
header="Saved populations"
items={searchList?.map(s => ({
...s,
text: s.name,
timestamp: s.updatedAt,
params: { id: s.id },
}))}
updateItem={() => null}
handleSearch={onSelectSearch}
/>
<Form.Field>
<em>Select beginning</em>
<Datetime
value={startdate}
onChange={value => setStartdate(value)}
timeFormat={false}
locale="fi"
isValidDate={date => date.isBefore(enddate)}
closeOnSelect
/>
</Form.Field>
<Form.Field>
<em>Select ending for enrollments:</em>
<br />
<em>Attainments are fetched until today.</em>
<Datetime
value={enddate}
onChange={value => setEnddate(value)}
timeFormat={false}
locale="fi"
isValidDate={date => date.isAfter(startdate)}
closeOnSelect
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button
disabled={!name || updateIsLoading || createIsLoading}
loading={updateIsLoading || createIsLoading}
floated="left"
icon="save"
onClick={onSave}
content="Save"
/>
<Button
disabled={!selectedSearchId || deleteIsLoading}
negative
floated="left"
icon="trash"
onClick={onDelete}
content="Delete"
/>
<Button onClick={handleClose}>Cancel</Button>
<Button positive onClick={e => onClicker(e)} data-cy="search-button">
Search population
</Button>
</Modal.Actions>
</Modal>
)
}
export default CustomOpenUniSearch
@@ -0,0 +1,257 @@
import React from 'react'
import moment from 'moment'
import { Loader, Icon } from 'semantic-ui-react'
import { useGetOpenUniCourseStudentsQuery } from 'redux/openUniPopulations'
import SortableTable from 'components/SortableTable'
const OpenUniPopulationResults = ({ fieldValues, language }) => {
const { courseList, startdate, enddate } = fieldValues
const openUniStudentStats = useGetOpenUniCourseStudentsQuery({ courseList, startdate, enddate })
const isFetchingOrLoading = openUniStudentStats.isLoading || openUniStudentStats.isFetching
const isError = openUniStudentStats.isError || (openUniStudentStats.isSuccess && !openUniStudentStats.data)
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
const studentNbrColumn = [
{
key: 'studentnumber-parent',
mergeHeader: true,
// merge: true,
title: 'Student Number',
children: [
{
key: 'studentnumber-child',
title: 'Student Number',
cellProps: {
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
},
getRowVal: s => s.studentnumber,
getRowContent: s => s.studentnumber,
child: true,
},
],
},
]
const statisticColumns = [
{
key: 'passed',
title: 'Passed',
cellProps: {
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
},
headerProps: { title: 'Passed' },
getRowVal: s => s.passedTotal,
getRowContent: s => s.passedTotal,
child: true,
},
{
key: 'unfinished',
title: 'Unfinished',
headerProps: { title: 'Unfinished' },
getRowVal: s => s.enrolledTotal,
getRowContent: s => s.enrolledTotal,
cellProps: {
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
},
child: true,
},
]
const getDisseminationInfoAllowed = s => {
if (s.disseminationInfoAllowed === null) return 'unknown'
if (s.disseminationInfoAllowed) return 'yes'
return 'no'
}
const informationColumns = [
{
key: 'disseminationInfoAllowed',
title: 'Marketing Allowed',
getRowVal: s => getDisseminationInfoAllowed(s),
getRowContent: s => getDisseminationInfoAllowed(s),
headerProps: { title: 'Marketing Allowed' },
child: true,
cellProps: {
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
},
},
{
key: 'email-child',
title: 'Email',
getRowVal: s => (s.email ? s.email : ''),
getRowContent: s => (s.email ? s.email : ''),
headerProps: { title: 'Email' },
child: true,
},
{
key: 'secondary_email-child',
title: 'Secondary Email',
getRowVal: s => (s.secondaryEmail ? s.secondaryEmail : ''),
getRowContent: s => (s.secondaryEmail ? s.secondaryEmail : ''),
headerProps: { title: 'Secondary Email' },
child: true,
},
]
const findRowContent = (s, courseCode) => {
if (s.courseInfo[courseCode] === undefined) return null
if (s.courseInfo[courseCode].notEnrolled) return null
if (s.courseInfo[courseCode].enrolledNotPassed.length > 0) return <Icon fitted name="times" color="red" />
if (s.courseInfo[courseCode].enrolledPassed !== null) return <Icon fitted name="check" color="green" />
return null
}
const findRowValue = (s, courseCode) => {
if (s.courseInfo[courseCode] === undefined) return ''
if (s.courseInfo[courseCode].enrolledNotPassed.length === 0 && s.courseInfo[courseCode].enrolledPassed === null)
return ''
if (s.courseInfo[courseCode].enrolledNotPassed.length > 0)
return s.courseInfo[courseCode].enrolledNotPassed
.map((dat, idx) => `Enrollment ${idx + 1}: ${moment(dat).format('DD-MM-YYYY')}`)
.join(' ')
if (s.courseInfo[courseCode].enrolledPassed !== null)
return `Passed: ${moment(s.courseInfo[courseCode].enrolledPassed).format('DD-MM-YYYY')}`
return ''
}
const findProp = (s, courseCode) => {
const propObj = {
title: '',
style: {
verticalAlign: 'middle',
textAlign: 'center',
},
}
if (s.courseInfo[courseCode] === undefined) return propObj
if (s.courseInfo[courseCode].enrolledNotPassed.length === 0 && s.courseInfo[courseCode].enrolledPassed === null)
return propObj
if (s.courseInfo[courseCode].enrolledNotPassed.length > 0)
return {
...propObj,
title: `Enrollments: ${s.courseInfo[courseCode].enrolledNotPassed
.map(dat => moment(dat).format('DD-MM-YYYY'))
.join(', ')}`,
}
if (s.courseInfo[courseCode].enrolledPassed !== null)
return { ...propObj, title: `Passed: ${moment(s.courseInfo[courseCode].enrolledPassed).format('DD-MM-YYYY')}` }
return { ...propObj, title: '' }
}
const unOrderedlabels = openUniStudentStats?.data.courses
const labelsToCourses = [...unOrderedlabels].sort((a, b) => a.label.localeCompare(b.label))
const columns = []
columns.push(
{
key: 'general',
title: <b>Labels:</b>,
textTitle: null,
parent: true,
children: studentNbrColumn,
},
{
key: 'Courses',
title: <b>Courses:</b>,
textTitle: null,
parent: true,
children: labelsToCourses.map(course => ({
key: `${course.label}-${course.name[language]}`,
title: (
<div style={{ maxWidth: '15em', whiteSpace: 'normal', overflow: 'hidden', width: 'max-content' }}>
<div>{course.label}</div>
{/* <div style={{ color: 'gray', fontWeight: 'normal' }}>{course.name[language]}</div> */}
</div>
),
textTitle: `${course.label}-${course.name[language]}`,
vertical: false,
forceToolsMode: 'dangling',
cellProps: s => findProp(s, course.label),
headerProps: { title: `${course.label}-${course.name[language]}` },
getRowVal: s => {
return findRowValue(s, course.label)
},
getRowContent: s => {
return findRowContent(s, course.label)
},
child: true,
childOf: course.label,
code: course.label,
})),
},
{
key: 'statistics',
title: <b>Total of:</b>,
textTitle: null,
parent: true,
children: statisticColumns,
},
{
key: 'information',
title: <b>Information:</b>,
textTitle: null,
parent: true,
children: informationColumns,
}
)
const data = Object.keys(openUniStudentStats?.data.students).reduce(
(acc, student) => [
...acc,
{
studentnumber: student,
courseInfo: { ...openUniStudentStats?.data.students[student].courseInfo },
email: openUniStudentStats?.data.students[student].email,
secondaryEmail: openUniStudentStats?.data.students[student].secondaryEmail,
disseminationInfoAllowed: openUniStudentStats?.data.students[student].disseminationInfoAllowed,
enrolledTotal: openUniStudentStats?.data.students[student].enrolledTotal,
passedTotal: openUniStudentStats?.data.students[student].passedTotal,
},
],
[]
)
return (
<div style={{ paddingBottom: '50px' }}>
{isFetchingOrLoading ? (
<Loader active style={{ marginTop: '15em' }} />
) : (
<div style={{ display: 'flex', paddingBottom: '10px' }}>
<div style={{ maxHeight: '70vh', width: '100%' }}>
{courseList.length > 0 && (
<SortableTable
title={`Open Uni Student Population (${
Object.keys(openUniStudentStats?.data.students).length
} students)`}
getRowKey={s => s.studentNumber}
tableProps={{
celled: true,
compact: 'very',
padded: false,
collapsing: true,
basic: true,
striped: true,
singleLine: true,
textAlign: 'center',
}}
columns={columns}
data={data}
/>
)}
</div>
</div>
)}
</div>
)
}
export default OpenUniPopulationResults
@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react'
import { withRouter, useLocation, useHistory } from 'react-router-dom'
import { Message, Icon, Loader } from 'semantic-ui-react'
import moment from 'moment'
import { useGetSavedSearchesQuery } from 'redux/openUniPopulations'
import OpenUniPopulationResults from './OpenUniPopulationResults'
import { useTitle } from '../../common/hooks'
import useLanguage from '../LanguagePicker/useLanguage'
import CustomOpenUniSearch from './CustomOpenUniSearch'
import TSA from '../../common/tsa'
const CustomOpenUniPopulation = () => {
useTitle('Custom open uni population')
const [fieldValues, setValues] = useState({})
const { language } = useLanguage()
const location = useLocation()
const history = useHistory()
const savedSearches = useGetSavedSearchesQuery()
const isFetchingOrLoading = savedSearches.isLoading || savedSearches.isFetching
const isError = savedSearches.isError || (savedSearches.isSuccess && !savedSearches.data)
useEffect(() => {
if (fieldValues && fieldValues.courseList?.length > 0) {
TSA.Influx.sendEvent({
group: 'Custom Open Uni Usage',
name: 'open uni populations',
label: 'openUniPopulations',
value: 1,
})
}
}, [fieldValues])
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
return (
<div className="segmentContainer">
<Message style={{ maxWidth: '800px', fontSize: '16px' }}>
<Message.Header>Open uni student population</Message.Header>
<p>
Here you can create custom population using a list of courses. Clicking the blue custom population button will
open a modal where you can enter a list of courses.
<br />
<Icon fitted name="check" color="green" />: Student passed the course.
<br />
<Icon fitted name="times" color="red" />: Student has enrolled but has not passed the course.
<br />
<b>Empty cell</b>: Student has no enrollments for the course.
<br />
View under progress.
</p>
</Message>
<CustomOpenUniSearch
setValues={setValues}
savedSearches={savedSearches.data}
location={location}
history={history}
/>
<div style={{ paddingTop: '25px', paddingBottom: '10px', fontSize: '20px' }}>
{fieldValues && fieldValues.courseList?.length > 0 && (
<p>
<span style={{ color: '#484848' }}>Beginning of the search for all fields:</span>
<span style={{ paddingLeft: '10px', fontWeight: '600' }}>
{moment(fieldValues.startdate).format('DD.MM.YYYY')}
</span>
<br />
<span style={{ color: '#484848' }}>End of the search for enrollments:</span>
<span style={{ paddingLeft: '30px', fontWeight: '600' }}>
{moment(fieldValues.enddate).format('DD.MM.YYYY')}
</span>
</p>
)}
</div>
<div style={{ paddingTop: '25px' }}>
{fieldValues && fieldValues.courseList?.length > 0 && (
<OpenUniPopulationResults fieldValues={fieldValues} language={language} />
)}
</div>
</div>
)
}
export default withRouter(CustomOpenUniPopulation)
@@ -100,10 +100,14 @@ const FacultyStudentDataTable = ({
? programmeNames[programme][language]
: programmeNames[programme].fi}
</b>
{(requiredRights.rights?.includes(programme) ||
{(requiredRights.rights?.includes(programmeNames[programme].code) ||
requiredRights.isAdmin ||
requiredRights.IAMrights?.includes(programme)) && (
<PopulationLink studyprogramme={programme} year={year} years={calendarYears} />
requiredRights.IAMrights?.includes(programmeNames[programme].code)) && (
<PopulationLink
studyprogramme={programmeNames[programme].code}
year={year}
years={calendarYears}
/>
)}
</Table.Cell>
{programmeStats[programme][year].map((value, valIdx) => {
@@ -199,7 +199,7 @@ const BarChart = ({
if (!facultyGraph) config.title.text = `Year ${year} by ${label.toLowerCase()}`
return (
<div>
<div className={`${facultyGraph ? 'faculty' : 'programmes'}-graph`}>
<ReactHighcharts config={config} />
</div>
)
@@ -33,10 +33,8 @@ const GraduationTimes = ({
}
return (
<>
<Divider data-cy={`Section-${level}`} horizontal>
{title}
</Divider>
<div className={`graduation-times-${level}`} data-cy={`Section-${level}`}>
<Divider horizontal>{title}</Divider>
<div>
{level === 'bcMsCombo' && groupBy === 'byStartYear' && (
<div className="graduations-message">
@@ -86,7 +84,7 @@ const GraduationTimes = ({
)}
</div>
</div>
</>
</div>
)
}
@@ -32,7 +32,9 @@ const Header = ({ children, actions, contextItems }) => {
<Dropdown position="bottom center" direction="left" icon="ellipsis vertical">
<Dropdown.Menu>
{contextItems.map(({ label, onClick }) => (
<Dropdown.Item onClick={onClick}>{label}</Dropdown.Item>
<Dropdown.Item key={label} onClick={onClick}>
{label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
@@ -30,9 +30,12 @@ const allNavigationItems = {
reqRights: ['studyGuidanceGroups'],
},
customPopulations: {
path: '/custompopulation',
key: 'customPopulation',
label: 'Custom populations',
items: [
{ path: '/custompopulation', key: 'customSearch', label: 'Search by Studentnumbers' },
{ path: '/openunipopulation', key: 'openUniSearch', label: 'Fetch Open Uni Students by Courses' },
],
},
updater: { path: '/updater', key: 'updater', label: 'Updater', reqRights: ['admin'] },
feedback: { path: '/feedback', key: 'feedback', label: 'Give feedback' },
@@ -50,8 +53,11 @@ const NavigationBar = () => {
if (key === 'populations') {
if (!isAdmin && rights.length === 0 && iamRights.length === 0) return
}
if (key === 'students' || key === 'customPopulations') {
if (key === 'students') {
if (!checkUserAccess(['admin', 'studyGuidanceGroups'], roles) && rights.length === 0) return
}
if (key === 'customPopulations') {
if (!checkUserAccess(['admin', 'studyGuidanceGroups', 'openUniSearch'], roles) && rights.length === 0) return
} else if (key === 'courseStatistics') {
if (!checkUserAccess(['courseStatistics', 'admin'], roles) && rights.length === 0) return
} else if (key === 'faculty') {
@@ -75,23 +81,33 @@ const NavigationBar = () => {
</span>
</Menu.Item>
)
const showSearch = item => {
if (item.key === 'class' || item.key === 'overview') return true
if (checkUserAccess(['openUniSearch', 'admin'], roles) && item.key === 'openUniSearch') return true
if ((checkUserAccess(['studyGuidanceGroups', 'admin'], roles) || rights.length > 0) && item.key === 'customSearch')
return true
return false
}
const renderNavigationRoutes = () =>
Object.values(visibleNavigationItems).map(({ items, path, key, label, tag }) =>
items ? (
<Menu.Item as={Dropdown} key={`menu-item-drop-${key}`} tabIndex="-1" text={label} data-cy={`navbar-${key}`}>
<Dropdown.Menu>
{items.map(i => (
<Dropdown.Item
as={NavLink}
key={`menu-item-${i.path}`}
to={i.path}
tabIndex="-1"
data-cy={`navbar-${i.key}`}
>
{i.label}
</Dropdown.Item>
))}
{items.map(
i =>
showSearch(i) && (
<Dropdown.Item
as={NavLink}
key={`menu-item-${i.path}`}
to={i.path}
tabIndex="-1"
data-cy={`navbar-${i.key}`}
>
{i.label}
</Dropdown.Item>
)
)}
</Dropdown.Menu>
</Menu.Item>
) : (
@@ -30,7 +30,8 @@ const mapCourseData = course => ({
})
const GradeDistribution = ({ flat, onlyIamRights }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { modules, courseStatistics, onGoToCourseStatisticsClick, toggleGroupExpansion, expandedGroups } =
UsePopulationCourseContext()
const columns = useMemo(() => {
const columns = [
@@ -147,7 +148,13 @@ const GradeDistribution = ({ flat, onlyIamRights }) => {
return (
<>
<SortableTable title="Grade distribution of courses" data={data} columns={columns} />
<SortableTable
title="Grade distribution of courses"
data={data}
columns={columns}
toggleGroupExpansion={toggleGroupExpansion}
expandedGroups={expandedGroups}
/>
</>
)
}
@@ -27,7 +27,8 @@ const createModuleAggregateRow = ({ definition, children }) => ({
})
const PassFailEnrollments = ({ flat, onlyIamRights }) => {
const { modules, courseStatistics, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { modules, courseStatistics, onGoToCourseStatisticsClick, toggleGroupExpansion, expandedGroups } =
UsePopulationCourseContext()
const columns = useMemo(() => {
const columns = [
@@ -236,6 +237,8 @@ const PassFailEnrollments = ({ flat, onlyIamRights }) => {
title="Pass and fail statistics of courses with course enrollment details"
data={data}
columns={columns}
toggleGroupExpansion={toggleGroupExpansion}
expandedGroups={expandedGroups}
/>
</>
)
@@ -35,7 +35,7 @@ const yearColumn = (year, cumulative) => ({
})
const PassingSemesters = ({ onlyIamRights }) => {
const { modules, onGoToCourseStatisticsClick } = UsePopulationCourseContext()
const { modules, onGoToCourseStatisticsClick, toggleGroupExpansion, expandedGroups } = UsePopulationCourseContext()
const [cumulativeStats, setCumulativeStats] = useState(false)
const columns = useMemo(() => {
@@ -154,6 +154,8 @@ const PassingSemesters = ({ onlyIamRights }) => {
}
columns={columns}
data={data}
toggleGroupExpansion={toggleGroupExpansion}
expandedGroups={expandedGroups}
/>
</div>
)
@@ -9,7 +9,8 @@ import { getTextIn } from '../../../common'
import { useStudentNameVisibility } from '../../StudentNameVisibilityToggle'
const Students = ({ filteredStudents }) => {
const { courseStatistics, onGoToCourseStatisticsClick, modules } = UsePopulationCourseContext()
const { courseStatistics, onGoToCourseStatisticsClick, modules, toggleGroupExpansion, expandedGroups } =
UsePopulationCourseContext()
const { visible: namesVisible, toggle: toggleStudentNames } = useStudentNameVisibility()
const [page, setPage] = useState(0)
@@ -181,6 +182,8 @@ const Students = ({ filteredStudents }) => {
}
columns={columns}
data={data}
toggleGroupExpansion={toggleGroupExpansion}
expandedGroups={expandedGroups}
/>
</div>
)
@@ -10,7 +10,6 @@ import './populationCourseStats.css'
import { PopulationCourseContext } from './PopulationCourseContext'
import sendEvent from '../../common/sendEvent'
import GradeDistribution from './GradeDistribution'
import PassFail from './PassFail'
import PassFailEnrollments from './PassFailEnrollments'
import Students from './Students'
import { getTextIn } from '../../common'
@@ -243,16 +242,24 @@ const PopulationCourseStats = props => {
setFilterFields({ ...filterFields, [field]: '' })
}
const toggleGroupExpansion = code => {
const newExpandedGroups = new Set(expandedGroups)
if (newExpandedGroups.has(code)) {
newExpandedGroups.delete(code)
sendAnalytics('Courses of Population collapsed group', code)
const toggleGroupExpansion = (code, close = false, all = null) => {
if (all) {
sendAnalytics('Courses of Population expanded all groups', '')
setExpandedGroups(new Set(all))
} else if (close) {
sendAnalytics('Courses of Population collapsed all groups', '')
setExpandedGroups(new Set())
} else {
newExpandedGroups.add(code)
sendAnalytics('Courses of Population expanded group', code)
const newExpandedGroups = new Set(expandedGroups)
if (newExpandedGroups.has(code)) {
newExpandedGroups.delete(code)
sendAnalytics('Courses of Population collapsed group', code)
} else {
newExpandedGroups.add(code)
sendAnalytics('Courses of Population expanded group', code)
}
setExpandedGroups(newExpandedGroups)
}
setExpandedGroups(newExpandedGroups)
}
const getFilterValue = field => (field in filterFields ? filterFields[field] || '' : '')
@@ -287,23 +294,13 @@ const PopulationCourseStats = props => {
sortCriteria,
reversed,
courses,
toggleGroupExpansion,
expandedGroups,
}
const panes = [
{
menuItem: 'pass/fail',
render: () => (
<Tab.Pane className="menuTab">
<PassFail
expandedGroups={expandedGroups}
toggleGroupExpansion={toggleGroupExpansion}
onlyIamRights={props.onlyIamRights}
/>
</Tab.Pane>
),
},
{
menuItem: 'pass/fail w/ enrollments ',
render: () => (
<Tab.Pane className="menuTab">
<PassFailEnrollments
@@ -45,7 +45,7 @@ const PopulationCourses = ({ query = {}, filteredStudents, selectedStudentsByYea
!mandatoryCourses.pending &&
!queryHasBeenUpdated() &&
!populationSelectedStudentCourses.pending &&
mandatoryCourses.data
mandatoryCourses.data.length > 0
)
fetch(mandatoryCourses.data.map(({ code }) => code))
}, [query, filteredStudents, mandatoryCourses, populationSelectedStudentCourses])
@@ -10,10 +10,8 @@ import useLanguage from '../LanguagePicker/useLanguage'
const PopulationQueryCard = ({ population, query, removeSampleFn, units, tags }) => {
const { language } = useLanguage()
const { uuid, year, semesters, months, studentStatuses, tag, years } = query
const { uuid, year, semesters, months, studentStatuses, tag } = query
const tagname = tag && tags.length > 0 ? tags.find(t => t.tag_id === tag).tagname : ''
const lowestYear = query.years ? Math.min(...query.years.map(year => Number(year))) : null
const highestYear = query.years ? Math.max(...query.years.map(year => Number(year))) : null
const { students } = population
const header = units.map(u => getTextIn(u.name, language)).join(', ')
const semesterList = semesters.map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join(', ')
@@ -23,19 +21,10 @@ const PopulationQueryCard = ({ population, query, removeSampleFn, units, tags })
<>
<Card className="cardContainer">
<Card.Header className="cardHeader">
<div>{header}</div>
<div>Result details</div>
</Card.Header>
<Card.Meta>
<div className="dateItem">
<Icon name="calendar" size="small" />
{!years
? `${semesterList} /
${year}-${Number(year) + 1}, showing ${months} months.`
: `${semesterList} /
started during ${lowestYear}-${highestYear}`}
</div>
{tag ? <div>{`Tagged with: ${tagname}`}</div> : null}
<div>Sample size: {students.length} students</div>
<div>{`Updated at ${reformatDate(minBy(students, 'updatedAt').updatedAt, DISPLAY_DATE_FORMAT)} `}</div>
<div>{studentStatuses.includes('EXCHANGE') ? 'Includes' : 'Excludes'} exchange students</div>
<div>
@@ -16,7 +16,6 @@ const FilterActiveNote = () => {
return (
<Message color="blue" style={{ marginTop: '2.25rem' }}>
<Message.Header>Note:</Message.Header>
By default only students who have not transferred to this study programme are shown.
<div style={{ marginTop: '1rem' }}>
<Button primary disabled={clicked} onClick={handleClick}>
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import qs from 'query-string'
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
import uuidv4 from 'uuid/v4'
import { v4 as uuidv4 } from 'uuid'
import Datetime from 'react-datetime'
import { sortBy, isEqual } from 'lodash'
import moment from 'moment'
@@ -193,7 +193,7 @@ class PopulationSearchHistory extends Component {
{!populations.query.tag ? (
<Form.Field style={{ marginTop: '15px' }}>
<b>Semesters</b>
<b>Starting semesters</b>
<Form.Checkbox
className="populationStatisticsRadio"
key="FALL"
@@ -218,7 +218,8 @@ class PopulationSearchHistory extends Component {
) : null}
<Form.Field style={{ marginTop: '15px' }}>
<b>Include</b>
<Form.Checkbox
{/* Hide buggy options for now */}
{/* <Form.Checkbox
className="populationStatisticsRadio"
key="EXCHANGE"
label="Exchange students"
@@ -235,7 +236,7 @@ class PopulationSearchHistory extends Component {
name="studentStatusGroup"
checked={studentStatuses.includes('NONDEGREE')}
onChange={this.handleStudentStatusSelection}
/>
/> */}
<Form.Checkbox
className="populationStatisticsRadio"
key="TRANSFERRED"
@@ -31,26 +31,24 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
{location.search !== '' && !loading && (
<Form>
<Form.Group inline>
<Form.Field>
<Link to="/populations">
<Button icon labelPosition="left" color="blue">
<Icon name="left arrow" />
Search new class
</Button>
</Link>
</Form.Field>
<Form.Field>
<Form.Radio
toggle
checked={onlyHopsCredits}
onClick={() => {
filterDispatch(hopsFilter.actions.toggle())
}}
label="Show only credits included in study plan"
/>
</Form.Field>
</Form.Group>
<Form.Field>
<Link to="/populations">
<Button icon labelPosition="left" color="blue">
<Icon name="left arrow" />
Search new class
</Button>
</Link>
</Form.Field>
<Form.Field>
<Form.Radio
toggle
checked={onlyHopsCredits}
onClick={() => {
filterDispatch(hopsFilter.actions.toggle())
}}
label="Show only credits included in study plan"
/>
</Form.Field>
<PopulationSearchHistory history={history} />
</Form>
@@ -6,7 +6,9 @@ import { useGetSemestersQuery } from 'redux/semesters'
import populationToData from 'selectors/populationDetails'
import { getStudentTotalCredits } from 'common'
import PopulationDetails from '../PopulationDetails'
import { useLanguage, useTitle } from '../../common/hooks'
import { useTitle } from '../../common/hooks'
import useLanguage from '../LanguagePicker/useLanguage'
import { getTextIn } from '../../common'
import FilterView from '../FilterView'
import PopulationSearch from '../PopulationSearch'
import DataExport from './DataExport'
@@ -30,7 +32,7 @@ import {
const PopulationStatistics = () => {
const location = useLocation()
const language = useLanguage()
const { language } = useLanguage()
const history = useHistory()
// const { query, queryIsSet, isLoading, students } = useSelector(selectPopulations)
const courses = useSelector(store => store.populationSelectedStudentCourses.data?.coursestatistics)
@@ -43,6 +45,8 @@ const PopulationStatistics = () => {
const { data: allSemesters } = useGetSemestersQuery()
const programmeCode = query?.studyRights?.programme
const programmes = useSelector(store => store.populationProgrammes?.data?.programmes)
const programmeName = programmes && programmeCode ? programmes[programmeCode]?.name : ''
const filters = [
studentNumberFilter,
@@ -91,6 +95,11 @@ const PopulationStatistics = () => {
})
}, [samples, programmeCode])
const title =
location.search === ''
? 'Class statistics'
: `${getTextIn(programmeName, language)} ${query?.year} - ${Number(query?.year) + 1}`
return (
<FilterView
name="PopulationStatistics"
@@ -108,9 +117,15 @@ const PopulationStatistics = () => {
>
{filteredStudents => (
<div className="segmentContainer" style={{ flexGrow: 1 }}>
<Header className="segmentTitle" size="large">
Class statistics
<Header className="segmentTitle" size="large" align="center">
{title}
{location.search !== '' && (
<Header.Subheader>
studytime {query?.months} months, class size {students.length} students
</Header.Subheader>
)}
</Header>
<Segment className="contentSegment">
<PopulationSearch history={history} location={location} />
{location.search !== '' ? (
@@ -212,7 +212,7 @@ const CoursesTable = ({ students }) => {
...sortedlabels
.filter(({ code }) => visibleLabels.has(code))
.map(e => ({
key: e.id,
key: e.id + e.code,
title: (
<div style={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<div>{e.code}</div>
@@ -252,14 +252,14 @@ const CoursesTable = ({ students }) => {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : 'Not passed'
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowExportVal: s => {
if (s.total) {
return getTotalRowVal(s, m)
}
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : 'Not passed'
return hasPassedMandatory(s.studentNumber, m.code) ? 'Passed' : ''
},
getRowContent: s => {
if (s.total) return getTotalRowVal(s, m)
@@ -40,6 +40,7 @@ const Updater = React.lazy(() => retry(() => import('../Updater')))
const Trends = React.lazy(() => retry(() => import('../Trends')))
const StudyGuidanceGroups = React.lazy(() => retry(() => import('../StudyGuidanceGroups')))
const FacultyStatistics = React.lazy(() => retry(() => import('../FacultyStatistics')))
const CustomOpenUniPopulations = React.lazy(() => retry(() => import('../CustomOpenUniPopulation')))
const routes = {
students: '/students/:studentNumber?',
@@ -53,6 +54,7 @@ const routes = {
trends: '/trends',
studyGuidanceGroups: '/studyguidancegroups/:groupid?',
faculties: '/faculties/:facultyCode?',
customOpenUniPopulation: '/openunipopulation',
}
const Routes = () => (
@@ -65,7 +67,7 @@ const Routes = () => (
<ProtectedRoute
requiredRoles={['admin', 'facultyStatistics']}
exact
path="/faculties/:facultyCode?"
path={routes.faculties}
component={FacultyStatistics}
/>
<ProtectedRoute
@@ -98,6 +100,12 @@ const Routes = () => (
path={routes.custompopulation}
component={CustomPopulation}
/>
<ProtectedRoute
requiredRoles={['admin', 'openUniSearch']}
exact
path={routes.customOpenUniPopulation}
component={CustomOpenUniPopulations}
/>
<ProtectedRoute requiredRoles={['admin']} requireUserHasRights exact path={routes.updater} component={Updater} />
<ProtectedRoute
requiredRoles={['studyGuidanceGroups']}
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
import moment from 'moment'
import { sortBy } from 'lodash'
import { Segment, Form, Header, Icon } from 'semantic-ui-react'
import { arrayOf, date, func, shape, string, bool } from 'prop-types'
import { arrayOf, func, shape, string, bool } from 'prop-types'
const SearchHistory = ({ items, handleSearch, updateItem, disabled, header }) => {
const [selected, setSelected] = useState(null)
@@ -41,7 +41,7 @@ const SearchHistory = ({ items, handleSearch, updateItem, disabled, header }) =>
key: id,
value: id,
text,
description: moment(timestamp).format('DD.MM LT'),
description: moment(timestamp).format('DD.MM.YYYY LT'),
}))}
onChange={handleChange}
closeOnChange
@@ -65,7 +65,7 @@ SearchHistory.propTypes = {
shape({
text: string,
params: shape({}),
timestamp: date,
timestamp: string,
})
).isRequired,
handleSearch: func.isRequired,
@@ -5,6 +5,7 @@ import React, { useState, useMemo } from 'react'
import { Modal, Table, Button, Checkbox } from 'semantic-ui-react'
import _ from 'lodash'
import xlsx from 'xlsx'
import { v4 as uuidv4 } from 'uuid'
import { getColumnTitle, getDataItemType, DataItemType } from './common'
import ValueVisitor from './ValueVisitor'
import ExportVisitor from './ExportVisitor'
@@ -86,6 +87,10 @@ const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
return (
<Modal onOpen={onOpen} onClose={onClose} open={open}>
<Modal.Header>Export to Excel</Modal.Header>
<Modal.Actions>
<Button content="Cancel" onClick={() => onClose()} />
<Button primary content="Export" onClick={() => handleExport()} />
</Modal.Actions>
<Modal.Content>
<p>
Exporting {flatData.length} rows into an Excel (.xlsx) file. Choose which columns you want to include in the
@@ -106,9 +111,11 @@ const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
<Table celled striped>
<Table.Header>
<Table.HeaderCell />
<Table.HeaderCell>Column</Table.HeaderCell>
<Table.HeaderCell>Sample Values</Table.HeaderCell>
<Table.Row key="TitleRow">
<Table.HeaderCell />
<Table.HeaderCell>Column</Table.HeaderCell>
<Table.HeaderCell>Sample Values</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{exportColumns.map(column => (
@@ -121,6 +128,7 @@ const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
<div style={{ width: '0' }}>
{sampledValues[column.key].map(value => (
<span
key={uuidv4()}
style={{
border: '1px solid #dedede',
borderRadius: '3px',
@@ -140,10 +148,6 @@ const ExportModal = ({ open, onOpen, onClose, data, columns }) => {
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button content="Cancel" onClick={() => onClose()} />
<Button primary content="Export" onClick={() => handleExport()} />
</Modal.Actions>
</Modal>
)
}
@@ -6,6 +6,7 @@ import { Icon, Popup } from 'semantic-ui-react'
import FigureContainer from 'components/FigureContainer'
import _ from 'lodash'
import produce from 'immer'
import { v4 as uuidv4 } from 'uuid'
import ExportModal from './ExportModal'
import { row, group, getDataItemType, SortableTableContext, DataItemType } from './common'
import DefaultColumnFilter from './defaultFilter'
@@ -113,17 +114,16 @@ const Row = ({ data, isGroup, parents }) => {
if (content !== undefined && content !== null) {
cells.push(
<td colSpan={columnSpans[column.key]} {...cellProps}>
<td colSpan={columnSpans[column.key]} key={column.key + cellProps.title} {...cellProps}>
<ColumnContent column={column} data={data} isGroup={isGroup} parents={parents} />
</td>
)
} else if (column.children && column.children.length > 0) {
stack.splice(0, 0, ...column.children.map((c, i) => ({ ...c, childIndex: i })))
} else {
cells.push(<td {...cellProps} />)
cells.push(<td key={`${column.key}-else-${cellProps.title}`} {...cellProps} />)
}
}
return <tr className={isGroup ? 'group-header-row' : ''}>{cells}</tr>
}
@@ -605,17 +605,16 @@ const createHeaders = (columns, columnDepth, dispatch) => {
stack = [...children, ...stack]
}
}
return rows.map(cells => <tr>{cells}</tr>)
return rows.map(cells => <tr key={uuidv4()}>{cells}</tr>)
}
const getInitialState = defaultSort => () => ({
const getInitialState = (defaultSort, expandedGroups) => () => ({
columnOptions: !defaultSort
? {}
: {
[defaultSort[0]]: { filterOptions: undefined, sort: defaultSort[1] },
},
expandedGroups: [],
expandedGroups: !expandedGroups ? [] : Array.from(expandedGroups), // [],
})
const tableStateReducer = (...args) =>
@@ -801,9 +800,15 @@ const SortableTable = ({
contextMenuItems: pContextMenuItems,
singleLine = true,
figure = true,
toggleGroupExpansion,
expandedGroups,
}) => {
const [exportModalOpen, setExportModalOpen] = useState(false)
const [state, dispatch] = useReducer(tableStateReducer(defaultSort), null, getInitialState(defaultSort))
const [state, dispatch] = useReducer(
tableStateReducer(defaultSort),
null,
getInitialState(defaultSort, expandedGroups)
)
const groupDepth = useMemo(() => calculateGroupDepth(data), [data])
const toggleGroup = useCallback(
@@ -812,6 +817,9 @@ const SortableTable = ({
type: 'TOGGLE_GROUP',
payload: { group: groupKey },
})
if (toggleGroupExpansion) {
toggleGroupExpansion(groupKey)
}
},
[dispatch]
)
@@ -823,6 +831,9 @@ const SortableTable = ({
type: 'SET_UNFOLDED_GROUPS',
payload: { groups },
})
if (toggleGroupExpansion) {
toggleGroupExpansion('', false, groups)
}
}, [data])
const handleFoldAllGroups = useCallback(() => {
@@ -830,6 +841,9 @@ const SortableTable = ({
type: 'SET_UNFOLDED_GROUPS',
payload: { groups: [] },
})
if (toggleGroupExpansion) {
toggleGroupExpansion('', true)
}
}, [data])
const [columns, columnsByKey] = useMemo(() => {
@@ -898,7 +912,7 @@ const SortableTable = ({
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>{headers}</thead>
<tbody>
{sortedData.map(item => (
<DataItem item={item} key={item.key} />
<DataItem item={item} key={uuidv4()} />
))}
</tbody>
</table>
+1 -1
View File
@@ -1,4 +1,4 @@
import uuidv4 from 'uuid/v4'
import { v4 as uuidv4 } from 'uuid'
export const sendError = (message = 'Something went wrong') => ({
type: 'ADD_ERROR',
+1 -1
View File
@@ -4,7 +4,7 @@ import { callController } from '../apiConnection'
// Use that for new stuff or switch while refactoring
export const getFaculties = () => {
const route = '/faculties'
const route = '/faculties-only'
const prefix = 'GET_FACULTIES_'
return callController(route, prefix)
}
@@ -0,0 +1,47 @@
import { RTKApi } from 'apiConnection'
const openUniPopulationApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getOpenUniCourseStudents: builder.query({
query: ({ courseList, startdate, enddate }) =>
`/openunisearch?courselist=${JSON.stringify(courseList)}&startdate=${startdate}&enddate=${enddate}`,
}),
getSavedSearches: builder.query({
query: () => `/openunisearch/searches`,
}),
createOpenUniCourseSearch: builder.mutation({
query: ({ courseList, name }) => ({
url: '/openunisearch/searches',
method: 'POST',
body: {
courselist: courseList,
name,
},
}),
}),
updateOpenUniCourseSearch: builder.mutation({
query: ({ id, courseList }) => ({
url: `/openunisearch/searches/${id}`,
method: 'PUT',
body: {
courselist: courseList,
},
}),
}),
deleteOpenUniCourseSearch: builder.mutation({
query: ({ id }) => ({
url: `/openunisearch/searches/${id}`,
method: 'DELETE',
}),
}),
}),
overrideExisting: false,
})
export const {
useGetOpenUniCourseStudentsQuery,
useGetSavedSearchesQuery,
useCreateOpenUniCourseSearchMutation,
useDeleteOpenUniCourseSearchMutation,
useUpdateOpenUniCourseSearchMutation,
} = openUniPopulationApi
@@ -0,0 +1,10 @@
const { STRING } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.addColumn('student', 'secondary_email', STRING)
},
down: async queryInterface => {
await queryInterface.removeColumn('student', 'secondary_email')
},
}
@@ -0,0 +1,10 @@
const { BOOLEAN } = require('sequelize')
module.exports = {
up: async queryInterface => {
await queryInterface.addColumn('student', 'dissemination_info_allowed', BOOLEAN)
},
down: async queryInterface => {
await queryInterface.removeColumn('student', 'dissemination_info_allowed')
},
}
@@ -1,4 +1,4 @@
const { Model, STRING, DATE, INTEGER } = require('sequelize')
const { Model, STRING, DATE, INTEGER, BOOLEAN } = require('sequelize')
const { dbConnections } = require('../connection')
class Student extends Model {}
@@ -16,6 +16,7 @@ Student.init(
creditcount: { type: INTEGER },
dateofuniversityenrollment: { type: DATE },
email: { type: STRING },
secondary_email: { type: String },
national_student_number: { type: STRING },
country_fi: { type: STRING },
country_sv: { type: STRING },
@@ -25,6 +26,7 @@ Student.init(
home_country_en: { type: STRING },
gender_code: { type: INTEGER },
sis_person_id: { type: STRING },
dissemination_info_allowed: { type: BOOLEAN },
createdAt: {
type: DATE,
},
@@ -83,8 +83,18 @@ const calculateTotalCreditsFromAttainments = attainments => {
return totalCredits
}
const studentMapper = (attainments, studyRights, attainmentsToBeExluced) => student => {
const { last_name, first_names, student_number, primary_email, gender_urn, oppija_id, date_of_birth, id } = student
const studentMapper = (attainments, studyRights, attainmentsToBeExluced, disclosures) => student => {
const {
last_name,
first_names,
student_number,
primary_email,
secondary_email,
gender_urn,
oppija_id,
date_of_birth,
id,
} = student
// Filter out test student from oodi data
if (student_number === '012023965') return null
@@ -95,9 +105,13 @@ const studentMapper = (attainments, studyRights, attainmentsToBeExluced) => stud
// https://github.com/UniversityOfHelsinkiCS/oodikone/issues/2958
const country = getCountry(student.country_urn)
const home_country = student.citizenships ? getCountry(student.citizenships[0]) : null
// "hy-luovutusehto-6" = Open University's dissemination of information on its course offerings
const openUniDisseminationOfInfo = disclosures.filter(
d => d.person_id === id && d.disclosure_category_id === 'hy-luovutusehto-6'
)
const marketingAllowed = openUniDisseminationOfInfo.length > 0 ? openUniDisseminationOfInfo[0].authorized : null
const studyRightsOfStudent = studyRights.filter(SR => SR.person_id === id)
const dateofuniversityenrollment =
studyRightsOfStudent.length > 0 ? sortBy(studyRightsOfStudent.map(sr => sr.valid.startDate))[0] : null
@@ -112,6 +126,7 @@ const studentMapper = (attainments, studyRights, attainmentsToBeExluced) => stud
abbreviatedname: `${last_name} ${first_names}`.trim(),
studentnumber: student_number,
email: primary_email,
secondary_email: secondary_email,
gender_code,
national_student_number: oppija_id,
birthdate: date_of_birth,
@@ -124,6 +139,7 @@ const studentMapper = (attainments, studyRights, attainmentsToBeExluced) => stud
home_country_sv: home_country ? home_country.name.sv : null,
home_country_en: home_country ? home_country.name.en : null,
sis_person_id: id,
dissemination_info_allowed: marketingAllowed,
}
}
@@ -183,6 +183,7 @@ const updateStudents = async personIds => {
studyRightPrimalities,
enrollments,
studyplans,
disclosures,
] = await Promise.all([
selectFromByIds('persons', personIds),
selectFromByIds('studyrights', personIds, 'person_id'),
@@ -191,6 +192,7 @@ const updateStudents = async personIds => {
selectFromByIds('study_right_primalities', personIds, 'student_id'),
selectFromByIds('enrolments', personIds, 'person_id'),
selectFromByIds('plans', personIds, 'user_id'),
selectFromByIds('disclosures', personIds, 'person_id'),
])
const parsedStudyrightSnapshots = parseStudyrightSnapshots(studyrightSnapshots)
@@ -209,10 +211,9 @@ const updateStudents = async personIds => {
}, {})
const attainmentsToBeExluced = getAttainmentsToBeExcluded()
const mappedStudents = students
.filter(s => s.student_number)
.map(studentMapper(attainments, parsedStudyrightSnapshots, attainmentsToBeExluced))
.map(studentMapper(attainments, parsedStudyrightSnapshots, attainmentsToBeExluced, disclosures))
await bulkCreate(Student, mappedStudents)
@@ -16,7 +16,7 @@ const updateStudyRights = async (
groupedStudyRightSnapshots,
personIdToStudentNumber,
personIdToStudyRightIdToPrimality,
personsTermRegistrations
allTermRegistrations
) => {
const currentSemester = getSemesterByDate(new Date())
@@ -138,6 +138,10 @@ const updateStudyRights = async (
const formattedStudyRights = latestStudyRights.reduce((acc, studyright) => {
const studyRightEducation = getEducation(studyright.education_id)
const personsTermRegistrations = allTermRegistrations.filter(
registration => registration.study_right_id === studyright.id
)
const termRegistrations = flatMap(personsTermRegistrations, 'term_registrations')
if (!studyRightEducation) return acc