Compare commits

...
35 Commits
Author SHA1 Message Date
Valtteri Kantanen b192d2b104 [Course statistics] Fix tests 2024-01-26 15:00:57 +02:00
Valtteri Kantanen c912438265 [Study guidance groups] Fix tests 2024-01-26 14:45:26 +02:00
Valtteri Kantanen 713e996ba9 [Tests] Add only study guidance groups user for mocking purposes 2024-01-26 14:45:02 +02:00
Riku Rauhala 03bb0b37dd [Course statistics] Show relative share of students as percentages 2024-01-26 14:17:41 +02:00
ShootingStar91 efce3e52c8 Fix email 2024-01-26 14:07:51 +02:00
Riku Rauhala d5d728e1f7 [Refactoring] Create a generic helper function for calculating percentages 2024-01-26 13:18:32 +02:00
Valtteri Kantanen 0ec0451971 [Study guidance groups] Add more tests 2024-01-26 12:31:49 +02:00
Riku Rauhala 3256757dc1 [GitHub Actions] Update test.yaml 2024-01-26 12:29:45 +02:00
Riku Rauhala d3c03e8451 Update e2e tests 2024-01-26 12:17:33 +02:00
Riku Rauhala 5f3e715452 [Navigation bar] Update student statistics test 2024-01-26 12:08:32 +02:00
Riku Rauhala 66d4c80bef [Navigation bar] Fix e2e tests 2024-01-26 11:56:52 +02:00
Riku Rauhala ad2133ef85 [Navigation bar] Rename Course statistics to Courses and Student statistics to Students 2024-01-26 11:34:05 +02:00
arttu 1eca9d1bb4 [Uni overview] Don't use faculty query for faculties, would send sentry error 2024-01-24 17:22:28 +02:00
arttu 401269571a [Uni overview] Don't show text for faculty link list if no rights. Smaller row gaps. 2024-01-24 16:59:59 +02:00
arttu 7f4050c5dc Add retries to course stat test 2024-01-24 16:14:11 +02:00
arttu 581950bd53 Rapodiff: Credit diff version that somehow works 2024-01-24 15:48:44 +02:00
arttu c712189d0e Rapodiff: Fix code transform 2024-01-24 14:08:10 +02:00
arttu 252c09f656 Rapodiff: Fix papaparse promise use 2024-01-24 14:07:40 +02:00
ShootingStar91 e7494bb269 Rapodiff: credits diff work in progress, csv parsing added 2024-01-24 12:21:06 +02:00
arttu f859886429 Return date to production console logs 2024-01-24 12:14:32 +02:00
arttu 1b0f009a1e [Uni overview] Add link list to individual faculties 2024-01-24 12:05:11 +02:00
arttu b216e69609 [Uni overview] Adjust rights in frontend 2024-01-24 12:03:40 +02:00
ShootingStar91 37eb61603a Also add university-route file to the latest commit 2024-01-24 11:23:27 +02:00
ShootingStar91 75681e2a2d [Uni overview] Refactor to own route & folder, fix loader 2024-01-24 11:13:41 +02:00
Riku Rauhala 6617c2d1bc [Course statistics] Fix e2e tests 2024-01-24 10:24:25 +02:00
Riku Rauhala 955f50271a [Course statistics] Fix e2e tests 2024-01-24 10:02:53 +02:00
Riku Rauhala 426eab64b8 [Course statistics] Create separate tabs for Students and Attempts tables 2024-01-23 16:16:22 +02:00
Valtteri Kantanen b67d1b8950 Run study guidance group tests on GHA 2024-01-22 17:43:20 +02:00
Valtteri Kantanen 390b9dfd50 [Study guidance groups] Add first end-to-end tests (#4278) 2024-01-22 17:32:14 +02:00
Valtteri Kantanen 653f558265 [Tests] Choose all tbody child elements instead of just the direct ones in the checkTableStats command 2024-01-22 17:27:18 +02:00
Valtteri Kantanen b8092c24f0 Specify profiles in commands 2024-01-22 16:04:48 +02:00
Valtteri Kantanen 9bf13dc720 [Filters] Add retries to a flaky test 2024-01-22 14:46:14 +02:00
Valtteri Kantanen 2111b39cfd [General tab] Use studyright specific semester enrollments in class statistics 2024-01-22 14:42:38 +02:00
ShootingStar91 307e2d7cc3 [Evaluation overview] Fix backend crashing 2024-01-22 14:02:58 +02:00
Valtteri Kantanen 199e8b74a0 [Student statistics] Display studyright specific semester enrollments 2024-01-22 12:59:33 +02:00
48 changed files with 899 additions and 715 deletions
+33 -166
View File
@@ -154,13 +154,42 @@ jobs:
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
# === End-to-end tests ===
test_course_statistics:
name: 'Test course statistics'
test_end_to_end:
runs-on: ubuntu-latest
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: 'Test course statistics'
spec: cypress/e2e/Course_statistics.js
- name: 'Test custom population'
spec: cypress/e2e/Custom_population.js
- name: 'Test population statistics'
spec: cypress/e2e/Population_statistics.js
- name: 'Test filters'
spec: cypress/e2e/Filters.js
- name: 'Test students'
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test studyprogramme overview'
spec: cypress/e2e/Studyprogramme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test open uni search'
spec: cypress/e2e/Openuni.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
spec: cypress/e2e/Study_guidance_groups.js
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
@@ -168,169 +197,7 @@ jobs:
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Course_statistics.js
test_custom_population:
name: 'Test custom population'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Custom_population.js
test_population_statistics:
name: 'Test population statistics'
runs-on: ubuntu-latest
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Population_statistics.js
test_filters:
name: 'Test filters'
runs-on: ubuntu-latest
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Filters.js
test_student_statistics:
name: 'Test student statistics'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Student_statistics.js
test_completed_courses:
name: 'Test completed courses'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Completed_courses.js
test_studyprogramme_overview:
name: 'Test studyprogramme overview'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Studyprogramme_overview.js
test_teachers_and_users:
name: 'Test teachers and users'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: |
cypress/e2e/Teachers.js
cypress/e2e/Users.js
test_faculty_statistics:
name: 'Test faculty statistics'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Faculty_statistics.js
test_open_uni_search:
name: 'Test open uni search'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Openuni.js
test_language_center:
name: 'Test language center view'
needs:
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 'Run tests'
uses: ./.github/actions/run-tests
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
spec: cypress/e2e/Language_center.js
spec: ${{ matrix.spec }}
# === Other tests ===
test_service_exit_statuses:
+10 -60
View File
@@ -78,7 +78,7 @@ describe('Course Statistics tests', () => {
})
})
it('Searching single course having substitution mappings shows course statistics', () => {
it('Searching single course having substitution mappings shows course statistics', { retries: 3 }, () => {
cy.contains('Search for courses')
cy.get("input[placeholder='Search by a course code']").type('TKT20001')
// Click uni course, not avoin
@@ -89,10 +89,11 @@ describe('Course Statistics tests', () => {
cy.contains('TKT20001')
cy.contains('58131') // old mapped code
cy.contains('.tabular.menu a', 'Table').click()
cy.contains('.tabular.menu a', 'Students').click()
cy.contains('All')
cy.contains('.tabular.menu a', 'Attempts').click()
cy.contains('All')
cy.cs('viewMode-Attempts').click()
cy.cs('viewMode-Students').click()
cy.contains('.tabular.menu a', 'Pass rate chart').click()
cy.contains('svg', 'Pass rate chart')
@@ -137,7 +138,7 @@ describe('Course Statistics tests', () => {
cy.contains('Show population')
cy.contains('TKT20003')
cy.contains('Course statistics').click()
cy.contains('Courses').click()
cy.contains('Search for courses')
cy.get("input[placeholder='Search by a course code']").type('TKT20001')
cy.contains('td', 'TKT20001').click()
@@ -310,7 +311,6 @@ describe('Course Statistics tests', () => {
cy.get("div[role='option']").should('have.length', 11)
})
cy.contains('#CourseStatPanes a.item', 'Table').click()
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
cy.get('#CourseStatPanes table>tbody').within(() => {
attemptsTableContents.forEach((values, trIndex) => {
@@ -358,16 +358,6 @@ describe('Course Statistics tests', () => {
['Total', null, 511, 486, 25],
]
const attemptsTableContentsOpen = [
// [time, total, passed, failed]
['Total', null, 199, 188, 11],
]
const attemptsTableContentsRegular = [
// [time, total, passed, failed]
['Total', null, 312, 298, 14],
]
const gradesTableContents = [
// [time, attempts, 0, 1, 2, 3, 4, 5, other passed]
['Total', null, 511, 25, 11, 17, 21, 59, 365, 13],
@@ -395,7 +385,6 @@ describe('Course Statistics tests', () => {
})
cy.contains('Show population').should('not.be.enabled')
cy.contains('#CourseStatPanes a.item', 'Table').click()
cy.contains('#CourseStatPanes a.item', 'Attempts').click()
cy.get('#CourseStatPanes table>tbody').within(() => {
attemptsTableContents.forEach((values, trIndex) => {
@@ -426,44 +415,6 @@ describe('Course Statistics tests', () => {
})
})
it('Toggling course provider changes stats correctly', () => {
cy.get('[data-cy=unify_radio_regular]').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(() => {
attemptsTableContentsRegular.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
if (value === null) return
cy.get('td').eq(tdIndex).contains(value)
})
})
})
})
cy.get('[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) => {
if (value === null) return
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']")
@@ -510,7 +461,7 @@ describe('Course Statistics tests', () => {
})
})
// ! This test does not pass locally but it does pass on GitHub Actions (see issue #4317)
// TODO: Why does this test not show "5 or less students" but 5 instead?
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()
@@ -519,13 +470,12 @@ describe('Course Statistics tests', () => {
cy.contains('Filter statistics by study programmes').should('not.exist')
cy.contains('Faculty statistics').should('not.exist')
cy.contains('Show population').should('not.exist')
cy.cs('viewMode-Attempts').click()
cy.contains('.tabular.menu a', 'Attempts').click()
const attemptsTableContents = [
// [time, passed, failed, passrate, total enrollments, enrolled, rejected, aborted]
['Total', null, 288, 213, 75, '73.96 %', 0, 0, 0, 0],
['2020-2021', null, '5 or less students', 'NA', 'NA', 'NA', 'NA', 'NA', 'NA', 'NA'],
['Total', null, 295, 216, 79, '73.22 %', 0, 0, 0, 0],
['2020-2021', null, 5, 2, 3, '40.00 %'],
['2019-2020', null, 164, 121, 43, '73.78 %'],
['2018-2019', null, 85, 60, 25, '70.59 %'],
['2017-2018', null, 39, 32, 7, '82.05 %'],
+1 -1
View File
@@ -130,7 +130,7 @@ describe('Population Statistics', () => {
})
})
it('Graduation filter works', () => {
it('Graduation filter works', { retries: 2 }, () => {
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
@@ -1,9 +1,9 @@
/// <reference types="Cypress" />
describe('Student Statistics tests', () => {
describe('Students tests', () => {
beforeEach(() => {
cy.init()
cy.contains('Student statistics').click()
cy.contains('Students').click()
cy.contains('Show student names')
})
@@ -14,7 +14,7 @@ describe('Student Statistics tests', () => {
sis_person_id: 'hy-hlo-84830887',
}
it('Student statistics search form is usable', () => {
it('Students search form is usable', () => {
cy.contains('Show student names')
cy.url().should('include', '/students')
cy.get('.prompt').type(student.lastname)
+165
View File
@@ -0,0 +1,165 @@
/// <reference types="Cypress" />
describe('Study guidance group tests', () => {
describe('Study guidance group overview page', () => {
describe('without study guidance groups', () => {
it('shows the correct notification', () => {
cy.init('/studyguidancegroups', 'admin')
cy.contains('Study guidance groups')
cy.contains('You do not have access to any study guidance groups.')
})
})
describe('without correct rights', () => {
it('shows the correct notification', () => {
cy.init('/studyguidancegroups')
cy.contains('Access denied')
cy.contains(
"You're currently not allowed to see this page. Please contant oodikone@helsinki.fi, if this is a mistake."
)
})
})
describe('with study guidance groups', () => {
beforeEach(() => {
cy.init('/studyguidancegroups', 'onlystudyguidancegroups')
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
})
it('shows the correct data', () => {
cy.contains('Study guidance groups')
const studyGuidanceGroupsTableContent = [
['TKT kandit 2018', 3, 'Tietojenkäsittelytieteen kandiohjelma', '2018 - 2019'],
['Oma ohjausryhmä', 2, 'Add study programme', 'Add year'],
]
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
})
it('has working links to single study guidance groups', () => {
cy.cs('study-guidance-group-link-sgg-cypress-1').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups/sgg-cypress-1`)
})
})
})
describe('Page for single study guidance group', () => {
beforeEach(() => {
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
})
it('has a working back button', () => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
cy.get('button.ui.button').contains('Back').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
})
describe('without associated study programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
})
it('shows the correct panes', () => {
cy.contains('Oma ohjausryhmä')
cy.cs('Credit accumulation (for 2 students)')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (2)')
cy.cs('Credit statistics').should('not.exist')
})
it('students table has the correct tabs', () => {
cy.cs('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General'])
})
})
it('general tab of the students table has the correct columns', () => {
const firstRowHeadings = [
'Phone number',
'Student number',
'Credits',
'Start year at uni',
'Study programmes',
'Tags',
'Last Updated At',
]
const secondRowHeadings = ['All', 'Since 1.1.1970']
cy.cs('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
})
})
})
describe('with associated study programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
})
it('shows the correct labels', () => {
cy.get('.ui.medium.header').contains('TKT kandit 2018')
cy.get('.ui.blue.tag.label:first').contains('Tietojenkäsittelytieteen kandiohjelma')
cy.get('.ui.blue.tag.label:last').contains('2018 - 2019')
})
it('shows the correct panes', () => {
cy.cs('Credit accumulation (for 3 students)')
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.cs('Students (3)')
})
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="true"]')
cy.get('.ui.mini.icon.button.credit-date-filter-input').contains('01.08.2018')
cy.contains('Reset All Filters')
cy.get('.ui.primary.button').contains('Show all credits').click()
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
})
it('students table has the correct tabs', () => {
cy.cs('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
})
})
it('general tab of the students table has the correct columns', () => {
const firstRowHeadings = [
'Phone number',
'Student number',
'Credits',
'Start of studyright',
'Started in programme',
'Semesters present',
'Graduation date',
'Start year at uni',
'Other programmes',
'Semesters present amount',
'Tags',
'Last Updated At',
]
const secondRowHeadings = ['All', 'HOPS', 'Since 1.8.2018']
cy.cs('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
})
})
})
})
})
+2 -2
View File
@@ -401,7 +401,7 @@ describe('Studyprogramme overview', () => {
cy.get('textarea').type('010113437')
cy.get('.positive').click()
cy.contains('Student statistics').click()
cy.contains('Students').click()
cy.get('.prompt').type(student)
cy.contains('a', student).click()
cy.contains(name)
@@ -410,7 +410,7 @@ describe('Studyprogramme overview', () => {
cy.go('back')
deleteTag(name)
cy.contains('Student statistics').click()
cy.contains('Students').click()
cy.get('.prompt').type(student)
cy.contains('a', student).click()
cy.contains(name).should('not.exist')
+4 -4
View File
@@ -8,8 +8,8 @@ describe('Users tests', () => {
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
cy.get('[data-cy="navBar"]').contains('Study programme').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Students').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Courses').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Teachers').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
@@ -30,8 +30,8 @@ describe('Users tests', () => {
it('should see more stuff than others', () => {
cy.get('[data-cy="navBar"]').contains('Study programme').should('exist')
cy.get('[data-cy="navBar"]').contains('Student statistics').should('exist')
cy.get('[data-cy="navBar"]').contains('Course statistics').should('exist')
cy.get('[data-cy="navBar"]').contains('Students').should('exist')
cy.get('[data-cy="navBar"]').contains('Courses').should('exist')
// TODO: admin should probably see this as well, since can access this page: cy.get('[data-cy="navBar"]').contains('Teachers').should('exist')
cy.get('[data-cy="navBar"]').contains('Users').should('exist')
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
+28
View File
@@ -0,0 +1,28 @@
[
{
"id": "sgg-cypress-1",
"name": {
"fi": "TKT kandit 2018"
},
"members": [
{ "personStudentNumber": "010450938" },
{ "personStudentNumber": "010589388" },
{ "personStudentNumber": "010262566" }
],
"tags": {
"studyProgramme": "KH50_005",
"year": "2018"
}
},
{
"id": "sgg-cypress-2",
"name": {
"fi": "Oma ohjausryhmä"
},
"members": [{ "personStudentNumber": "010614509" }, { "personStudentNumber": "010307146" }],
"tags": {
"studyProgramme": null,
"year": null
}
}
]
+12 -1
View File
@@ -53,6 +53,16 @@ const onlyIamRightsUserHeaders = {
hypersonsisuid: 'hy-hlo-1111111',
}
// Also has element detail of "KH50_005" for the study guidance groups to work
const onlyStudyGuidanceGroupsUser = {
uid: 'onlystudyguidancegroups',
displayname: 'Study Guidance Groups User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users',
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-2222222',
}
// TODO add user into anon-DB (check the need for changing hypersonid)
// REMEMBER TO ADD ROLE openUniSearch
// const openUniUser = {
@@ -71,6 +81,7 @@ const userHeaders = [
onlycoursestatisticsUserHeaders,
norightsUserHeaders,
onlyIamRightsUserHeaders,
onlyStudyGuidanceGroupsUser,
]
/**
@@ -134,7 +145,7 @@ Cypress.Commands.add('selectStudyProgramme', name => {
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get(`[data-cy=Table-${tableName}]>tbody`).within(() => {
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
correctStats.forEach((values, trIndex) => {
cy.get('tr')
.eq(trIndex)
-2
View File
@@ -83,8 +83,6 @@ parse_profiles
parse_env
if [[ "$compose_command" == "" ]]; then
final_command="docker-compose ${env}"
elif [[ "$compose_command" == *"down"* ]]; then
final_command="docker-compose ${env} ${compose_command}"
else
final_command="docker-compose ${env} ${profiles} ${compose_command}"
fi
+6
View File
@@ -20,6 +20,7 @@
"moment": "^2.29.1",
"moment-timezone": "^0.5.37",
"morgan": "^1.10.0",
"papaparse": "^5.4.1",
"pg": "^8.8.0",
"redis": "^3.1.2",
"sequelize": "^6.24.0",
@@ -2913,6 +2914,11 @@
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"node_modules/papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+1
View File
@@ -21,6 +21,7 @@
"moment": "^2.29.1",
"moment-timezone": "^0.5.37",
"morgan": "^1.10.0",
"papaparse": "^5.4.1",
"pg": "^8.8.0",
"redis": "^3.1.2",
"sequelize": "^6.24.0",
+2
View File
@@ -0,0 +1,2 @@
*.csv
*.csv#
@@ -0,0 +1,74 @@
/* eslint-disable no-console */
const { getCreditStats } = require('../services/analyticsService')
const { parseCsv } = require('./helpers')
const diffs = []
const diff = (rapoData, okData, code, field) => {
for (const year of Object.keys(okData)) {
const rapo = rapoData[year]
const ok = okData[year]
if (!rapo) {
if (!ok) console.log(`Year ${year} not in rapo for code ${code}, skipping`)
continue
}
const diff = Math.abs(rapo[field] - ok[field])
const bigger = Math.max(rapo[field], ok[field])
if ((diff > bigger * 0.1 && bigger > 400) || diff > 100) {
console.log(`${code} \t ${year}: ${field} diff: ${diff} \tOk: ${ok[field]} \tRapo: ${rapo[field]}`)
diffs.push({ code, year, diff })
}
}
}
const parseOkData = data => {
return data.tableStats.reduce((obj, cur) => {
const [year, total, major, nonmajor, nondegree, transferred] = cur
obj[year] = { total, basic: major, nonmajor, nondegree, transferred }
return obj
}, {})
}
// Acual logic in this function
const process = async (data, field = 'total') => {
const rapoProgrammeData = formatData(data.slice(1))
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
for (const programmeCode of allProgrammeCodes) {
const okProgrammeData = await getCreditStats(programmeCode, '', 'CALENDAR_YEAR', 'SPECIAL_INCLUDED')
diff(rapoProgrammeData[programmeCode], parseOkData(okProgrammeData), programmeCode, field)
}
console.log(`${diffs.length} diffs found.`)
}
const programmeCreditsDiff = async fileName => {
console.log('Running studyprogramme credits diff')
await parseCsv(fileName, process)
console.log('Diff completed.')
}
// Change weird rapo programmecodes to oodikone format, example: 300-M003 => MH30_003
const transformProgrammeCode = oldCode => `${oldCode[4]}H${oldCode.slice(0, 2)}_${oldCode.slice(5, 8)}`
const formatData = data =>
data.reduce((obj, cur) => {
const parseNum = str => (str === '' ? 0 : parseInt(str, 10))
const [year, facultyCode, programmeInfo, basic, exchange, otherUni, openUni, special, total, abroad, other] = cur
const programmeCode = transformProgrammeCode(programmeInfo.split(' ')[0])
if (!obj[programmeCode]) obj[programmeCode] = {}
obj[programmeCode][year] = {
year,
facultyCode,
programmeInfo,
basic: parseNum(basic),
exchange: parseNum(exchange),
otherUni: parseNum(otherUni),
openUni: parseNum(openUni),
special: parseNum(special),
total: parseNum(total),
abroad: parseNum(abroad),
other: parseNum(other),
}
return obj
}, {})
module.exports = { programmeCreditsDiff }
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable no-console */
const Papa = require('papaparse')
const fs = require('fs/promises')
const parseCsv = async (fileName, fun) => {
const file = await fs.readFile(`${__dirname}/${fileName}`, 'utf8')
Papa.parsePromise = file => {
return new Promise((complete, error) => {
Papa.parse(file, { complete, error })
})
}
const data = await Papa.parsePromise(file)
await fun(data?.data)
}
module.exports = { parseCsv }
+4 -4
View File
@@ -3,6 +3,7 @@
const { bscProgrammesOfFaculties } = require('./conf')
const { facultyDiff } = require('./studyrightDiff')
const { programmeCreditsDiff } = require('./creditsDiff')
const runFacultyStudyrightDiffs = async () => {
console.log('Running faculty diffs.')
@@ -19,18 +20,17 @@ const runCreditDiffs = async () => {
}
// ** To add new mode, just type key and function here **
const modes = {} // { studyrights: runFacultyStudyrightDiffs, credits: runCreditDiffs }
const modes = { credits: () => programmeCreditsDiff('data.csv') } // { studyrights: runFacultyStudyrightDiffs, credits: runCreditDiffs }
const main = async () => {
console.log('**RAPODIFF**\n')
const mode = process.argv[2]
const modeFunction = modes[mode]
if (!modeFunction) {
/*console.log(
console.log(
'Unknown or missing mode. Supply the mode as an argument. Supported modes:\n',
Object.keys(modes).join('\n ')
) */
console.log('Rapodiff disabled: an importer route in api-gateway must be first opened & possibly other fixes.')
)
return
}
await modeFunction()
@@ -6,7 +6,7 @@ const _ = require('lodash')
const { optimizedStatisticsOf } = require('../services/populations')
const { Student, Transfer, StudyrightElement, Studyright } = require('../models')
const { rapoToken, tokenImporterApi, importerDbApiUrl, nodeproxyUrl } = require('./rapodiff.js')
const { rapoToken, tokenImporterApi, importerDbApiUrl, nodeproxyUrl } = require('./conf.js')
const getFromRapo = async (urlStart, urlEnd) => {
console.log({ urlStart, urlEnd })
+2
View File
@@ -17,6 +17,7 @@ const elementdetails = require('./routes/elementdetails')
const teachers = require('./routes/teachers')
const providers = require('./routes/providers')
const faculties = require('./routes/faculties')
const university = require('./routes/university')
const courseExclusions = require('./routes/courseExclusions')
const semesters = require('./routes/semesters')
const feedback = require('./routes/feedback')
@@ -62,6 +63,7 @@ module.exports = (app, url) => {
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
app.use(`${url}/languagecenterdata`, languageCenterData)
app.use(`${url}/faculties`, auth.roles(['facultyStatistics', 'katselmusViewer']), faculties)
app.use(`${url}/university`, university)
app.use(`${url}/updater`, auth.roles(['admin']), updater)
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
app.use(`${url}/users`, auth.roles(['admin']), users)
+1 -195
View File
@@ -1,5 +1,4 @@
const router = require('express').Router()
const { faculties } = require('../services/organisations')
const { combineFacultyBasics } = require('../services/faculty/facultyBasics')
const { combineFacultyCredits } = require('../services/faculty/facultyCredits')
const { combineFacultyThesisWriters } = require('../services/faculty/facultyThesisWriters')
@@ -23,20 +22,11 @@ const {
setFacultyProgressStats,
} = require('../services/faculty/facultyService')
const logger = require('../util/logger')
const { getMedian } = require('../services/studyprogramme/studyprogrammeHelpers')
const { getFacultyList } = require('../services/faculty/facultyHelpers')
// Faculty uses a lot of tools designed for Study programme.
// Some of them have been copied here and slightly edited for faculty purpose.
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'licentiate', 'doctor']
const getFacultyList = async () => {
const ignore = ['Y', 'H99', 'Y01', 'H92', 'H930']
const facultyList = (await faculties()).filter(f => !ignore.includes(f.code))
facultyList.sort((a, b) => (a.name.fi > b.name.fi ? 1 : -1))
return facultyList
}
router.get('/', async (req, res) => {
const facultyList = await getFacultyList()
res.json(facultyList)
@@ -152,190 +142,6 @@ router.get('/:id/progressstats', async (req, res) => {
return res.json(updateStats)
})
router.get('/allprogressstats', async (req, res) => {
const specialGroups = 'SPECIAL_EXCLUDED'
const graduated = req.query?.graduated
const allFaculties = await getFacultyList()
const facultyCodes = allFaculties.map(f => f.code)
const codeToData = {}
for (const facultyCode of facultyCodes) {
const data = await getFacultyProgressStats(facultyCode, specialGroups, graduated)
if (!data) {
return res.status(500).end()
}
codeToData[facultyCode] = data
}
const universityData = {
years: codeToData.H50.years,
yearlyBachelorTitles: codeToData.H50.yearlyBachelorTitles,
programmeNames: allFaculties.reduce((obj, fac) => {
const { name, ...rest } = fac.dataValues
obj[fac.code] = { ...rest, ...name }
return obj
}, {}),
bachelorsProgStats: {},
creditCounts: {
bachelor: {},
},
}
const unifyProgressStats = progStats => {
return progStats.reduce(
(all, prog) => {
prog.forEach((yearStats, yearIndex) =>
yearStats.forEach((category, categoryIndex) => {
all[yearIndex][categoryIndex] += prog[yearIndex][categoryIndex]
})
)
return all
},
// eslint-disable-next-line no-unused-vars
progStats[0].map(year => year.map(_num => 0))
)
}
for (const facultyCode of facultyCodes) {
const facultyData = codeToData[facultyCode]
for (const year of universityData.years.slice(1).reverse()) {
for (const fieldName of degreeNames) {
if (!facultyData.creditCounts[fieldName] || Object.keys(facultyData.creditCounts[fieldName]).length === 0)
continue
if (!universityData.creditCounts[fieldName]) universityData.creditCounts[fieldName] = {}
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
}
for (const fieldName of [
'bachelorsProgStats',
'bcMsProgStats',
'licentiateProgStats',
'mastersProgStats',
'doctoralProgStats',
]) {
if (Object.keys(facultyData[fieldName]).length === 0) continue
if (!universityData[fieldName]) universityData[fieldName] = {}
universityData[fieldName][facultyCode] = unifyProgressStats(Object.values(facultyData[fieldName]))
}
}
}
// Remove ELL bachelor+master progresstats for now, because it is problematic due to different credit limits
// when expanding the table rows to show 'programme'(faculty) specific progress bars
delete universityData.bcMsProgStats['H90']
return res.status(200).json(universityData)
})
router.get('/allgraduationstats', async (req, res) => {
const degreeNames = ['bachelor', 'bcMsCombo', 'master', 'licentiate', 'doctor']
const allFaculties = await getFacultyList()
const facultyCodes = allFaculties.map(f => f.code)
const facultyData = {}
const timesArrays = [] // keep book of these to null them in the end, large lists not used in frontend
for (const facultyCode of facultyCodes) {
const data = await getGraduationStats(facultyCode, 'NEW_STUDY_PROGRAMMES', true)
if (!data) res.status(500).json({ message: `Did not find data for ${facultyCode}` })
facultyData[facultyCode] = data
}
const unifyTotals = (facultyData, universityData, isLast) => {
for (const degree of degreeNames) {
if (!universityData[degree]) universityData[degree] = []
if (!facultyData[degree]) continue
for (const yearStats of facultyData[degree]) {
const universityStats = universityData[degree]
const universityYearStats = universityData[degree].find(stats => stats.name === yearStats.name)
if (!universityYearStats) {
universityStats.push(yearStats)
} else {
universityYearStats.times.push(...yearStats.times)
timesArrays.push(universityYearStats.times)
universityYearStats.amount += yearStats.amount
universityYearStats.statistics.onTime += yearStats.statistics.onTime
universityYearStats.statistics.yearOver += yearStats.statistics.yearOver
universityYearStats.statistics.wayOver += yearStats.statistics.wayOver
if (isLast) {
universityYearStats.y = getMedian(universityYearStats.times)
}
}
}
}
}
const unifyProgrammeStats = (universityData, facultyData, facultyCode) => {
for (const degree of degreeNames) {
if (!facultyData[degree]) continue
if (!universityData[degree]) universityData[degree] = {}
for (const yearData of facultyData[degree]) {
if (!universityData[degree][yearData.name]) {
universityData[degree][yearData.name] = { programmes: [], data: [] }
}
const uniYearStats = universityData[degree][yearData.name]
if (!uniYearStats.programmes.find(prog => prog === facultyCode)) {
uniYearStats.programmes.push(facultyCode)
}
const uniYearFacultyStats = uniYearStats.data.find(item => item.code === facultyCode)
const yearDataClone = { ...yearData, times: null, statistics: { ...yearData.statistics } }
if (!uniYearFacultyStats) {
uniYearStats.data.push({ ...yearDataClone, name: facultyCode, code: facultyCode })
} else {
uniYearFacultyStats.y = yearData.y
uniYearFacultyStats.amount += yearData.amount
uniYearFacultyStats.statistics.onTime += yearData.statistics.onTime
uniYearFacultyStats.statistics.yearOver += yearData.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += yearData.statistics.wayOver
}
}
}
return universityData
}
const universityData = {
goals: {
bachelor: 36,
bcMsCombo: 60,
master: 24,
doctor: 48,
licentiate: 78,
},
programmeNames: allFaculties.reduce((obj, fac) => {
const { name, ...rest } = fac.dataValues
obj[fac.code] = { ...rest, ...name }
return obj
}, {}),
byGradYear: { medians: {}, programmes: { medians: {} } },
classSizes: { programmes: {} },
}
for (let i = 0; i < facultyCodes.length; i++) {
const facultyCode = facultyCodes[i]
const data = facultyData[facultyCode]
unifyTotals(data.byGradYear.medians, universityData.byGradYear.medians, i === facultyCodes.length - 1)
unifyProgrammeStats(universityData.byGradYear.programmes.medians, data.byGradYear.medians, facultyCode)
for (const degree of degreeNames) {
if (!universityData.classSizes[degree]) {
universityData.classSizes[degree] = data.classSizes[degree]
} else {
Object.entries(data.classSizes[degree]).forEach(([key, value]) => {
universityData.classSizes[degree][key] += value
})
}
}
// eslint-disable-next-line no-unused-vars
const { programmes, ...rest } = data.classSizes
universityData.classSizes.programmes[facultyCode] = rest
}
// Empty "times" arrays because that's not needed anymore.
timesArrays.forEach(arr => {
arr.length = 0
})
return res.status(200).json(universityData)
})
router.get('/:id/studentstats', async (req, res) => {
const code = req.params.id
const specialGroups = req.query?.special_groups
+192
View File
@@ -0,0 +1,192 @@
const router = require('express').Router()
const { getFacultyList } = require('../services/faculty/facultyHelpers')
const { getGraduationStats, getFacultyProgressStats } = require('../services/faculty/facultyService')
const { getMedian } = require('../services/studyprogramme/studyprogrammeHelpers')
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'licentiate', 'doctor']
router.get('/allprogressstats', async (req, res) => {
const specialGroups = 'SPECIAL_EXCLUDED'
const graduated = req.query?.graduated
const allFaculties = await getFacultyList()
const facultyCodes = allFaculties.map(f => f.code)
const codeToData = {}
for (const facultyCode of facultyCodes) {
const data = await getFacultyProgressStats(facultyCode, specialGroups, graduated)
if (!data) {
return res.status(500).end()
}
codeToData[facultyCode] = data
}
const universityData = {
years: codeToData.H50.years,
yearlyBachelorTitles: codeToData.H50.yearlyBachelorTitles,
programmeNames: allFaculties.reduce((obj, fac) => {
const { name, ...rest } = fac.dataValues
obj[fac.code] = { ...rest, ...name }
return obj
}, {}),
bachelorsProgStats: {},
creditCounts: {
bachelor: {},
},
}
const unifyProgressStats = progStats => {
return progStats.reduce(
(all, prog) => {
prog.forEach((yearStats, yearIndex) =>
yearStats.forEach((category, categoryIndex) => {
all[yearIndex][categoryIndex] += prog[yearIndex][categoryIndex]
})
)
return all
},
// eslint-disable-next-line no-unused-vars
progStats[0].map(year => year.map(_num => 0))
)
}
for (const facultyCode of facultyCodes) {
const facultyData = codeToData[facultyCode]
for (const year of universityData.years.slice(1).reverse()) {
for (const fieldName of degreeNames) {
if (!facultyData.creditCounts[fieldName] || Object.keys(facultyData.creditCounts[fieldName]).length === 0)
continue
if (!universityData.creditCounts[fieldName]) universityData.creditCounts[fieldName] = {}
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
}
for (const fieldName of [
'bachelorsProgStats',
'bcMsProgStats',
'licentiateProgStats',
'mastersProgStats',
'doctoralProgStats',
]) {
if (Object.keys(facultyData[fieldName]).length === 0) continue
if (!universityData[fieldName]) universityData[fieldName] = {}
universityData[fieldName][facultyCode] = unifyProgressStats(Object.values(facultyData[fieldName]))
}
}
}
// Remove ELL bachelor+master progresstats for now, because it is problematic due to different credit limits
// when expanding the table rows to show 'programme'(faculty) specific progress bars
delete universityData.bcMsProgStats['H90']
return res.status(200).json(universityData)
})
router.get('/allgraduationstats', async (req, res) => {
const degreeNames = ['bachelor', 'bcMsCombo', 'master', 'licentiate', 'doctor']
const allFaculties = await getFacultyList()
const facultyCodes = allFaculties.map(f => f.code)
const facultyData = {}
const timesArrays = [] // keep book of these to null them in the end, large lists not used in frontend
for (const facultyCode of facultyCodes) {
const data = await getGraduationStats(facultyCode, 'NEW_STUDY_PROGRAMMES', true)
if (!data) res.status(500).json({ message: `Did not find data for ${facultyCode}` })
facultyData[facultyCode] = data
}
const unifyTotals = (facultyData, universityData, isLast) => {
for (const degree of degreeNames) {
if (!universityData[degree]) universityData[degree] = []
if (!facultyData[degree]) continue
for (const yearStats of facultyData[degree]) {
const universityStats = universityData[degree]
const universityYearStats = universityData[degree].find(stats => stats.name === yearStats.name)
if (!universityYearStats) {
universityStats.push(yearStats)
} else {
universityYearStats.times.push(...yearStats.times)
timesArrays.push(universityYearStats.times)
universityYearStats.amount += yearStats.amount
universityYearStats.statistics.onTime += yearStats.statistics.onTime
universityYearStats.statistics.yearOver += yearStats.statistics.yearOver
universityYearStats.statistics.wayOver += yearStats.statistics.wayOver
if (isLast) {
universityYearStats.y = getMedian(universityYearStats.times)
}
}
}
}
}
const unifyProgrammeStats = (universityData, facultyData, facultyCode) => {
for (const degree of degreeNames) {
if (!facultyData[degree]) continue
if (!universityData[degree]) universityData[degree] = {}
for (const yearData of facultyData[degree]) {
if (!universityData[degree][yearData.name]) {
universityData[degree][yearData.name] = { programmes: [], data: [] }
}
const uniYearStats = universityData[degree][yearData.name]
if (!uniYearStats.programmes.find(prog => prog === facultyCode)) {
uniYearStats.programmes.push(facultyCode)
}
const uniYearFacultyStats = uniYearStats.data.find(item => item.code === facultyCode)
const yearDataClone = { ...yearData, times: null, statistics: { ...yearData.statistics } }
if (!uniYearFacultyStats) {
uniYearStats.data.push({ ...yearDataClone, name: facultyCode, code: facultyCode })
} else {
uniYearFacultyStats.y = yearData.y
uniYearFacultyStats.amount += yearData.amount
uniYearFacultyStats.statistics.onTime += yearData.statistics.onTime
uniYearFacultyStats.statistics.yearOver += yearData.statistics.yearOver
uniYearFacultyStats.statistics.wayOver += yearData.statistics.wayOver
}
}
}
return universityData
}
const universityData = {
goals: {
bachelor: 36,
bcMsCombo: 60,
master: 24,
doctor: 48,
licentiate: 78,
},
programmeNames: allFaculties.reduce((obj, fac) => {
const { name, ...rest } = fac.dataValues
obj[fac.code] = { ...rest, ...name }
return obj
}, {}),
byGradYear: { medians: {}, programmes: { medians: {} } },
classSizes: { programmes: {} },
}
for (let i = 0; i < facultyCodes.length; i++) {
const facultyCode = facultyCodes[i]
const data = facultyData[facultyCode]
unifyTotals(data.byGradYear.medians, universityData.byGradYear.medians, i === facultyCodes.length - 1)
unifyProgrammeStats(universityData.byGradYear.programmes.medians, data.byGradYear.medians, facultyCode)
for (const degree of degreeNames) {
if (!universityData.classSizes[degree]) {
universityData.classSizes[degree] = data.classSizes[degree]
} else {
Object.entries(data.classSizes[degree]).forEach(([key, value]) => {
universityData.classSizes[degree][key] += value
})
}
}
// eslint-disable-next-line no-unused-vars
const { programmes, ...rest } = data.classSizes
universityData.classSizes.programmes[facultyCode] = rest
}
// Empty "times" arrays because that's not needed anymore.
timesArrays.forEach(arr => {
arr.length = 0
})
return res.status(200).json(universityData)
})
module.exports = router
@@ -289,9 +289,9 @@ const count = async (
y: median,
amount: graduationAmounts[level][year],
/* Include invidivual graduation times for the sake of university-level evaluation overview (UniversityView)
which has to count medians by itself - but this is not needed for startYear as that is not enabled there
which has to count medians by itself
*/
times: mode === 'startYear' ? graduationTimes[level][year] : null,
times: graduationTimes[level][year],
statistics,
name: year,
},
@@ -1,6 +1,14 @@
const Sequelize = require('sequelize')
const { Op } = Sequelize
const { codes } = require('../../../config/programmeCodes')
const { faculties } = require('../organisations')
const getFacultyList = async () => {
const ignore = ['Y', 'H99', 'Y01', 'H92', 'H930']
const facultyList = (await faculties()).filter(f => !ignore.includes(f.code))
facultyList.sort((a, b) => (a.name.fi > b.name.fi ? 1 : -1))
return facultyList
}
const findRightProgramme = (studyrightElements, code) => {
let programme = ''
@@ -140,6 +148,7 @@ const mapCodesToIds = data => {
}
module.exports = {
getFacultyList,
findRightProgramme,
facultyFormatStudyright,
facultyFormatProgramme,
@@ -161,6 +161,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
'admission_type',
'cancelled',
'is_ba_ma',
'semester_enrollments',
],
separate: true,
include: [
+4 -1
View File
@@ -8,7 +8,10 @@ const { combine, timestamp, printf, splat } = winston.format
let transports = []
const formatDate = timestamp => new Date(timestamp).toLocaleTimeString('fi-FI').replace(' klo', '')
const formatDate = timestamp => {
if (!isProduction) return new Date(timestamp).toLocaleTimeString('fi-FI').replace(' klo', '')
return new Date(timestamp).toLocaleString('fi-FI').replace(' klo', '')
}
const consoleFormat = printf(
({ level, message, timestamp }) => `${formatDate(timestamp).split(' ')} ${level}: ${message}`
@@ -13,7 +13,7 @@ export const courseStatisticsToolTips = {
Arvosanajakaumaa katsoessasi voit muuttaa luvut prosenteiksi, klikkaamalla painiketta "Relative".
Mahdollinen sarake "Other passed" sisältää sekä arvosanan "Hyv." saaneet että kurssin hyväksilukeneet.
`,
STUDENT: `
STUDENTS: `
**Table - Students**
Taulukko näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
@@ -35,7 +35,7 @@ export const courseStatisticsToolTips = {
Hylätyt suoritukset sisältävät arvosanat Hyl. ja 0, sekä suoritukset, joille opettaja on antanut merkinnän
"Luop" (Luopunut suorituksesta) tai "Eisa" (Ei saapunut tenttiin).
`,
STUDENT: `
STUDENTS: `
**Pass rate chart - Students**
Graafi näyttää yksittäiset opiskelijat, jotka ovat kurssia suorittaneet.
+5
View File
@@ -281,6 +281,11 @@ export const isMastersProgramme = programmeCode => programmeCode.startsWith('MH'
export const getMonthsForDegree = code => getTargetCreditsForProgramme(code) / (60 / 12)
export const calculatePercentage = (numerator, denominator) =>
new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
numerator / denominator
)
/**
* Get start and end dates for study right element. For bachelor a 3 year target is used
* and for masters a 2 year target. Any absences within the study right element extends the end date.
@@ -9,10 +9,9 @@ export const HelpButton = ({ tab, viewMode }) => {
if (viewMode) {
content = content[viewMode]
content = content.replace(/\n +/g, '\n')
}
content = content.replace(/\n +/g, '\n')
return (
<Popup
on="click"
@@ -189,7 +189,7 @@ const getColumns = (stats, showDetails, showGrades, userHasAccessToAllStats, alt
})
}
export const StudentTable = ({
export const StudentsTable = ({
data: { name, stats },
settings: { showDetails, separate, showGrades },
alternatives,
@@ -231,13 +231,13 @@ export const StudentTable = ({
)
}
StudentTable.propTypes = {
StudentsTable.propTypes = {
data: object.isRequired,
alternatives: arrayOf(string).isRequired,
separate: bool,
userHasAccessToAllStats: bool.isRequired,
}
StudentTable.defaultProps = {
StudentsTable.defaultProps = {
separate: false,
}
@@ -0,0 +1,46 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Menu, Radio } from 'semantic-ui-react'
import { getCourseAlternatives } from '../../../../selectors/courseStats'
import { HelpButton } from '../HelpButton'
import { UnifyRadioButtons } from '../UnifyRadioButtons'
import { AttemptsTable } from './Tables/attempts'
export const AttemptsTableSettings = ({ value, onChange, availableStats, onSeparateChange }) => {
const { showGrades, separate } = value
return (
<div>
<Menu style={{ flexWrap: 'wrap' }} secondary>
<Menu.Item>
<Radio
toggle
label="Show grades"
data-cy="gradeToggle"
checked={showGrades}
onChange={() => onChange({ ...value, showGrades: !showGrades })}
/>
</Menu.Item>
<Menu.Item>
<Radio
toggle
label="Separate by semesters"
data-cy="separateToggle"
checked={separate}
onChange={() => onSeparateChange(!separate)}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode="ATTEMPTS" />
</Menu.Item>
</Menu>
<UnifyRadioButtons availableStats={availableStats} />
</div>
)
}
export const AttemptsTableContent = ({ settings, ...otherProps }) => {
const alternatives = useSelector(getCourseAlternatives)
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
return <AttemptsTable settings={settings} {...otherProps} alternatives={alternatives} unifyCourses={openOrRegular} />
}
@@ -1,16 +1,15 @@
import React from 'react'
import { Menu, Radio } from 'semantic-ui-react'
import ReactHighcharts from 'react-highcharts'
import { HelpButton } from '../HelpButton'
import { Menu, Radio } from 'semantic-ui-react'
import { gradeGraphOptions } from '../../../../constants'
import { HelpButton } from '../HelpButton'
import {
absoluteToRelative,
getDataObject,
getMaxValueOfSeries,
getGradeSpread,
getMaxValueOfSeries,
getThesisGradeSpread,
isThesisSeries,
absoluteToRelative,
} from './util'
const getGradeSeries = series => {
@@ -98,9 +97,9 @@ export const Distribution = ({ data, settings: { isRelative }, userHasAccessToAl
const gradeGraphSeries = getGradeSeries(grades)
const maxGradeValue = isRelative ? 1 : getMaxValueOfSeries(gradeGraphSeries.absolute)
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(gradeGraphSeries.absolute)
const primaryDistributionOptions = gradeGraphOptions(statYears, maxGradeValue, 'Grades')
const primaryDistributionOptions = gradeGraphOptions(isRelative, statYears, maxGradeValue, 'Grades')
return (
<div>
@@ -99,15 +99,13 @@ export const PassRate = ({ data, settings: { viewMode, isRelative }, userHasAcce
const stats = data.stats.filter(stat => stat.name !== 'Total')
const statYears = stats.map(year => year.name)
// const comparisonStats = comparison ? comparison.stats : []
const passGraphSerieFn = isAttemptsMode ? getPassRateAttemptSeriesFromStats : getPassRateStudSeriesFromStats
const passGraphSerie = passGraphSerieFn(stats)
// const comparisonGraphSerie = passGraphSerieFn(comparisonStats)
const maxPassRateVal = isRelative ? 1 : getMaxValueOfSeries(passGraphSerie.absolute)
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(passGraphSerie.absolute)
const graphOptionsFn = isAttemptsMode ? passRateAttemptGraphOptions : passRateStudGraphOptions
const primaryGraphOptions = graphOptionsFn(statYears, maxPassRateVal, 'Pass rate chart', isRelative)
const primaryGraphOptions = graphOptionsFn(isRelative, statYears, maxPassRateVal, 'Pass rate chart', isRelative)
return (
<div>
@@ -1,39 +1,22 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Menu, Radio } from 'semantic-ui-react'
import { viewModeNames } from './util'
import { HelpButton } from '../HelpButton'
import { StudentTable } from './Tables/student'
import { AttemptsTable } from './Tables/attempts'
import { getCourseAlternatives } from '../../../../selectors/courseStats'
import { HelpButton } from '../HelpButton'
import { UnifyRadioButtons } from '../UnifyRadioButtons'
import { StudentsTable } from './Tables/students'
export const TablesSettings = ({ value, onChange, availableStats, onSeparateChange }) => {
const { viewMode, showDetails, showGrades, separate } = value
export const StudentsTableSettings = ({ value, onChange, availableStats, onSeparateChange }) => {
const { showDetails, showGrades, separate } = value
return (
<div>
<Menu style={{ flexWrap: 'wrap' }} 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'}
data-cy="detailToggle"
checked={showDetails}
onChange={() => onChange({ ...value, showDetails: !showDetails })}
/>
@@ -56,9 +39,8 @@ export const TablesSettings = ({ value, onChange, availableStats, onSeparateChan
onChange={() => onSeparateChange(!separate)}
/>
</Menu.Item>
<Menu.Item>
<HelpButton tab="Tables" viewMode={viewMode} />
<HelpButton tab="Tables" viewMode="STUDENTS" />
</Menu.Item>
</Menu>
<UnifyRadioButtons availableStats={availableStats} />
@@ -66,11 +48,8 @@ export const TablesSettings = ({ value, onChange, availableStats, onSeparateChan
)
}
export const Tables = ({ settings, ...otherProps }) => {
export const StudentsTableContent = ({ settings, ...otherProps }) => {
const alternatives = useSelector(getCourseAlternatives)
const viewModes = { ATTEMPTS: AttemptsTable, STUDENT: StudentTable }
const Content = viewModes[settings.viewMode]
const openOrRegular = useSelector(state => state.courseSearch.openOrRegular)
return <Content settings={settings} {...otherProps} alternatives={alternatives} unifyCourses={openOrRegular} />
return <StudentsTable settings={settings} {...otherProps} alternatives={alternatives} unifyCourses={openOrRegular} />
}
@@ -1,4 +1,5 @@
import { flatten } from 'lodash'
import { calculatePercentage } from 'common'
const gradesMap = {
0: 0,
@@ -19,7 +20,7 @@ const gradesMap = {
}
export const viewModeNames = {
STUDENT: 'Students',
STUDENTS: 'Students',
ATTEMPTS: 'Attempts',
}
@@ -39,7 +40,7 @@ export const isThesisGrades = grades => Object.keys(grades).some(k => THESIS_GRA
export const isThesisSeries = series => series && series.some(s => isThesisGrades(s))
export const absoluteToRelative = all => (p, i) => p / all[i] || 0
export const absoluteToRelative = all => (p, i) => parseFloat(calculatePercentage(p, all[i]).slice(0, -1))
export const resolveGrades = stats => {
const failedGrades = ['eisa', 'hyl.', 'hyl', '0', 'luop']
@@ -3,12 +3,12 @@ import { Tab, Segment, Menu, Icon } from 'semantic-ui-react'
import { useHistory, useLocation } from 'react-router-dom'
import qs from 'query-string'
import { useDispatch, useSelector } from 'react-redux'
import { getCourseStats } from 'redux/coursestats'
import { PassRate, PassRateSettings } from './Panes/passRate'
import { Distribution, DistributionSettings } from './Panes/distribution'
import { Tables, TablesSettings } from './Panes/tables'
import { useProgress, useTabs } from '../../../common/hooks'
import { AttemptsTableContent, AttemptsTableSettings } from './Panes/attempts'
import { Distribution, DistributionSettings } from './Panes/distribution'
import { PassRate, PassRateSettings } from './Panes/passRate'
import { StudentsTableContent, StudentsTableSettings } from './Panes/students'
import './resultTabs.css'
@@ -97,23 +97,30 @@ export const ResultTabs = ({ primary, comparison, separate, availableStats }) =>
const paneTypes = [
{
label: 'Tables',
icon: 'table',
initialSettings: { showDetails: false, viewMode: 'STUDENT', separate },
settings: TablesSettings,
component: Tables,
label: 'Students',
icon: 'user',
initialSettings: { showDetails: false, separate },
settings: StudentsTableSettings,
component: StudentsTableContent,
},
{
label: 'Attempts',
icon: 'redo',
initialSettings: { separate },
settings: AttemptsTableSettings,
component: AttemptsTableContent,
},
{
label: 'Pass rate chart',
icon: 'balance',
initialSettings: { viewMode: 'STUDENT', separate },
initialSettings: { viewMode: 'STUDENTS', separate },
settings: PassRateSettings,
component: PassRate,
},
{
label: 'Grade distribution chart',
icon: 'chart bar',
initialSettings: { isRelative: false, viewMode: 'STUDENT' },
initialSettings: { isRelative: false, viewMode: 'STUDENTS' },
settings: DistributionSettings,
component: Distribution,
},
@@ -1,12 +1,11 @@
import React, { useState } from 'react'
import { Divider, Header, Loader, Message } from 'semantic-ui-react'
import {
useGetFacultiesQuery,
useGetAllFacultiesProgressStatsQuery,
useGetAllFacultiesGraduationStatsQuery,
} from 'redux/facultyStats'
import { useGetAllFacultiesProgressStatsQuery, useGetAllFacultiesGraduationStatsQuery } from 'redux/facultyStats'
import { facultyToolTips } from 'common/InfoToolTips'
import { Link } from 'react-router-dom'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { useLanguage } from 'components/LanguagePicker/useLanguage'
import { orderBy } from 'lodash'
import { FacultyProgress } from './FacultyProgress'
import { Toggle } from '../StudyProgramme/Toggle'
import { InfoBox } from '../Info/InfoBox'
@@ -16,14 +15,14 @@ import { FacultyGraduations } from './FacultyGraduations'
export const UniversityView = ({ faculty }) => {
const [graduatedGroup, setGraduatedGroup] = useState(false)
const [medianMode, setMedianMode] = useState(false)
const { isAdmin, roles } = useGetAuthorizedUserQuery()
const userHasFacultyRights = isAdmin || roles.includes('facultyStatistics') || roles.includes('katselmusViewer')
const graduated = graduatedGroup ? 'GRADUATED_EXCLUDED' : 'GRADUATED_INCLUDED'
const allFaculties = useGetFacultiesQuery()
const progressStats = useGetAllFacultiesProgressStatsQuery({
graduated,
})
const { getTextIn } = useLanguage()
const graduationStats = useGetAllFacultiesGraduationStatsQuery()
const getDivider = (title, toolTipText, content, cypress = undefined) => (
<>
<div className="divider">
@@ -35,20 +34,17 @@ export const UniversityView = ({ faculty }) => {
</>
)
if (allFaculties.isLoading || allFaculties.isFetching) {
if (graduationStats.isLoading || graduationStats.isFetching || progressStats.isFetching || progressStats.isLoading) {
return <Loader active style={{ marginTop: '10em' }} />
}
const isFetchingOrLoading =
progressStats.isLoading || progressStats.isFetching || graduationStats.isLoading || graduationStats.isFetching
const isError =
progressStats.isError ||
(progressStats.isSuccess && !progressStats.data) ||
graduationStats.isError ||
!graduationStats.data
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
const allFaculties = Object.values(progressStats.data.programmeNames)
return (
<>
<div style={{ padding: '30px', textAlign: 'center' }}>
@@ -68,34 +64,45 @@ export const UniversityView = ({ faculty }) => {
a target time of 6 years, whereas the other degrees in that category have a target of 5 years.{' '}
</p>
</Message>
<div className="faculty-overview">
{isFetchingOrLoading ? (
<Loader active style={{ marginTop: '15em' }} />
) : (
<div className="programmes-overview">
{progressStats.isSuccess && progressStats.data && (
<>
{getDivider(
'Progress of students of the faculty ',
'BachelorStudentsOfTheFacultyByStartingYear',
facultyToolTips.StudentProgress,
'InfoFacultyProgress'
)}
<div className="toggle-container">
<Toggle
cypress="GraduatedToggle"
toolTips={facultyToolTips.GraduatedToggle}
firstLabel="Graduated included"
secondLabel="Graduated excluded"
value={graduatedGroup}
setValue={setGraduatedGroup}
/>
</div>
<FacultyProgress faculty="ALL" progressStats={progressStats} getDivider={getDivider} />
</>
)}
<div>
{userHasFacultyRights && (
<div>
<p>
<b>Click here to open the corresponding view for an individual faculty</b>
</p>
<div className="facultyLinkBox">
{orderBy(allFaculties, 'code').map(faculty => (
<span key={faculty.code}>
<Link style={{ marginTop: '5px' }} to={`/evaluationoverview/faculty/${faculty.code}`}>{`${
faculty.code
} ${getTextIn(faculty)}`}</Link>
</span>
))}
</div>
</div>
)}
</div>
<div className="faculty-overview">
<div className="programmes-overview">
{getDivider(
'Progress of students of the faculty ',
'BachelorStudentsOfTheFacultyByStartingYear',
facultyToolTips.StudentProgress,
'InfoFacultyProgress'
)}
<div className="toggle-container">
<Toggle
cypress="GraduatedToggle"
toolTips={facultyToolTips.GraduatedToggle}
firstLabel="Graduated included"
secondLabel="Graduated excluded"
value={graduatedGroup}
setValue={setGraduatedGroup}
/>
</div>
<FacultyProgress faculty="ALL" progressStats={progressStats} getDivider={getDivider} />
</div>
{getDivider('Average graduation times', 'AverageGraduationTimes', facultyToolTips.AverageGraduationTimes)}
<div className="toggle-container">
<Toggle
@@ -150,3 +150,12 @@
justify-content: space-between;
padding-bottom: 100px;
}
.facultyLinkBox {
display: grid;
grid-template-rows: repeat(4, 30%);
grid-template-columns: repeat(3, 30%);
row-gap: 0;
column-gap: 8px;
grid-auto-flow: column;
}
@@ -18,8 +18,8 @@ const allNavigationItems = {
{ path: '/study-programme', key: 'overview', label: 'Overview' },
],
},
courseStatistics: { path: '/coursestatistics', key: 'courseStatistics', label: 'Course statistics' },
students: { path: '/students', key: 'students', label: 'Student statistics' },
courseStatistics: { path: '/coursestatistics', key: 'courseStatistics', label: 'Courses' },
students: { path: '/students', key: 'students', label: 'Students' },
teachers: { path: '/teachers', key: 'teachers', label: 'Teachers', reqRights: ['teachers'] },
users: { path: '/users', key: 'users', label: 'Users', reqRights: ['admin'] },
studyGuidanceGroups: {
@@ -52,12 +52,29 @@ export const GeneralTab = ({
if (!populationStatistics || !populationStatistics.elementdetails) return null
const createSemesterEnrollmentsMap = student =>
student.semesterenrollments?.reduce((enrollments, enrollment) => {
const newEnrollmentsObject = { ...enrollments }
newEnrollmentsObject[enrollment.semestercode] = enrollment.enrollmenttype
return newEnrollmentsObject
const studyGuidanceGroupProgrammes =
group?.tags?.studyProgramme && group?.tags?.studyProgramme.includes('+')
? group?.tags?.studyProgramme.split('+')
: [group?.tags?.studyProgramme]
const programmeCode = query?.studyRights?.programme || studyGuidanceGroupProgrammes[0] || customPopulationProgramme
const createSemesterEnrollmentsMap = student => {
let semesterEnrollments
if (programmeCode) {
semesterEnrollments = student.studyrights.find(sr =>
sr.studyright_elements.some(e => e.code === programmeCode)
)?.semester_enrollments
}
semesterEnrollments = semesterEnrollments ?? student.semesterenrollments
return semesterEnrollments.reduce((enrollments, enrollment) => {
enrollments[enrollment.semestercode] = enrollment.enrollmenttype
return enrollments
}, {})
}
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
const students = Object.fromEntries(
@@ -71,21 +88,16 @@ export const GeneralTab = ({
.map(stu => [stu.studentNumber, stu])
)
const getCombinedProgrammeCode = (query, studyGuidangeGroupProgrammes) => {
const getCombinedProgrammeCode = (query, studyGuidanceGroupProgrammes) => {
if (query && query?.studyRights?.combinedProgramme) return query.studyRights.combinedProgramme
if (studyGuidangeGroupProgrammes.length > 1) return studyGuidangeGroupProgrammes[1]
if (studyGuidanceGroupProgrammes.length > 1) return studyGuidanceGroupProgrammes[1]
return ''
}
const queryStudyrights = query ? Object.values(query.studyRights) : []
const cleanedQueryStudyrights = queryStudyrights.filter(sr => !!sr)
const studyGuidangeGroupProgrammes =
group?.tags?.studyProgramme && group?.tags?.studyProgramme.includes('+')
? group?.tags?.studyProgramme.split('+')
: [group?.tags?.studyProgramme]
const programmeCode = query?.studyRights?.programme || studyGuidangeGroupProgrammes[0] || customPopulationProgramme
const combinedProgrammeCode = getCombinedProgrammeCode(query, studyGuidangeGroupProgrammes)
const combinedProgrammeCode = getCombinedProgrammeCode(query, studyGuidanceGroupProgrammes)
const {
studentToStudyrightStartMap,
@@ -64,6 +64,7 @@ export const getSemestersPresentFunctions = ({
}
const graduatedOnSemester = (student, sem, programmeCode) => {
if (!programmeCode) return 0
const firstGraduation = studentToStudyrightEndMap[student.studentNumber]
const secondGraduation = studentToSecondStudyrightEndMap[student.studentNumber]
if (
@@ -81,7 +82,7 @@ export const getSemestersPresentFunctions = ({
return 0
}
const getSemesterEnrollmentsContent = student => {
const getSemesterEnrollmentsContent = (student, studyright) => {
if (allSemesters.length === 0) return ''
if (!student.semesterenrollments) return ''
const semesterIcons = []
@@ -136,10 +137,13 @@ export const getSemestersPresentFunctions = ({
}
for (let sem = firstSemester; sem <= lastSemester; sem++) {
const enrollment = student.semesterEnrollmentsMap
? student.semesterEnrollmentsMap[sem]
: studyright[0].semesterEnrollments.find(e => e.semestercode === sem)?.enrollmenttype
semesterIcons.push(
getSemesterJSX(
sem,
student.semesterEnrollmentsMap[sem],
enrollment,
graduatedOnSemester(student, sem, programmeCode),
`${student.studentNumber}-${sem}`
)
@@ -37,6 +37,9 @@ export const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = fals
return iamGroups.includes('grp-kielikeskus-esihenkilot')
}
if (rest.path.includes('evaluationoverview')) {
if (rest.location.pathname.includes('university')) {
return true
}
return roles?.length > 0 || rights?.length > 0
}
@@ -1,107 +1,119 @@
import React, { useState } from 'react'
import { Icon, Accordion, Table, Popup } from 'semantic-ui-react'
import { array } from 'prop-types'
import _ from 'lodash'
import './studentInfoCard.css'
import { useGetSemestersQuery } from 'redux/semesters'
import { useLanguage } from 'components/LanguagePicker/useLanguage'
import { getSemestersPresentFunctions } from 'components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
const enrolmentTypes = {
1: { text: 'Present', className: 'label-present' },
2: { text: 'Absent', className: 'label-absent' },
3: { text: 'Inactive', className: 'label-passive' },
STATUTORY: { text: 'Absent, statutory', className: 'label-absent-statutory' },
const calculateSemesterEnrollmentsByStudyright = (semestersAndYears, studyrights) => {
const { semesters, years } = semestersAndYears
const programmeNames = {}
let firstYear
const enrollmentsByStudyright = Object.values(studyrights).reduce((enrollments, studyright) => {
// Let's choose the first studyright as they are all linked to the same actual (Sisu) studyright
studyright[0].semesterEnrollments.forEach(enrollment => {
const year = years[semesters[enrollment.semestercode].yearcode]
const enrollmentYear = new Date(year.startdate).getFullYear()
if (!firstYear || enrollmentYear < firstYear) {
firstYear = enrollmentYear
}
const studyrightId = studyright[0].actual_studyrightid
if (!enrollments[studyrightId]) {
enrollments[studyrightId] = {}
}
enrollments[studyrightId][enrollment.semestercode] = enrollment.enrollmenttype
})
studyright.forEach(element => {
const studyrightId = element.actual_studyrightid
if (!programmeNames[studyrightId]) {
programmeNames[studyrightId] = []
}
element.studyright_elements
.filter(elem => elem.element_detail.type === 20)
.forEach(elem => {
programmeNames[studyrightId].push(elem.element_detail.name)
})
})
return enrollments
}, {})
return { enrollmentsByStudyright, programmeNames, firstYear }
}
const helpTexts = {
1: '',
2: 'The registration has been done and the student is not attending to the study term.',
3: 'The registration has not been done and the student is counted as inactive.',
STATUTORY: 'This absence is statutory e.g. parental leave or military service',
}
const curDate = new Date()
const renderSemester = ({ enrollmenttype, statutoryAbsence }) => {
const { text, className } = statutoryAbsence ? enrolmentTypes.STATUTORY : enrolmentTypes[enrollmenttype]
const helpText = helpTexts[enrollmenttype] + (statutoryAbsence ? ` ${helpTexts.STATUTORY}` : '')
return (
<div className="enrollment-container">
{text}
<div className={`enrollment-label ${className}`}> </div>
{enrollmenttype > 1 ? (
<Popup content={helpText} trigger={<Icon className="help-icon" name="question circle outline" />} />
) : null}
</div>
)
}
const getEnrollmentStatus = (semester, season) => {
if (semester.name.en.includes('Spring') && season === 'SPRING') {
return renderSemester(semester)
}
if (semester.name.en.includes('Autumn') && season === 'FALL') {
return renderSemester(semester)
}
return 'Absent'
}
export const EnrollmentAccordion = ({ semesterEnrollments }) => {
export const EnrollmentAccordion = ({ student }) => {
const { data: semestersAndYears } = useGetSemestersQuery()
const [active, setActive] = useState(false)
const { getTextIn } = useLanguage()
const handleAccordionClick = () => {
setActive(!active)
}
if (!semestersAndYears || !student) return null
const groupedEnrollments = _.groupBy(semesterEnrollments, 'yearname')
const { studyrights } = student
const sortedKeys = Object.keys(groupedEnrollments).sort((a, b) => {
return groupedEnrollments[b][0].semestercode - groupedEnrollments[a][0].semestercode
const studyrightsGroupedByStudyright = _.groupBy(
studyrights?.filter(sr => sr.studyright_elements.length > 0),
'actual_studyrightid'
)
const { enrollmentsByStudyright, programmeNames, firstYear } = calculateSemesterEnrollmentsByStudyright(
semestersAndYears,
studyrightsGroupedByStudyright
)
const { getSemesterEnrollmentsContent } = getSemestersPresentFunctions({
allSemesters: Object.values(semestersAndYears.semesters),
allSemestersMap: semestersAndYears.semesters,
year: `${Math.max(new Date().getFullYear() - 7, firstYear)}`,
filteredStudents: [student],
getTextIn,
})
if (curDate < new Date(curDate.getFullYear(), 7, 1)) {
sortedKeys.shift()
}
const semesterEnrollments = Object.values(studyrightsGroupedByStudyright).reduce((acc, studyright) => {
acc[studyright[0].actual_studyrightid] = getSemesterEnrollmentsContent(student, studyright)
return acc
}, {})
return (
<div className={active ? 'enrollmentAccordion' : ''}>
<Accordion>
<Accordion.Title active={active} onClick={handleAccordionClick}>
<Icon name="dropdown" />
Enrollments
</Accordion.Title>
<Accordion.Content active={active}>
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Semester</Table.HeaderCell>
<Table.HeaderCell>Autumn</Table.HeaderCell>
<Table.HeaderCell>Spring</Table.HeaderCell>
<Accordion style={{ marginBottom: active ? '0.5em' : 0 }}>
<Accordion.Title active={active} onClick={() => setActive(!active)}>
<Icon name="dropdown" />
Enrollments
</Accordion.Title>
<Accordion.Content active={active}>
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Programme(s)</Table.HeaderCell>
<Table.HeaderCell>
Semesters
<Popup
position="top center"
trigger={<Icon name="question circle outline" style={{ opacity: 0.5, marginLeft: '0.25em' }} />}
content="Displays enrollment data for the current and up to six previous academic years."
/>
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{Object.entries(enrollmentsByStudyright).map(([studyrightId]) => (
<Table.Row key={studyrightId}>
<Table.Cell>
{_.flatten(programmeNames[studyrightId])
.map(getTextIn)
.map(elem => (
<p key={elem}>{elem}</p>
))}
</Table.Cell>
<Table.Cell>{semesterEnrollments[studyrightId]}</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{sortedKeys.map(key => {
const semester = groupedEnrollments[key]
return semester[0].startYear <= curDate.getFullYear() ? (
<Table.Row key={key}>
<Table.Cell>{key}</Table.Cell>
<Table.Cell>{getEnrollmentStatus(semester[0], 'FALL')}</Table.Cell>
<Table.Cell>
{semester.length > 1
? getEnrollmentStatus(semester[1], 'SPRING')
: getEnrollmentStatus(semester[0], 'SPRING')}
</Table.Cell>
</Table.Row>
) : null
})}
</Table.Body>
</Table>
</Accordion.Content>
</Accordion>
</div>
))}
</Table.Body>
</Table>
</Accordion.Content>
</Accordion>
)
}
EnrollmentAccordion.propTypes = {
semesterEnrollments: array.isRequired,
}
@@ -3,15 +3,16 @@ import { Card, Icon, Button } from 'semantic-ui-react'
import { useSelector, useDispatch } from 'react-redux'
import { useHistory } from 'react-router-dom'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { SisuLinkItem } from 'components/common/SisuLinkItem'
import { reformatDate } from 'common'
import { callApi } from 'apiConnection'
import { useGetAuthorizedUserQuery } from 'redux/auth'
import { removeStudentSelection, resetStudent } from 'redux/students'
import { DISPLAY_DATE_FORMAT, DISPLAY_DATE_FORMAT_DEV } from '../../../constants'
import './studentInfoCard.css'
import { removeStudentSelection, resetStudent } from '../../../redux/students'
import { callApi } from '../../../apiConnection'
import { EnrollmentAccordion } from './EnrollmentAccordion'
import './studentInfoCard.css'
export const StudentInfoCard = ({ student }) => {
const dispatch = useDispatch()
const history = useHistory()
@@ -48,7 +49,7 @@ export const StudentInfoCard = ({ student }) => {
</Card.Meta>
<Card.Description>{`Credits: ${student.credits || 0}`}</Card.Description>
<div style={{ paddingTop: '4px' }}>
<EnrollmentAccordion semesterEnrollments={student.semesterenrollments} />
<EnrollmentAccordion student={student} />
<p style={{ fontSize: 14 }}>{`Updated at ${formattedTimestamp}`}</p>
<Button disabled={updating} compact size="medium" labelPosition="left" onClick={updateStudent}>
<Icon loading={updating} name="refresh" />
@@ -3,41 +3,10 @@
justify-content: space-between !important;
}
.startDate {
margin-top: 3px !important;
}
.controlIcon {
cursor: pointer;
}
.enrollmentAccordion {
margin-bottom: 10px;
}
.help-icon {
opacity: 0.5 !important;
margin-left: 0.75rem !important;
margin-bottom: 5px !important;
}
.enrollment-container {
display: flex;
width: 100%;
align-items: center;
}
.enrollment-label {
border-radius: 0.28rem;
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
display: inline-block;
margin-left: 0.75rem;
width: 15px;
height: 15px;
min-width: 15px;
min-height: 15px;
}
.enrollment-label-no-margin {
border-radius: 0.28rem;
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
@@ -27,6 +27,7 @@ const LinkToGroup = ({ group }) => {
padding: '.78571429em .78571429em',
}}
to={dest}
data-cy={`study-guidance-group-link-${group.id}`}
>
{getTextIn(group.name)}
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
@@ -242,7 +243,9 @@ export const StudyGuidanceGroupOverview = ({ groups }) => {
lisäominaisuuksia.{' '}
</p>
</StyledMessage>
<SortableTable hideHeaderBar columns={headers} data={groups} singleLine={false} />
<div data-cy="Table-study-guidance-group-overview">
<SortableTable hideHeaderBar columns={headers} data={groups} singleLine={false} />
</div>
</>
)
}
@@ -1,14 +1,9 @@
import React, { useMemo } from 'react'
import { Link, useHistory } from 'react-router-dom'
import { Segment, Icon, Item } from 'semantic-ui-react'
import { calculatePercentage } from 'common'
import { SortableTable } from 'components/SortableTable'
const calculatePassrate = (pass, fail) =>
new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
pass / (pass + fail)
)
const createColumnWithTitle = title => ({
key: title,
title: title[0].toUpperCase() + title.slice(1),
@@ -95,7 +90,7 @@ export const TeacherStatisticsTable = ({ statistics, variant }) => {
() =>
statistics.map(stat => ({
...stat,
passrate: calculatePassrate(stat.passed, stat.failed),
passrate: calculatePercentage(stat.passed, stat.passed + stat.failed),
})),
[statistics]
)
+21 -19
View File
@@ -7,102 +7,104 @@ export const DISPLAY_DATE_FORMAT = 'DD.MM.YYYY'
export const DISPLAY_DATE_FORMAT_DEV = 'DD.MM.YYYY HH:mm:ss'
export const API_DATE_FORMAT = 'YYYY.MM.DD'
export const passRateAttemptGraphOptions = (categories, max, title, skipFirstColor) => ({
export const passRateAttemptGraphOptions = (isRelative, categories, max, title, skipFirstColor) => ({
chart: {
type: 'column',
},
colors: skipFirstColor ? [green, red] : [chartblue, green, red],
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: 'Number of Students',
text: isRelative ? 'Share of Students' : 'Number of Students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal',
borderRadius: 3,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
export const passRateStudGraphOptions = (categories, max, title, skipFirstColor) => ({
export const passRateStudGraphOptions = (isRelative, categories, max, title, skipFirstColor) => ({
chart: {
type: 'column',
},
colors: skipFirstColor
? [chartlgreen, chartdarkg, chartlred, chartdarkred]
: [chartblue, chartlgreen, chartdarkg, chartlred, chartdarkred],
title: {
text: title,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: 'Number of Students',
text: isRelative ? 'Share of Students' : 'Number of Students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal',
borderRadius: 1,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
export const gradeGraphOptions = (categories, max, title) => ({
export const gradeGraphOptions = (isRelative, categories, max, title) => ({
chart: {
type: 'column',
},
colors: [red, chartblue, chartblue, chartblue, chartblue, chartblue, green, green],
title: {
text: title,
},
legend: {
enabled: false,
},
xAxis: {
categories,
},
yAxis: {
allowDecimals: false,
title: {
text: 'Number of Students',
text: isRelative ? 'Share of Students' : 'Number of Students',
},
max,
floor: -max,
},
plotOptions: {
column: {
stacking: 'normal',
borderRadius: 2,
},
series: {
tooltip: {
valueSuffix: isRelative ? '%' : '',
},
},
},
})
+2 -2
View File
@@ -26,10 +26,10 @@ const facultystatsApi = RTKApi.injectEndpoints({
`/faculties/${id}/progressstats?special_groups=${specialGroups}&graduated=${graduated}`,
}),
getAllFacultiesProgressStats: builder.query({
query: ({ graduated }) => `/faculties/allprogressstats?graduated=${graduated}`,
query: ({ graduated }) => `/university/allprogressstats?graduated=${graduated}`,
}),
getAllFacultiesGraduationStats: builder.query({
query: () => '/faculties/allgraduationstats',
query: () => '/university/allgraduationstats',
}),
getFacultyStudentStats: builder.query({
query: ({ id, specialGroups, graduated }) =>
+2 -2
View File
@@ -6,10 +6,10 @@ const usersApi = RTKApi.injectEndpoints({
query: () => '/users/email/preview',
}),
sendUserAccessEmail: builder.mutation({
query: ({ recipientAddress }) => ({
query: recipientAddress => ({
url: '/users/email',
method: 'POST',
body: { recipientAddress },
body: { email: recipientAddress },
}),
}),
}),